Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 62a70d7042 Add sequence number logging and duplicate detection
- Log UnfilteredResultCountAfter for each ActionResultResponse to help
  diagnose sync mismatches (gaps in sequence = messages never arrived)
- Add LogFlow sequence counter (#1, #2, #3...) to diagnose duplicate
  logging issue: if same seq# appears twice, two threads are processing
  same message; if seq# increments but lines appear doubled, it's a
  StreamWriter/Logger issue

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 17:04:18 -08:00
238 changed files with 12742 additions and 27779 deletions
-4
View File
@@ -36,7 +36,3 @@ common --java_language_version=17
common --java_runtime_version=remotejdk_17
common --tool_java_language_version=17
common --tool_java_runtime_version=remotejdk_17
# Workspace status for build stamping (git commit, timestamp)
common --workspace_status_command=tools/workspace_status.sh
common --stamp
@@ -1,24 +0,0 @@
name: Deploy OAuth Relay Worker
on:
push:
branches:
- main
paths:
- 'cloudflare/oauth-relay/**'
workflow_dispatch: # Allow manual trigger
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy to Cloudflare Workers
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy Worker
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
workingDirectory: cloudflare/oauth-relay
+3 -92
View File
@@ -11,8 +11,6 @@ on:
- 'src/main/resources/**'
- 'ci/BUILD.bazel'
- 'MODULE.bazel'
- 'docker-compose.prod.yml'
- 'nginx/**'
- '.github/workflows/docker_build.yml'
workflow_dispatch:
inputs:
@@ -350,94 +348,20 @@ jobs:
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
build-jfr-sidecar:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-jfr-sidecar.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build JFR Sidecar Docker image
id: build-jfr-sidecar
run: |
set -ex
# Build JFR sidecar image (Go binary has explicit goos/goarch in BUILD.bazel)
bazel build //ci:jfr_sidecar_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/jfr_sidecar_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 JFR Sidecar image to DO registry
id: push-jfr-sidecar
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
JFR_IMAGE="${{ steps.build-jfr-sidecar.outputs.image_path }}"
echo "Using JFR Sidecar image: $JFR_IMAGE"
if [ -z "$JFR_IMAGE" ] || [ ! -d "$JFR_IMAGE" ]; then
echo "ERROR: JFR Sidecar image not found at: $JFR_IMAGE"
exit 1
fi
# Build the push target to get crane in runfiles
bazel build //ci:jfr_sidecar_push
# Use crane directly for push
CRANE="bazel-bin/ci/push_jfr_sidecar_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/jfr-sidecar:${GIT_SHA}"
echo "Pushing JFR sidecar image: $IMAGE_TAG"
$CRANE push "$JFR_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/jfr-sidecar:latest"
deploy:
runs-on: ubuntu-latest
needs: [build-eagle, build-shardok, build-admin, build-jfr-sidecar]
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 }}
JFR_SIDECAR_IMAGE: ${{ needs.build-jfr-sidecar.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 }}
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -458,7 +382,7 @@ jobs:
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script_stop: true
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,JFR_SIDECAR_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY,JWT_PRIVATE_KEY,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET
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
@@ -466,18 +390,11 @@ jobs:
# Write env vars to .env file for docker-compose
rm -f .env 2>/dev/null || true
cat > .env << EOF
EAGLE_IMAGE=${EAGLE_IMAGE}
SHARDOK_IMAGE=${SHARDOK_IMAGE}
ADMIN_IMAGE=${ADMIN_IMAGE}
JFR_SIDECAR_IMAGE=${JFR_SIDECAR_IMAGE}
OPENAI_API_KEY=${OPENAI_API_KEY:-}
GPT_MODEL_NAME=${GPT_MODEL_NAME:-gpt-4o}
EAGLE_ENABLE_S3=${EAGLE_ENABLE_S3:-false}
DO_SPACES_ACCESS_KEY=${DO_SPACES_ACCESS_KEY:-}
DO_SPACES_SECRET_KEY=${DO_SPACES_SECRET_KEY:-}
JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}
DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}
DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}
EOF
chmod 600 .env
@@ -485,7 +402,7 @@ jobs:
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
# Use exact image tags passed from build jobs (no :latest fallback)
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE, $ADMIN_IMAGE, $JFR_SIDECAR_IMAGE"
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
@@ -512,12 +429,6 @@ jobs:
docker load -i admin.tar
rm admin.tar
echo "Pulling JFR Sidecar image with crane..."
./crane pull "${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar || { echo "ERROR: Failed to pull jfr-sidecar image"; exit 1; }
echo "Loading JFR Sidecar image into Docker..."
docker load -i jfr-sidecar.tar
rm jfr-sidecar.tar
rm ./crane
# Also pull other compose images
+2 -2
View File
@@ -52,14 +52,14 @@ jobs:
- name: Persist Library/
run: ./ci/github_actions/persist_library.sh
- name: Deploy Windows unity
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "/tmp/eagle0/eagle0WIN" "/tmp/unity_manifest.txt"
- name: Update unified manifest
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
+7 -45
View File
@@ -8,18 +8,6 @@ This document tracks the migration from protobuf types to native Scala models in
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
## Recent Completed Work
### GameState Round-Trip Elimination (PRs #4913, #4914, #4915)
Eliminated wasteful Scala→proto→Scala conversions in the hot path:
1. **LLM Pipeline** (#4913): `LlmRequestWithGameState` now uses Scala `GameState` instead of proto. All ~38 prompt generators updated to use Scala model types (`FactionT`, `HeroT`, `ProvinceT`).
2. **ActionWithResultingState Caching** (#4914): Added `precomputedScalaState: Option[GameState]` to cache Scala state when available, avoiding `fromProto()` conversion in `stateAfter()`.
3. **PostResults Simplification** (#4915): Changed `PostResults.gameState` from proto to `Option[GameState]` (Scala), eliminating `toProto()` calls when creating PostResults.
## Migration Pattern
The codebase follows a **Legacy* pattern** for separating proto-dependent and protoless code:
@@ -48,7 +36,6 @@ When migrating a file:
- [x] `ExpandCommandSelector`
- [x] `HeroGiftCommandSelector`
- [x] `ImproveCommandSelector`
- [x] `MarchTowardProvinceCommandChooser` - in AI folder, uses native GameState (callers convert)
- [x] `OrganizeCommandSelector`
- [x] `RansomOfferHelpers`
- [x] `SeekMoreLeadersCommandChooser` - in AI folder, uses native GameState
@@ -67,47 +54,22 @@ When migrating a file:
- [x] `TruceCountQuestCommandChooser`
- [x] `TruceWithFactionQuestCommandChooser`
### Fully Protoless
### Dual (both proto and protoless versions)
- [x] `AIClientUtils` - has protoless overloads (`takenHeroIdsForMarchTowardFocus`, `mostPowerfulHeroes`)
- [x] `ProvinceGoldSurplusCalculator` - fully protoless (callers use converters)
- [x] `HeroSelector` - fully protoless (removed dead `minimallyFatiguedHeroesProto`)
- [~] `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
- Central hub called by many command selectors
- [ ] `AttackDecisionCommandChooser` - uses proto GameState, converts internally
- [ ] `CommandChooser` - trait uses proto GameState in signature
- [ ] `AttackDecisionCommandChooser` - uses proto types
- [ ] `CommandChooser` - uses proto GameState
- [ ] `FulfillQuestsCommandSelector` - takes proto, converts to native immediately
- Called by `MidGameAIClient` which uses proto GameState
## Next Steps
### Phase 1: CommandChoiceHelpers Migration
The main blocker is `CommandChoiceHelpers.scala` which uses proto `GameState` extensively. Strategy:
1. **Add Scala overloads** to `CommandChoiceHelpers` methods that currently take proto GameState
2. **Update internal helpers** to use Scala types where possible
3. **Migrate callers incrementally** - command selectors that are already protoless can switch to Scala overloads
### Phase 2: CommandChooser Trait
Once CommandChoiceHelpers is protoless:
1. Add Scala `GameState` overload to `CommandChooser.choose()` method
2. Update implementations (`AttackDecisionCommandChooser`, etc.) to use Scala internally
3. Eventually deprecate proto overloads
### Phase 3: MidGameAIClient
The top-level AI client still uses proto GameState. Once lower layers are protoless:
1. Convert `MidGameAIClient` to use Scala GameState internally
2. Only convert at the boundary when receiving from/sending to gRPC
## Key Files
### Protoless Model Types
+2 -2
View File
@@ -130,12 +130,12 @@ 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 JDK - includes jcmd for JFR dumps)
# Base image for Eagle (Java 17)
oci.pull(
name = "eclipse_temurin_17",
digest = "sha256:d286b5352d98777bbf727f54038b04f0145cd9b76ca83f38a67aa111d4303748",
image = "docker.io/library/eclipse-temurin",
platforms = ["linux/amd64"],
tag = "17-jdk",
)
# Base image for Shardok (Ubuntu 24.04 for C++ runtime)
+3 -3
View File
@@ -1293,7 +1293,7 @@
"@@rules_oci~//oci:extensions.bzl%oci": {
"general": {
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
"usagesDigest": "uqsqMpE+yaVuTByR2rIA2v1Vkm1JwwuN6nbXOo1W9G0=",
"usagesDigest": "KXZUVR9ea29hTmhxC4+BG0pTXTijLpsFrDVraHG4OyU=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -1306,7 +1306,7 @@
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/eclipse-temurin",
"identifier": "17-jdk",
"identifier": "sha256:d286b5352d98777bbf727f54038b04f0145cd9b76ca83f38a67aa111d4303748",
"platform": "linux/amd64",
"target_name": "eclipse_temurin_17_linux_amd64",
"bazel_tags": []
@@ -1321,7 +1321,7 @@
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/eclipse-temurin",
"identifier": "17-jdk",
"identifier": "sha256:d286b5352d98777bbf727f54038b04f0145cd9b76ca83f38a67aa111d4303748",
"platforms": {
"@@platforms//cpu:x86_64": "@eclipse_temurin_17_linux_amd64"
},
+2 -48
View File
@@ -51,17 +51,13 @@ oci_image(
base = "@eclipse_temurin_17_linux_amd64",
entrypoint = [
"java",
"-Xmx2g",
"-Xmx4g",
"-XX:+UseG1GC",
# JFR profiling support
"-XX:+UnlockDiagnosticVMOptions",
"-XX:+DebugNonSafepoints", # Required for JFR to see through inlined methods
"-XX:FlightRecorderOptions=stackdepth=256",
"-jar",
"/app/eagle_server_deploy.jar",
],
env = {
"JAVA_OPTS": "-Xmx2g -XX:+UseG1GC",
"JAVA_OPTS": "-Xmx4g -XX:+UseG1GC",
},
exposed_ports = ["40032/tcp"],
tars = [
@@ -195,45 +191,3 @@ oci_push(
image = ":admin_server_image",
repository = "registry.digitalocean.com/eagle0/admin-server",
)
#
# JFR Sidecar Docker Image (Go + JDK for jcmd)
#
# This sidecar runs with shared PID namespace to access the Eagle JVM.
# Build: bazel build //ci:jfr_sidecar_image
# Load: bazel run //ci:jfr_sidecar_load
# Push: bazel run //ci:jfr_sidecar_push
#
# Package the Go JFR server binary
pkg_tar(
name = "jfr_sidecar_binary_layer",
srcs = ["//src/main/go/net/eagle0/jfr_server:jfr_server_linux_amd64"],
package_dir = "/app",
)
oci_image(
name = "jfr_sidecar_image",
# Use JDK base image - we need jcmd to dump JFR recordings
base = "@eclipse_temurin_17_linux_amd64",
entrypoint = ["/app/jfr_server_linux_amd64"],
exposed_ports = ["8081/tcp"],
tars = [
":jfr_sidecar_binary_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:jfr_sidecar_load
oci_load(
name = "jfr_sidecar_load",
image = ":jfr_sidecar_image",
repo_tags = ["eagle0/jfr-sidecar:latest"],
)
# Push to DigitalOcean Container Registry
oci_push(
name = "jfr_sidecar_push",
image = ":jfr_sidecar_image",
repository = "registry.digitalocean.com/eagle0/jfr-sidecar",
)
-1
View File
@@ -1 +0,0 @@
node_modules/
-46
View File
@@ -1,46 +0,0 @@
# OAuth Relay Worker
Cloudflare Worker that relays OAuth callbacks to the eagle0:// custom URL scheme.
## Why?
Discord (and some other OAuth providers) don't support custom URL schemes as redirect URIs. This worker acts as a relay:
1. Discord redirects to `https://eagle0-oauth-relay.<account>.workers.dev/oauth/callback?code=xxx&state=yyy`
2. Worker responds with 302 redirect to `eagle0://auth/callback?code=xxx&state=yyy`
3. OS opens the Eagle0 app via deep link
## Deploy
1. Install wrangler: `npm install -g wrangler`
2. Login: `wrangler login`
3. Deploy: `wrangler deploy`
The worker will be available at `https://eagle0-oauth-relay.<your-account>.workers.dev`
## Test Locally
```bash
wrangler dev
# Then in another terminal:
curl -I "http://localhost:8787/oauth/callback?code=test&state=abc"
```
## Test Production
```bash
curl -I "https://eagle0-oauth-relay.<your-account>.workers.dev/oauth/callback?code=test&state=abc"
```
Should return:
```
HTTP/2 302
location: eagle0://auth/callback?code=test&state=abc
```
## OAuth Provider Configuration
In Discord Developer Portal / Google Cloud Console, set the redirect URI to:
```
https://eagle0-oauth-relay.<your-account>.workers.dev/oauth/callback
```
-38
View File
@@ -1,38 +0,0 @@
/**
* OAuth Relay Worker
*
* Receives OAuth callbacks from providers (Discord, Google) and redirects
* to the eagle0:// custom URL scheme for the native app to handle.
*
* Input: GET /oauth/callback?code=xxx&state=yyy
* Output: 302 Redirect to eagle0://auth/callback?code=xxx&state=yyy
*/
export default {
async fetch(request) {
const url = new URL(request.url);
// Only handle /oauth/callback path
if (url.pathname !== '/oauth/callback') {
return new Response('Not Found', { status: 404 });
}
// Build the deep link URL
const deepLink = new URL('eagle0://auth/callback');
// Forward all query parameters
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');
if (code) deepLink.searchParams.set('code', code);
if (state) deepLink.searchParams.set('state', state);
if (error) deepLink.searchParams.set('error', error);
if (errorDescription) deepLink.searchParams.set('error_description', errorDescription);
console.log(`OAuth relay: redirecting to ${deepLink.toString()}`);
return Response.redirect(deepLink.toString(), 302);
}
}
-4
View File
@@ -1,4 +0,0 @@
name = "eagle0-oauth-relay"
main = "worker.js"
compatibility_date = "2024-01-01"
workers_dev = true
-36
View File
@@ -24,13 +24,8 @@ services:
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
volumes:
- ./saves:/app/saves
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
- jvm-tmp:/tmp # Shared with jfr-sidecar for JVM attach socket files
depends_on:
- shardok
restart: unless-stopped
@@ -49,8 +44,6 @@ services:
shardok:
image: ${SHARDOK_IMAGE:-registry.digitalocean.com/eagle0/shardok-server:latest}
container_name: shardok-server
mem_limit: 1g
memswap_limit: 1g # Prevent swap, OOM-kill cleanly instead
ports:
- "40042:40042"
- "40052:40052"
@@ -97,15 +90,12 @@ services:
command:
- "--eagle-addr"
- "eagle:40032"
- "--jfr-sidecar-addr"
- "jfr-sidecar:8081"
- "--http-port"
- "8080"
ports:
- "8080:8080"
depends_on:
- eagle
- jfr-sidecar
restart: unless-stopped
logging:
driver: "json-file"
@@ -119,28 +109,6 @@ services:
retries: 3
start_period: 10s
jfr-sidecar:
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
container_name: jfr-sidecar
# Share PID namespace with Eagle to access its JVM via jcmd
pid: "service:eagle"
volumes:
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
depends_on:
- eagle
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "2"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8081/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
certbot:
image: certbot/certbot
container_name: certbot
@@ -148,7 +116,3 @@ services:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
volumes:
jvm-tmp:
# Shared /tmp for JVM attach socket files between Eagle and jfr-sidecar
-471
View File
@@ -1,471 +0,0 @@
# Admin Server Enhancement Plan
## Overview
This document outlines enhancements to the Go admin server (`src/main/go/net/eagle0/admin_server/`) to provide a proper web UI for game administration.
### Current State
The admin server provides a full web UI with htmx interactivity:
- `GET /` - Redirect to games list
- `GET /games` - Game list page (HTML)
- `GET /games/{id}` - Game detail with action history
- `GET /games/{id}/history` - History rows (htmx partial, infinite scroll)
- `GET /games/{id}/action/{index}` - Action detail (htmx partial)
- `POST /games/{id}/rewind` - Rewind game to target action
- `GET /settings` - Settings list with live search
- `POST /settings/update` - Update setting value
- `GET /health` - Health check (JSON)
- `GET /api/games` - JSON API for programmatic access
- `GET /api/games/{id}/history` - JSON API for history
### Goals
1. **Web UI**: Replace raw JSON with an interactive HTML interface
2. **Settings Management**: View and modify the 275+ game settings at runtime
3. **Game Rewind**: Restore a game to a previous action count
---
## Architecture
### Technology Choice: Go Templates + htmx
**Rationale:**
- Single binary deployment (no separate frontend build)
- htmx provides interactivity without JavaScript framework complexity
- Familiar HTML/CSS, minimal learning curve
- Excellent for admin tools where SEO and bundle size don't matter
**Alternatives Considered:**
- React/Vue SPA: Adds build complexity, separate deployment artifact
- Server-side only: Less interactive, full page reloads
### Directory Structure
```
src/main/go/net/eagle0/admin_server/
├── admin_server.go # Main entry point, HTTP routes
├── handlers/
│ ├── games.go # Game list and detail handlers
│ ├── settings.go # Settings list and update handlers
│ └── rewind.go # Game rewind handlers
├── templates/
│ ├── layout.html # Base layout with nav, htmx includes
│ ├── games/
│ │ ├── list.html # Game list page
│ │ ├── detail.html # Single game view with history
│ │ └── history.html # Partial for history table (htmx)
│ ├── settings/
│ │ ├── list.html # Settings list with search/filter
│ │ └── edit.html # Inline edit partial (htmx)
│ └── rewind/
│ └── confirm.html # Rewind confirmation modal
├── static/
│ ├── style.css # Minimal CSS (Pico CSS or similar)
│ └── htmx.min.js # htmx library
└── BUILD.bazel
```
---
## Feature 1: Web UI
### Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/` | GET | Redirect to `/games` |
| `/games` | GET | Game list page (HTML) |
| `/games/{id}` | GET | Game detail page with history |
| `/games/{id}/history` | GET | History partial (htmx, for infinite scroll) |
| `/api/games` | GET | JSON API (existing, keep for programmatic access) |
| `/api/games/{id}/history` | GET | JSON API (existing) |
### Game List Page
```
┌─────────────────────────────────────────────────────────────┐
│ Eagle Admin [Settings] [Health] │
├─────────────────────────────────────────────────────────────┤
│ │
│ Running Games (3) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Game abc123f Round 45 │ │
│ │ Players: Liu Bei (Human), Cao Cao (AI), Sun Quan │ │
│ │ Actions: 1,234 [View] [Rewind]│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Game def456a Round 12 │ │
│ │ Players: Test Player (Human) │ │
│ │ Actions: 456 [View] [Rewind]│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Game Detail Page
Shows game info and scrollable action history:
- **Reverse chronological order**: Most recent actions displayed first
- Each action shows: index, type, round ID
- **Clickable actions**: Clicking an action row expands to show JSON representation of the full action data
- "Rewind to here" button on each action row
- Infinite scroll loads more history via htmx (loading older actions as user scrolls down)
### Implementation Notes
1. **Embed static files**: Use `//go:embed` to bundle templates and static files
2. **Template functions**: Add helpers for formatting (hex IDs, timestamps, action summaries)
3. **CSS framework**: Use Pico CSS (~10KB) for clean defaults without classes
---
## Feature 2: Settings Management
### New gRPC Endpoints (Eagle Server)
Add to `eagle.proto`:
```protobuf
message Setting {
string name = 1;
string type = 2; // "Int" or "Double"
string value = 3; // Current value as string
string default_value = 4; // Default from BUILD.bazel
string description = 5; // Optional, for UI hints
}
message GetSettingsRequest {
string filter = 1; // Optional name filter (substring match)
}
message GetSettingsResponse {
repeated Setting settings = 1;
}
message UpdateSettingRequest {
string name = 1;
string value = 2;
}
message UpdateSettingResponse {
Setting setting = 1; // Updated setting
string error = 2; // Empty on success
}
service Eagle {
// ... existing methods ...
rpc GetSettings(GetSettingsRequest) returns (GetSettingsResponse);
rpc UpdateSetting(UpdateSettingRequest) returns (UpdateSettingResponse);
}
```
### Eagle Server Implementation
Create a settings registry that:
1. Discovers all `IntSetting` and `DoubleSetting` instances via reflection or explicit registration
2. Provides get/set by name
3. Validates types on update
```scala
// src/main/scala/net/eagle0/eagle/library/settings/SettingsRegistry.scala
object SettingsRegistry {
private val settings: Map[String, Either[IntSetting, DoubleSetting]] = Map(
"ActionVigorCost" -> Left(ActionVigorCost),
"BaseFoodBuyPrice" -> Right(BaseFoodBuyPrice),
// ... register all 275 settings
)
def getAll(filter: Option[String]): Seq[Setting] = ...
def get(name: String): Option[Setting] = ...
def update(name: String, value: String): Either[String, Setting] = ...
}
```
**Alternative: Code generation**
Rather than manually registering 275 settings, modify `setting_rule.bzl` to generate a registry file during build.
### Admin Server Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/settings` | GET | Settings list page with search |
| `/settings/{name}` | GET | Single setting detail (htmx partial) |
| `/settings/{name}` | PUT | Update setting value |
| `/api/settings` | GET | JSON API |
| `/api/settings/{name}` | PUT | JSON API |
### Settings UI
```
┌─────────────────────────────────────────────────────────────┐
│ Eagle Admin [Games] [Health] │
├─────────────────────────────────────────────────────────────┤
│ │
│ Settings [Search: __________ ] │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ActionVigorCost (Int) │ │
│ │ Current: [15 ] Default: 15 [Save] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ BaseFoodBuyPrice (Double) │ │
│ │ Current: [0.5 ] Default: 0.5 [Save] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ... (275 settings, virtualized/paginated) ... │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Considerations
1. **Persistence**: Settings changes are in-memory only. Document that restarts reset to defaults.
2. **Validation**: Validate numeric ranges where applicable (e.g., percentages 0-100)
3. **Categories**: Consider grouping settings by prefix (AI*, Combat*, Economy*, etc.)
4. **Audit log**: Log setting changes with timestamp for debugging
---
## Feature 3: Game Rewind
### Concept
Restore a game to a previous point in its action history. This is useful for:
- Debugging issues that occurred at a specific point
- Testing "what if" scenarios
- Recovering from bugs that corrupted state
### New gRPC Endpoint
Add to `eagle.proto`:
```protobuf
message RewindGameRequest {
int64 game_id = 1;
int32 target_action_count = 2; // Rewind to state after this many actions
}
message RewindGameResponse {
bool success = 1;
string error = 2;
int32 new_action_count = 3;
int32 disconnected_clients = 4; // Number of clients that were disconnected
}
service Eagle {
// ... existing methods ...
rpc RewindGame(RewindGameRequest) returns (RewindGameResponse);
}
```
### Eagle Server Implementation
The `GameHistory` already stores `ActionWithResultingState` for each action, which includes the `GameState` after that action. Rewinding means:
1. **Validate**: Check that `target_action_count` is within valid range (0 to current count)
2. **Get target state**: Retrieve `GameState` at target action count from history
3. **Disconnect clients**: Close all human player connections (they'll need to reconnect)
4. **Replace engine**: Create new `EngineImpl` with target state and truncated history
5. **Reset AI state**: Clear any cached AI state that depends on current game state
```scala
// GameController.scala (pseudocode)
def rewindTo(targetActionCount: Int): Either[String, RewindResult] = {
if (targetActionCount < 0 || targetActionCount > engine.history.count)
return Left(s"Invalid action count: $targetActionCount")
// Get state at target point
val targetState = engine.history.stateAt(targetActionCount)
val truncatedHistory = engine.history.truncateTo(targetActionCount)
// Disconnect all human clients
val disconnectedCount = humanClients.length
humanClients.foreach(_.disconnect("Game rewound by admin"))
// Create new engine at target state
val newEngine = EngineImpl(
gameId = engine.gameId,
currentState = targetState,
history = truncatedHistory,
// ... other fields
)
// Replace controller's engine
this.engine = newEngine
Right(RewindResult(targetActionCount, disconnectedCount))
}
```
### GameHistory Enhancement
Add method to get state at a specific action count:
```scala
trait GameHistory {
// ... existing methods ...
def stateAt(actionCount: Int): GameState = {
if (actionCount == 0) initialState
else all(actionCount - 1).resultingState
}
def truncateTo(actionCount: Int): GameHistory = {
GameHistoryImpl(
initialState = initialState,
actions = all.take(actionCount)
)
}
}
```
### Admin Server Route
| Route | Method | Description |
|-------|--------|-------------|
| `/games/{id}/rewind` | POST | Rewind game (form: `target_action_count`) |
| `/games/{id}/rewind/confirm` | GET | Confirmation modal (htmx partial) |
### Rewind UI Flow
1. User views game history
2. User clicks "Rewind to here" on an action row
3. Confirmation modal appears via htmx:
```
┌─────────────────────────────────────────┐
│ Rewind Game abc123f? │
│ │
│ This will: │
│ • Restore to action 456 (Round 23) │
│ • Discard 778 subsequent actions │
│ • Disconnect 2 connected players │
│ │
│ This cannot be undone. │
│ │
│ [Cancel] [Rewind] │
└─────────────────────────────────────────┘
```
4. On confirm, POST to `/games/{id}/rewind`
5. Success: redirect to game detail showing new state
6. Error: show error message
### Safety Considerations
1. **No undo**: Rewinding discards history. Consider optional backup before rewind.
2. **Client disconnect**: All connected clients are forcibly disconnected.
3. **AI state**: Ensure AI clients restart cleanly after rewind.
4. **Concurrent access**: Lock game during rewind to prevent race conditions.
5. **Authorization**: In production, require admin authentication.
---
## Implementation Phases
### Phase 1: Web UI Foundation
**Status: Complete**
1. ✅ Set up Go templates with `embed`
2. ✅ Add Pico CSS and htmx
3. ✅ Create base layout with navigation
4. ✅ Convert `/games` to HTML with styling
5. ✅ Add game detail page with history table
6. ✅ Implement htmx infinite scroll for history
7. ✅ Reverse history order (most recent first)
8. ✅ Clickable action rows that expand to show JSON representation
9. ✅ Add `/games/{id}/action/{index}` endpoint for fetching action details
**Deliverable**: Browsable game list and history in HTML with clickable action details
### Phase 2: Settings Management
**Status: Complete**
1. ✅ Add `GetSettings` to `eagle.proto` (uses existing `AddSettings` for updates)
2. ✅ Add `getAllSettings` method to auto-generated `SettingsLoader`
3. ✅ Implement `getSettings` in `EagleServiceImpl`
4. ✅ Create settings list page with live search
5. ✅ Add inline editing with htmx
6. ✅ Modified settings are highlighted
**Deliverable**: View and edit settings via admin UI
### Phase 3: Game Rewind
**Status: Complete**
1. ✅ Add `RewindGame` to `eagle.proto`
2. ✅ Implement `stateAt` and `truncateTo` in `GameHistory`
3. ✅ Implement rewind logic in `Engine` and `GameController`
4. ✅ Add rewind confirmation (htmx `hx-confirm` dialog)
5. ✅ Handle client disconnection gracefully
6. ✅ Add rewind button to history rows
7. ✅ Implement `rewindGame` in `GamesManager` and `EagleServiceImpl`
8. ✅ Add admin server `/games/{id}/rewind` POST handler
9. ✅ Add success/error feedback UI
**Deliverable**: Rewind games to any previous action
### Phase 4: Polish
**Status: Not Started**
#### High Priority
1. **Add tests for rewind functionality**
- `PersistedHistory.truncateTo` (handles complex persisted vs recent logic)
- `InMemoryHistory.truncateTo`
- `EngineImpl.rewindTo`
- `GameController.rewindTo`
- `GamesManager.rewindGame`
2. **Improve action history display**
- Human-readable action type names (e.g., "New Round" instead of "NewRoundAction")
- Show acting faction/province when available
- Action summaries from the `summary` field in `GameHistoryEntry`
#### Medium Priority
3. **Settings improvements**
- Group settings by category prefix (AI*, Combat*, Economy*, etc.)
- Show setting descriptions where available
- Pagination for large settings lists
4. **Error handling improvements**
- Better error messages on failed operations
- Retry logic for transient gRPC failures
#### Low Priority (Nice to Have)
5. **Basic auth** - HTTP Basic Auth or OAuth for production use
6. **Audit logging** - Log admin actions with timestamps
7. **Documentation** - Usage guide, deployment notes
#### Future Considerations
- Game creation from admin UI
- Player management (view connected players, force disconnect)
- Export game history to file
- Metrics/stats dashboard
---
## Security Notes
The admin server is intended for local/trusted network use only. For production:
1. **Do not expose to public internet** without authentication
2. Consider adding HTTP Basic Auth or OAuth
3. Run on internal network or behind VPN
4. Log all admin actions for audit trail
---
## Open Questions
1. **Settings persistence**: Should we add optional persistence to disk/database?
2. **Game snapshots**: Should rewind create a backup first?
3. **Multi-admin**: Need locking if multiple admins access simultaneously?
4. **Shardok settings**: Are there Shardok (C++) settings to expose too?
-350
View File
@@ -1,350 +0,0 @@
# OAuth Implementation: Next Steps and Design
## Executive Summary
The OAuth implementation is functional but has several gaps that need addressing before it's production-ready. This document outlines the known issues, proposes a comprehensive user identity model, and provides a prioritized implementation plan.
## Current State (Updated January 2026)
### What Works ✅
- Discord OAuth flow (server-mediated polling)
- Google OAuth flow
- JWT token generation and validation
- User creation and display name setting
- Auto-login with stored tokens
- Basic game creation and play with OAuth users
- Headshot fetching via public CDN (no auth required)
- Logout button in lobby (preserves tokens for quick reconnect)
- Environment (prod/qa) and user display in lobby
- Game identity with userName = displayName (PR #4964 merged)
### Known Issues
#### 1. Game Identity Model Fragility (Deferred)
**Status**: Accepted for now. PR #4964 merged with `userName = displayName`.
**Current behavior**:
- Games store `userNameToFactionId: Map[String, Int]`
- For JWT users, this maps displayName → factionId
- displayName is technically mutable (users could change it)
- No migration path when displayName changes
**Why this is acceptable**:
1. We don't currently have a "change display name" feature
2. The alternative (using userId) requires more extensive changes
3. Can migrate to userId-based identity later if needed
#### 2. In-Game Headshot Fetching ✅ FIXED
**Solution**: Made the `eagle0-headshots` S3 bucket public and enabled CDN.
- Client now fetches directly from `https://eagle0-headshots.sfo3.cdn.digitaloceanspaces.com/`
- No authentication required
- Works for both OAuth and Basic Auth users
- Simpler architecture, no dependency on home Mac server
#### 3. Logout from Lobby ✅ FIXED
**Solution**: Added logout button to lobby UI (PR #4967).
- Button disconnects from server and returns to connection screen
- Intentionally does NOT clear OAuth tokens
- Allows quick reconnect with same account without full OAuth flow
#### 4. Display Name Uniqueness Not Enforced (Medium) - OPEN
**Problem**: User was able to set displayName "nolen" when that name was already taken.
**Root cause**: Unknown - needs investigation. Either:
- The uniqueness check is buggy
- The displayNameIndex wasn't populated correctly during user creation
- Race condition during concurrent registrations
#### 5. Admin Server Crashes ✅ FIXED
**Solution**: PR #4964 sets `userName = displayName` for JWT users.
#### 6. Intermittent "Expired" Errors During Login (Medium) - INVESTIGATING
**Problem**: Users occasionally get "OAuth session expired" errors even when server logs show the callback succeeded.
**Status**: Added diagnostic logging in PR #4974 to trace:
- State creation in `getAuthUrl`
- State lookup in `handleCallback`
- Result lookup in `checkStatus`
**Possible causes**:
- State mismatch between client and server
- Race condition in polling
- Cleanup running at wrong time
#### 7. Token Expiry Field Bug ✅ FIXED
**Problem**: `CheckOAuthStatusResponse.expiresAt` was returning refresh token expiry (30 days) instead of access token expiry (7 days).
**Solution**: Fixed in PR #4974 to calculate correct access token expiry.
---
## Proposed User Identity Model
### Design Principles
1. **Stable Internal Identity**: `userId` (UUID) is the only key used for persistent associations
2. **Display Name is Cosmetic**: Can change without breaking game associations
3. **Backwards Compatibility**: Basic Auth continues to work for local development
4. **Multi-Provider Support**: Users can link Discord, Google, and future providers
5. **Avatar Flexibility**: Use OAuth avatar by default, support custom uploads later
### Data Model
```
User {
userId: String (UUID) // Primary key, immutable, used for all internal references
displayName: String // Unique, user-visible, mutable with migration
displayNameLower: String // Case-insensitive uniqueness
email: String // Primary email for account recovery/linking
avatarUrl: String // Current avatar URL
avatarData: bytes // Cached avatar for offline/fast access (future)
oauthIdentities: [OAuthIdentity]
createdAt: Timestamp
lastLoginAt: Timestamp
isAdmin: Boolean
}
OAuthIdentity {
provider: String // "discord", "google", etc.
providerUserId: String // Provider's user ID
providerEmail: String // Email from this provider
avatarUrl: String // Avatar from this provider
linkedAt: Timestamp
}
```
### Identity Resolution Strategy
The key question: **What should `AuthorizationUtils.userName` return?**
#### Option A: userName = displayName (Current PR #4964)
- **Pro**: Human-readable in logs, game saves, debugging
- **Con**: Breaks if displayName changes
- **Migration**: None needed now, complex later
#### Option B: userName = userId (Recommended)
- **Pro**: Stable identity, displayName changes are safe
- **Con**: UUIDs in logs are ugly, need display name lookup for UI
- **Migration**: Cleaner long-term, but breaking change for any existing OAuth games
#### Option C: Hybrid with Migration Support
- **userName** = userId for new games
- **Legacy lookup** for old games by displayName
- **Display layer** resolves userId → displayName for UI
**Recommendation**: Option B with a display name resolution layer. The ugliness in logs is acceptable for the stability it provides. Implement a `UserService.resolveDisplayName(identifier: String): String` that returns displayName for UUIDs or the identifier itself for legacy usernames.
### Account Linking Strategy
#### Automatic Linking (Future)
When a user logs in with a new OAuth provider:
1. Check if the provider email matches an existing user's email
2. If match found, prompt: "An account exists with this email. Link accounts?"
3. If confirmed, add new OAuthIdentity to existing user
4. If declined, create separate account (different email required)
#### Manual Linking (MVP)
1. User logs in with primary account
2. User goes to Settings → Linked Accounts
3. User clicks "Link Discord" or "Link Google"
4. OAuth flow adds new identity to current user
### Avatar/Headshot Strategy
#### Phase 1: OAuth Avatars (MVP)
- Store `avatarUrl` from OAuth provider during login
- Server proxies avatar requests to avoid CORS issues
- Cache avatars locally with TTL
#### Phase 2: Avatar Caching
- Download avatar to local storage on login
- Serve from local storage for reliability
- Refresh periodically or on login
#### Phase 3: Custom Avatars (Future)
- Allow users to upload custom avatar
- Store in S3/DO Spaces
- Custom avatar overrides OAuth avatar
---
## Implementation Plan
### Phase 1: Stabilization ✅ COMPLETE
#### 1.1 Fix Display Name Uniqueness Bug - OPEN
- [ ] Investigate why "nolen" was allowed when it existed
- [ ] Add logging to `setDisplayName` to trace the issue
- [ ] Ensure `displayNameIndex` is correctly maintained
- [ ] Add unit tests for uniqueness enforcement
#### 1.2 Add Logout Button to Lobby ✅ DONE
- [x] Add "Logout" button to lobby UI
- [x] Disconnect from server
- [x] Navigate to connection screen
- [x] Preserve OAuth tokens for quick reconnect (intentional change from original plan)
#### 1.3 Merge PR #4964 (userName = displayName) ✅ DONE
- [x] Merged - games work with OAuth users
- [x] Documented limitation (games break if displayName changes)
#### 1.4 Fix Headshot Fetching ✅ DONE
- [x] Made eagle0-headshots bucket public
- [x] Client fetches directly from CDN
- [x] No authentication required
#### 1.5 Add Lobby Status Display ✅ DONE
- [x] Show environment (prod/qa) in lobby
- [x] Show current user in lobby (OAuth displayName or classic username)
### Phase 2: Remaining Work (Priority Order)
#### 2.1 Diagnose Intermittent "Expired" Errors - IN PROGRESS
- [x] Add diagnostic logging (PR #4974)
- [ ] Deploy and reproduce the issue
- [ ] Analyze logs to identify root cause
- [ ] Implement fix based on findings
#### 2.2 Fix Display Name Uniqueness
- [ ] Investigate UserService.setDisplayName logic
- [ ] Check displayNameIndex population
- [ ] Add logging to trace the issue
- [ ] Fix the bug and add tests
#### 2.3 Wire Up Lobby UI in Unity
- [ ] Connect `lobbyEnvironmentText` to TextMeshProUGUI in scene
- [ ] Connect `lobbyUserText` to TextMeshProUGUI in scene
#### 2.4 Implement Token Refresh During Gameplay
- [ ] Implement `RefreshToken` RPC on server (currently throws UNIMPLEMENTED)
- [ ] Store refresh tokens server-side for validation
- [ ] Add proactive refresh in client before token expires
- [ ] Handle refresh during reconnection attempts
### Phase 3: Nice-to-Haves (Future)
#### 3.1 Proactive Token Refresh
- [ ] Monitor token expiry in client
- [ ] Refresh automatically when < 5 minutes remaining
- [ ] Update TokenStorage with new access token
#### 3.2 Better Error Messages
- [ ] Distinguish between network errors and auth errors
- [ ] Show user-friendly messages for OAuth failures
- [ ] Add retry suggestions
#### 3.3 Session Persistence Across Server Restarts
- [ ] Move pendingOAuth from in-memory TrieMap to Redis/database
- [ ] Move completedOAuth to Redis with TTL
- [ ] Server can restart without breaking in-flight OAuth flows
#### 3.4 Migrate to userId-based Game Identity (Deferred)
- [ ] Change `AuthorizationUtils.userName` to return `userId` for JWT users
- [ ] Add `UserService.resolveDisplayName(id: String): String` for UI display
- [ ] Update game UI to resolve userIds to displayNames
- [ ] Existing Basic Auth games continue to work (userName is literal)
#### 3.5 Display Name Change Support (Requires 3.4)
- [ ] Add `ChangeDisplayName` RPC
- [ ] Validate new name is unique
- [ ] Update user record
- [ ] No game migration needed (games use userId)
### Phase 3: Multi-Provider Support (Future)
#### 3.1 Account Linking UI
- [ ] Add Settings page with "Linked Accounts" section
- [ ] Show currently linked providers
- [ ] "Link Another Account" button triggers OAuth flow
- [ ] `LinkOAuthProvider` RPC adds identity to current user
#### 3.2 Login Provider Selection
- [ ] If user has multiple providers, any can be used to login
- [ ] All resolve to same userId
- [ ] Session shows which provider was used
#### 3.3 Account Merging (Complex)
- [ ] Handle case where user created separate accounts
- [ ] Merge game history, stats, etc.
- [ ] Delete duplicate user record
- [ ] This is complex - may defer or not implement
### Phase 4: Enhanced Avatars (Future)
#### 4.1 Avatar Caching
- [ ] Download avatars to S3/DO Spaces on login
- [ ] Serve from our CDN
- [ ] Refresh on login if changed
#### 4.2 Custom Avatar Upload
- [ ] Upload endpoint with size/format validation
- [ ] Store in S3/DO Spaces
- [ ] Custom avatar overrides OAuth avatar
---
## Technical Debt to Address
1. **Context Propagation in Futures**: PR #4960 fixed `setDisplayName` and `getCurrentUser`, but audit all `Future` blocks that access `AuthorizationUtils`
2. **Dual Auth Support**: The system supports both Basic Auth and JWT. Consider:
- Should Basic Auth be deprecated for production?
- Should it remain for local development only?
- How do Basic Auth users interact with OAuth users in the same game?
3. **Token Refresh**: `RefreshToken` RPC throws UNIMPLEMENTED. Need to:
- Implement refresh token storage and validation
- Handle token refresh in client
- Consider refresh token rotation for security
4. **Session Management**: No server-side session tracking. Consider:
- Track active sessions per user
- Allow "logout all devices"
- Detect concurrent logins
---
## Open Questions
1. **What happens when a Basic Auth user and OAuth user have the same name?**
- Currently possible - Basic Auth doesn't check UserService
- Could cause confusion in games
- Solution: Require OAuth for multiplayer? Or namespace Basic Auth names?
2. **Should displayName changes be allowed?**
- With userId-based identity, it's safe
- But could cause confusion ("who is this new player?")
- Consider: rate limit changes, show "formerly known as" temporarily
3. **How to handle OAuth provider account deletion?**
- User deletes their Discord account
- Their Eagle0 account still exists
- They can't login unless they linked another provider
- Solution: Encourage linking multiple providers, or add email/password fallback
4. **Admin impersonation with OAuth**
- Currently works via X-Impersonate-User header
- Should this use userId or displayName?
- Probably userId for stability
---
## Appendix: File Locations
### Server (Scala)
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - Token generation/validation
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala` - Auth middleware
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala` - Context accessors
### Client (C#)
- `Assets/Auth/AuthClient.cs` - gRPC client for Auth service
- `Assets/Auth/OAuthManager.cs` - OAuth flow orchestration
- `Assets/Auth/TokenStorage.cs` - Persistent token storage
- `Assets/Auth/JwtAuthInterceptor.cs` - Attaches JWT to requests
### Protos
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth service definition
- `src/main/protobuf/net/eagle0/eagle/internal/user/user.proto` - User data model
-23
View File
@@ -81,29 +81,6 @@ http {
error_page 502 = /error502grpc;
}
# gRPC proxy for Auth service
location /net.eagle0.eagle.api.auth.Auth {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# gRPC proxy
grpc_pass grpc://eagle_grpc;
# Timeouts
grpc_read_timeout 30s;
grpc_send_timeout 30s;
# Error handling
error_page 502 = /error502grpc;
}
# OAuth callback endpoint (proxied to Eagle's HTTP handler)
location /oauth/callback {
proxy_pass http://eagle:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Health check endpoint
location /health {
access_log off;
@@ -25,8 +25,7 @@ auto CalculateTimeBudget(
const PlayerId playerId,
const GameSettingsSPtr &settings,
const GameStateW &state,
const size_t numCommands,
const bool isAllAiBattle) -> AITimeBudget {
const size_t numCommands) -> AITimeBudget {
const auto settingsGetter = settings->GetGetter();
const auto castleCoords = AllCastleCoords(state->hex_map());
@@ -35,10 +34,8 @@ auto CalculateTimeBudget(
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP;
// Get maximum budget cap from settings (in seconds)
// For all-AI battles, use the faster budget limit
const double maxBudgetSeconds =
isAllAiBattle ? settingsGetter.Backing().all_ai_battle_time_budget_maximum()
: settingsGetter.Backing().lookahead_time_budget_maximum_seconds();
settingsGetter.Backing().lookahead_time_budget_maximum_seconds();
const double maxBudgetMs = maxBudgetSeconds * 1000.0;
// During setup, use the setup-specific time budget
@@ -38,13 +38,11 @@ struct AITimeBudget {
// Calculate time budget based on proximity to enemies and castles
// Time budget is calculated dynamically based on number of available commands:
// budget = msPerCommand × numCommands (clamped to 200-5000ms)
// If isAllAiBattle is true, uses allAiBattleTimeBudgetMaximum instead of the normal maximum.
auto CalculateTimeBudget(
PlayerId playerId,
const GameSettingsSPtr &settings,
const GameStateW &state,
size_t numCommands,
bool isAllAiBattle) -> AITimeBudget;
size_t numCommands) -> AITimeBudget;
} // namespace shardok
@@ -53,7 +53,6 @@ auto RoundsRemaining(const GameSettingsSPtr &settings, const GameStateView &gsv)
ShardokAIClient::ShardokAIClient(
const PlayerId playerId,
const bool isDefender,
const bool isAllAiBattle,
const HexMap *hexMap,
const SettingsGetter &settings,
const AIAlgorithmType aiAlgorithmType,
@@ -61,7 +60,6 @@ ShardokAIClient::ShardokAIClient(
const mcts::MCTSConfig &mctsConfig)
: playerId(playerId),
isDefender(isDefender),
isAllAiBattle(isAllAiBattle),
aiAlgorithmType(aiAlgorithmType),
scoringCalculatorType(scoringCalculatorType),
alCache(std::make_unique<AttackLocationsCache>(hexMap, settings)),
@@ -137,8 +135,7 @@ auto ShardokAIClient::StandardChooseCommandIndex(
const auto commandCount = guessedCommands->size();
// Calculate time budget based on game situation using new dynamic per-command settings
const auto timeBudget =
CalculateTimeBudget(playerId, settings, guessedState, commandCount, isAllAiBattle);
const auto timeBudget = CalculateTimeBudget(playerId, settings, guessedState, commandCount);
// Configure MCTS based on proximity to enemy
// When far from enemy: use AVERAGING with maxPlayerFlips=0 (single-player lookahead)
@@ -41,7 +41,6 @@ class ShardokAIClient {
private:
const PlayerId playerId;
const bool isDefender;
const bool isAllAiBattle; // Whether this battle has only AI players (for faster time budgets)
const AIAlgorithmType aiAlgorithmType;
const ScoringCalculatorType scoringCalculatorType;
@@ -70,7 +69,6 @@ public:
explicit ShardokAIClient(
PlayerId playerId,
bool isDefender,
bool isAllAiBattle,
const HexMap* hexMap,
const SettingsGetter& settings,
AIAlgorithmType aiAlgorithmType,
@@ -24,6 +24,9 @@ using std::scoped_lock;
using std::string;
using std::unique_lock;
using std::weak_ptr;
using std::chrono::duration;
static constexpr duration kWaitForUpdatesDuration = std::chrono::milliseconds(5000);
using net::eagle0::shardok::common::GameStatus;
@@ -71,17 +74,11 @@ vector<shared_ptr<ShardokAIClient>> ShardokGameController::MakeAIClients(
mctsConfig.maxSimulationFlips = 1;
mctsConfig.simulationPolicy = mcts::MCTSSimulationPolicy::WEIGHTED_HEURISTIC;
// Check if all players are AI - if so, use faster time budgets
const auto &playerInfos = e->GetPlayerInfos();
const bool isAllAiBattle =
std::ranges::all_of(playerInfos, [](const auto &pi) { return pi.is_ai(); });
for (const auto &pi : playerInfos) {
for (const auto &pi : e->GetPlayerInfos()) {
if (pi.is_ai()) {
auto newClient = std::make_shared<ShardokAIClient>(
pi.player_id(),
pi.is_defender(),
isAllAiBattle,
e->GetCurrentGameState()->hex_map(),
e->GetGameSettings()->GetGetter(),
AIAlgorithmType::ITERATIVE_DEEPENING,
@@ -281,9 +278,15 @@ auto ShardokGameController::GetUpdates(const int64_t startingActionId) -> AllUpd
awrs = engine->GetGameHistory(startingActionId);
incomingRegistrations--;
// Note: Previously this had a 5-second wait for AI players to support long-polling.
// With streaming (WaitForUpdatesAndPush), the caller already waits for updates,
// so this wait is no longer needed. GetGameStatus is deprecated in favor of streaming.
// If the current player is an AI, wait for some results to post. Otherwise go ahead and
// return, we might be telling the caller about available commands.
if (awrs.empty() && !engine->GameIsOver() &&
engine->GetPlayerInfos()[engine->GetCurrentPlayerId()].is_ai()) {
updateCondition.wait_for(guard, kWaitForUpdatesDuration);
incomingRegistrations++;
awrs = engine->GetGameHistory(startingActionId);
incomingRegistrations--;
}
updates.mainResults.reserve(awrs.size());
std::ranges::transform(
@@ -403,10 +406,12 @@ auto ShardokGameController::WaitForUpdatesAndPush(
}
// Lock released - GetUpdates will acquire its own lock
// IMPORTANT: Send any pending updates FIRST, including the final actions
// that caused the game to end. Previously, we returned early on gameOver
// without sending these final updates, causing the client to never see
// the last few battle actions.
if (gameOver) {
subscriber->OnGameOver(gameOverInfo);
return true;
}
// Get updates outside the lock (GetUpdates acquires masterLock internally)
AllUpdates updates = GetUpdates(lastPushedActionId);
lastPushedActionId = updates.newUnfilteredCount;
@@ -417,12 +422,6 @@ auto ShardokGameController::WaitForUpdatesAndPush(
updates.newUnfilteredCount,
updates.currentGameState);
}
// Now send gameOver notification after all updates have been sent
if (gameOver) {
subscriber->OnGameOver(gameOverInfo);
return true;
}
}
return false; // Subscriber disconnected
@@ -322,6 +322,23 @@ void EagleInterfaceImpl::PopulateGameStatusResponse(
}
}
auto EagleInterfaceImpl::GetGameStatus(
ServerContext * /*context*/,
const GameStatusRequest *request,
GameStatusResponse *response) -> Status {
shared_ptr<ShardokGameController> controller;
try {
controller = ControllerForGame(request->game_id(), request->game_setup_info());
} catch (NewGameException &e) { return e.GetStatus(); }
PopulateGameStatusResponse(
controller,
request->game_setup_info().known_result_count(),
response);
return Status::OK;
}
auto EagleInterfaceImpl::GetHexMap(
ServerContext * /*context*/,
const HexMapRequest *request,
@@ -31,6 +31,7 @@ namespace shardok {
using grpc::ServerContext;
using grpc::Status;
using net::eagle0::common::GameSetupInfo;
using net::eagle0::common::GameStatusRequest;
using net::eagle0::common::GameStatusResponse;
using net::eagle0::common::GameSubscriptionRequest;
using net::eagle0::common::HexMapNamesRequest;
@@ -67,6 +68,10 @@ public:
ServerContext* context,
const PlacementCommandsRequest* request,
GameStatusResponse* response) -> Status override;
auto GetGameStatus(
ServerContext* context,
const GameStatusRequest* request,
GameStatusResponse* response) -> Status override;
auto GetHexMap(ServerContext* context, const HexMapRequest* request, HexMapResponse* response)
-> Status override;
@@ -68,7 +68,6 @@
<Compile Include="Assets/Eagle/CommandSelectors/ResolveInvitationCommandSelector.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Slider/SliderManager.cs" />
<Compile Include="Assets/common/GUIUtils/TableRowHoverDetector.cs" />
<Compile Include="Assets/ConnectionHandler/DropGameConfirmationPanel.cs" />
<Compile Include="Assets/Eagle/Table Rows/DynamicHeroTextUpdater.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/util/HeroDropdownController.cs" />
<Compile Include="Assets/Eagle/Notifications/PrisonerReturnedNotificationGenerator.cs" />
@@ -126,7 +125,6 @@
<Compile Include="Assets/Eagle/Notifications/Notification.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/QuestFailedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/QuestFulfilledDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Auth/OAuthManager.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ProfessionGainedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/TextureList.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Modal Window/ModalWindowManager.cs" />
@@ -148,7 +146,6 @@
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ProvinceHeldDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Bluetooth/DieInfo.cs" />
<Compile Include="Assets/common/ResourceFetcher.cs" />
<Compile Include="Assets/Auth/AuthClient.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/TruceRejectedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Notifications/RiotSuppressedNotificationGenerator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Modal Window/ModalWindowManagerEditor.cs" />
@@ -156,7 +153,6 @@
<Compile Include="Assets/Bluetooth/NativeDiceInterfaceImports.cs" />
<Compile Include="Assets/Eagle/Notifications/ProvinceConqueredNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Table Rows/ExtraTroopsRowController.cs" />
<Compile Include="Assets/Auth/JwtAuthInterceptor.cs" />
<Compile Include="Assets/Eagle/CommandPanelController.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ShatteredArmyDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/TurnHistoryPanelController.cs" />
@@ -262,7 +258,6 @@
<Compile Include="Assets/Eagle/CommandSelectors/ResolveTruceCommandSelector.cs" />
<Compile Include="Assets/Eagle/ClientPregeneratedText.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/SwearBrotherhoodCommandSelector.cs" />
<Compile Include="Assets/Auth/TokenStorage.cs" />
<Compile Include="Assets/Eagle/Table Rows/TableRowController.cs" />
<Compile Include="Assets/Eagle/DisplayNames.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerSliderEditor.cs" />
@@ -368,7 +363,6 @@
<Compile Include="Assets/Eagle/Table Rows/AvailableHeroTableRow.cs" />
<Compile Include="Assets/Eagle/Table Rows/HeroRowController.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerTooltip.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/NewFactionHeadDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/HexGrid.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Tooltip/TooltipManager.cs" />
<None Include="Assets/TextMesh Pro/Resources/Shaders/TMPro.cginc" />
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 683e069fbc3074e35ac6fc3881a03f4a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,161 +0,0 @@
using System;
using System.Threading.Tasks;
using Cysharp.Net.Http;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Grpc.Net.Client;
using Net.Eagle0.Eagle.Api.Auth;
using UnityEngine;
using GrpcAuthClient = Net.Eagle0.Eagle.Api.Auth.Auth.AuthClient;
namespace Auth {
/// <summary>
/// gRPC client for the Auth service.
/// Handles OAuth URL generation, status polling, and token refresh.
/// </summary>
public class AuthClient : IDisposable {
private const int PollIntervalMs = 2000; // Poll every 2 seconds
private const int PollTimeoutMs = 300000; // 5 minute timeout
private readonly GrpcAuthClient _client;
private readonly GrpcChannel _channel;
/// <summary>
/// Create auth client for the given server URL.
/// </summary>
/// <param name="serverUrl">Full URL with scheme, e.g. "http://localhost:40032" or
/// "https://prod.eagle0.net"</param>
public AuthClient(string serverUrl) {
_channel = GrpcChannel.ForAddress(
serverUrl,
new GrpcChannelOptions {
HttpHandler = new YetAnotherHttpHandler { Http2Only = true },
DisposeHttpClient = true
});
// Create invoker with JWT interceptor for authenticated requests
CallInvoker invoker = _channel.Intercept(new JwtAuthInterceptor());
_client = new GrpcAuthClient(invoker);
}
/// <summary>
/// Get OAuth URL to open in system browser.
/// Returns both the URL and the state token for polling.
/// </summary>
/// <param name="provider">Discord or Google</param>
/// <returns>Tuple of (URL to open in browser, state token for polling)</returns>
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(OAuthProvider provider) {
var request = new GetOAuthUrlRequest { Provider = provider };
var response = await _client.GetOAuthUrlAsync(request);
Debug.Log($"[AuthClient] Got OAuth URL for {provider}, state={response.State}");
return (response.AuthUrl, response.State);
}
/// <summary>
/// Poll for OAuth completion. Blocks until success, failure, or timeout.
/// The server handles the OAuth callback and token exchange.
/// </summary>
/// <param name="state">State token from GetOAuthUrlAsync</param>
/// <returns>Response with tokens and user info on success</returns>
public async Task<CheckOAuthStatusResponse> PollForOAuthCompletionAsync(string state) {
var startTime = DateTime.UtcNow;
while (true) {
var request = new CheckOAuthStatusRequest { State = state };
var response = await _client.CheckOAuthStatusAsync(request);
switch (response.Status) {
case OAuthStatus.Success:
Debug.Log(
$"[AuthClient] OAuth successful, user={response.User?.DisplayName}, isNew={response.IsNewUser}");
return response;
case OAuthStatus.Failed:
throw new Exception($"OAuth failed: {response.ErrorMessage}");
case OAuthStatus.Expired:
throw new Exception("OAuth session expired. Please try again.");
case OAuthStatus.Pending:
// Check timeout
if ((DateTime.UtcNow - startTime).TotalMilliseconds > PollTimeoutMs) {
throw new TimeoutException("OAuth login timed out. Please try again.");
}
// Wait before polling again
await Task.Delay(PollIntervalMs);
break;
default: throw new Exception($"Unknown OAuth status: {response.Status}");
}
}
}
/// <summary>
/// Refresh access token using stored refresh token.
/// </summary>
/// <returns>New access token and expiry, or null if refresh failed</returns>
public async Task<RefreshTokenResponse> RefreshTokenAsync() {
var refreshToken = TokenStorage.RefreshToken;
if (string.IsNullOrEmpty(refreshToken)) {
throw new InvalidOperationException("No refresh token available");
}
var request = new RefreshTokenRequest { RefreshToken = refreshToken };
var response = await _client.RefreshTokenAsync(request);
// Update stored access token
TokenStorage.UpdateAccessToken(response.AccessToken, response.ExpiresAt);
Debug.Log($"[AuthClient] Token refreshed, expires at {response.ExpiresAt}");
return response;
}
/// <summary>
/// Set display name for new users.
/// Requires valid JWT in interceptor.
/// </summary>
public async Task<SetDisplayNameResponse> SetDisplayNameAsync(string displayName) {
var request = new SetDisplayNameRequest { DisplayName = displayName };
var response = await _client.SetDisplayNameAsync(request);
if (response.Success) {
TokenStorage.UpdateDisplayName(displayName);
Debug.Log($"[AuthClient] Display name set to: {displayName}");
} else {
Debug.LogWarning(
$"[AuthClient] Failed to set display name: {response.ErrorMessage}");
}
return response;
}
/// <summary>
/// Get current user info. Validates the stored JWT.
/// </summary>
public async Task<GetCurrentUserResponse> GetCurrentUserAsync() {
var response = await _client.GetCurrentUserAsync(new GetCurrentUserRequest());
Debug.Log($"[AuthClient] Current user: {response.User?.DisplayName}");
return response;
}
/// <summary>
/// Logout - invalidates refresh token on server.
/// </summary>
public async Task LogoutAsync() {
try {
await _client.LogoutAsync(new LogoutRequest());
} catch (Exception ex) {
Debug.LogWarning($"[AuthClient] Logout RPC failed (may be expected): {ex.Message}");
}
// Clear local tokens regardless of server response
TokenStorage.Clear();
Debug.Log("[AuthClient] Logged out, tokens cleared");
}
public void Dispose() { _channel?.Dispose(); }
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 4ba94126c2d2145b8875612eaf5bc371
@@ -1,75 +0,0 @@
using System;
using Grpc.Core;
using Grpc.Core.Interceptors;
namespace Auth {
/// <summary>
/// gRPC interceptor that attaches JWT Bearer token to requests.
/// Reads token from TokenStorage on each request.
/// </summary>
public class JwtAuthInterceptor : Interceptor {
private const string HeaderName = "Authorization";
private string GetBearerHeader() {
var token = TokenStorage.AccessToken;
return string.IsNullOrEmpty(token) ? null : $"Bearer {token}";
}
public override TResponse BlockingUnaryCall<TRequest, TResponse>(
TRequest request,
ClientInterceptorContext<TRequest, TResponse> context,
BlockingUnaryCallContinuation<TRequest, TResponse> continuation) {
return continuation(request, ContextWithHeaders(context));
}
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
TRequest request,
ClientInterceptorContext<TRequest, TResponse> context,
AsyncUnaryCallContinuation<TRequest, TResponse> continuation) {
return continuation(request, ContextWithHeaders(context));
}
public override AsyncServerStreamingCall<TResponse>
AsyncServerStreamingCall<TRequest, TResponse>(
TRequest request,
ClientInterceptorContext<TRequest, TResponse> context,
AsyncServerStreamingCallContinuation<TRequest, TResponse> continuation) {
return continuation(request, ContextWithHeaders(context));
}
public override AsyncClientStreamingCall<TRequest, TResponse>
AsyncClientStreamingCall<TRequest, TResponse>(
ClientInterceptorContext<TRequest, TResponse> context,
AsyncClientStreamingCallContinuation<TRequest, TResponse> continuation) {
return continuation(ContextWithHeaders(context));
}
public override AsyncDuplexStreamingCall<TRequest, TResponse>
AsyncDuplexStreamingCall<TRequest, TResponse>(
ClientInterceptorContext<TRequest, TResponse> context,
AsyncDuplexStreamingCallContinuation<TRequest, TResponse> continuation) {
return continuation(ContextWithHeaders(context));
}
private ClientInterceptorContext<TRequest, TResponse>
ContextWithHeaders<TRequest, TResponse>(
ClientInterceptorContext<TRequest, TResponse> original)
where TRequest : class
where TResponse : class {
var bearerHeader = GetBearerHeader();
if (string.IsNullOrEmpty(bearerHeader)) {
// No token available - return original context
// Auth service endpoints don't require token
return original;
}
var headers = new Metadata();
headers.Add(HeaderName, bearerHeader);
return new ClientInterceptorContext<TRequest, TResponse>(
original.Method,
original.Host,
original.Options.WithHeaders(headers));
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 8528d8ee967e042ecb2a5e8285c425e0
@@ -1,183 +0,0 @@
using System;
using System.Threading.Tasks;
using Net.Eagle0.Eagle.Api.Auth;
using UnityEngine;
namespace Auth {
/// <summary>
/// Manages OAuth authentication flow using server-mediated polling:
/// - Opens system browser for OAuth consent
/// - Polls server for OAuth completion (no deep links needed)
/// - Token storage and refresh
/// </summary>
public class OAuthManager : MonoBehaviour {
public static OAuthManager Instance { get; private set; }
private AuthClient _authClient;
private string _currentServerUrl;
// Events for UI updates
public event Action<UserInfo> OnLoginSuccess;
public event Action<string> OnLoginFailed;
public event Action OnLogout;
public event Action<bool> OnNewUserNeedsDisplayName; // true if new user
public bool IsAuthenticated => TokenStorage.HasValidToken;
public string DisplayName => TokenStorage.DisplayName;
public string UserId => TokenStorage.UserId;
private void Awake() {
if (Instance != null && Instance != this) {
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
// AuthClient is created lazily when SetServerUrl is called
}
/// <summary>
/// Set the server URL for OAuth requests. Call this before any OAuth operations.
/// </summary>
/// <param name="serverUrl">Full URL with scheme, e.g. "https://prod.eagle0.net"</param>
public void SetServerUrl(string serverUrl) {
if (_currentServerUrl == serverUrl && _authClient != null) {
return; // Already configured for this URL
}
_authClient?.Dispose();
_currentServerUrl = serverUrl;
_authClient = new AuthClient(serverUrl);
Debug.Log($"[OAuthManager] Configured for server: {serverUrl}");
}
private void OnDestroy() {
_authClient?.Dispose();
if (Instance == this) { Instance = null; }
}
private void EnsureConfigured() {
if (_authClient == null) {
throw new InvalidOperationException(
"OAuthManager not configured. Call SetServerUrl() first.");
}
}
/// <summary>
/// Start OAuth login with specified provider.
/// Opens system browser for user consent, then polls for completion.
/// </summary>
public async Task<CheckOAuthStatusResponse> LoginAsync(OAuthProvider provider) {
EnsureConfigured();
try {
// Get OAuth URL and state from server
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider);
// Open system browser
Debug.Log($"[OAuthManager] Opening browser: {authUrl}");
Application.OpenURL(authUrl);
// Poll for OAuth completion (server handles the callback)
var response = await _authClient.PollForOAuthCompletionAsync(state);
// Store tokens
TokenStorage.StoreTokens(
response.AccessToken,
response.RefreshToken,
response.ExpiresAt,
response.User.UserId,
response.User.DisplayName ?? "");
// Notify listeners
if (response.IsNewUser) {
OnNewUserNeedsDisplayName?.Invoke(true);
} else {
OnLoginSuccess?.Invoke(response.User);
}
return response;
} catch (Exception ex) {
Debug.LogError($"[OAuthManager] Login failed: {ex.Message}");
OnLoginFailed?.Invoke(ex.Message);
throw;
}
}
/// <summary>
/// Set display name for new user after OAuth.
/// </summary>
public async Task<bool> SetDisplayNameAsync(string displayName) {
EnsureConfigured();
var response = await _authClient.SetDisplayNameAsync(displayName);
if (response.Success) { OnLoginSuccess?.Invoke(response.User); }
return response.Success;
}
/// <summary>
/// Try to restore session from stored tokens.
/// Returns true if valid session exists.
/// Must call SetServerUrl() before this method.
/// </summary>
public async Task<bool> TryRestoreSessionAsync() {
if (_authClient == null) {
Debug.Log("[OAuthManager] Not configured yet, skipping session restore");
return false;
}
if (!TokenStorage.HasValidToken && !TokenStorage.HasRefreshToken) {
Debug.Log("[OAuthManager] No stored session");
return false;
}
// If token is expired but we have refresh token, try to refresh
if (!TokenStorage.HasValidToken && TokenStorage.HasRefreshToken) {
try {
await _authClient.RefreshTokenAsync();
} catch (Exception ex) {
Debug.LogWarning($"[OAuthManager] Token refresh failed: {ex.Message}");
TokenStorage.Clear();
return false;
}
}
// Validate session with server
try {
var response = await _authClient.GetCurrentUserAsync();
Debug.Log($"[OAuthManager] Session restored for {response.User.DisplayName}");
OnLoginSuccess?.Invoke(response.User);
return true;
} catch (Exception ex) {
Debug.LogWarning($"[OAuthManager] Session validation failed: {ex.Message}");
TokenStorage.Clear();
return false;
}
}
/// <summary>
/// Refresh access token if needed.
/// Call this before making API requests.
/// </summary>
public async Task EnsureValidTokenAsync() {
EnsureConfigured();
if (TokenStorage.NeedsRefresh && TokenStorage.HasRefreshToken) {
await _authClient.RefreshTokenAsync();
}
}
/// <summary>
/// Logout and clear stored tokens.
/// </summary>
public async Task LogoutAsync() {
// LogoutAsync can work even without server connection - just clear local tokens
if (_authClient != null) {
await _authClient.LogoutAsync();
} else {
TokenStorage.Clear();
}
OnLogout?.Invoke();
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 0e9d5b0cb9f26477eaf26e8a09c41707
@@ -1,122 +0,0 @@
using System;
using UnityEngine;
namespace Auth {
/// <summary>
/// Secure storage for OAuth tokens using Unity's PlayerPrefs.
/// In production, consider using platform-specific secure storage
/// (Keychain on iOS, Keystore on Android).
/// </summary>
public static class TokenStorage {
private const string AccessTokenKey = "eagle0_access_token";
private const string RefreshTokenKey = "eagle0_refresh_token";
private const string ExpiresAtKey = "eagle0_expires_at";
private const string UserIdKey = "eagle0_user_id";
private const string DisplayNameKey = "eagle0_display_name";
public static bool HasValidToken {
get {
var token = AccessToken;
var expiresAt = ExpiresAt;
return !string.IsNullOrEmpty(token) &&
expiresAt > DateTimeOffset.UtcNow.ToUnixTimeSeconds();
}
}
public static bool HasRefreshToken => !string.IsNullOrEmpty(RefreshToken);
public static string AccessToken {
get => PlayerPrefs.GetString(AccessTokenKey, null);
private
set => PlayerPrefs.SetString(AccessTokenKey, value ?? "");
}
public static string RefreshToken {
get => PlayerPrefs.GetString(RefreshTokenKey, null);
private
set => PlayerPrefs.SetString(RefreshTokenKey, value ?? "");
}
public static long ExpiresAt {
get => long.TryParse(PlayerPrefs.GetString(ExpiresAtKey, "0"), out var val) ? val : 0;
private
set => PlayerPrefs.SetString(ExpiresAtKey, value.ToString());
}
public static string UserId {
get => PlayerPrefs.GetString(UserIdKey, null);
private
set => PlayerPrefs.SetString(UserIdKey, value ?? "");
}
public static string DisplayName {
get => PlayerPrefs.GetString(DisplayNameKey, null);
private
set => PlayerPrefs.SetString(DisplayNameKey, value ?? "");
}
/// <summary>
/// Store tokens received from OAuth exchange.
/// </summary>
public static void StoreTokens(
string accessToken,
string refreshToken,
long expiresAt,
string userId,
string displayName) {
AccessToken = accessToken;
RefreshToken = refreshToken;
ExpiresAt = expiresAt;
UserId = userId;
DisplayName = displayName;
PlayerPrefs.Save();
Debug.Log(
$"[TokenStorage] Stored tokens for user {displayName} (expires at {expiresAt})");
}
/// <summary>
/// Update access token after refresh.
/// </summary>
public static void UpdateAccessToken(string accessToken, long expiresAt) {
AccessToken = accessToken;
ExpiresAt = expiresAt;
PlayerPrefs.Save();
Debug.Log($"[TokenStorage] Updated access token (expires at {expiresAt})");
}
/// <summary>
/// Update display name after user sets it.
/// </summary>
public static void UpdateDisplayName(string displayName) {
DisplayName = displayName;
PlayerPrefs.Save();
}
/// <summary>
/// Clear all stored tokens (logout).
/// </summary>
public static void Clear() {
PlayerPrefs.DeleteKey(AccessTokenKey);
PlayerPrefs.DeleteKey(RefreshTokenKey);
PlayerPrefs.DeleteKey(ExpiresAtKey);
PlayerPrefs.DeleteKey(UserIdKey);
PlayerPrefs.DeleteKey(DisplayNameKey);
PlayerPrefs.Save();
Debug.Log("[TokenStorage] Cleared all tokens");
}
/// <summary>
/// Check if token needs refresh (expires within 5 minutes).
/// </summary>
public static bool NeedsRefresh {
get {
var expiresAt = ExpiresAt;
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
return expiresAt > 0 && expiresAt - now < 300; // 5 minutes
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 4b1f130cec656466893fa04ba789d34f
@@ -29,9 +29,9 @@ RectTransform:
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 616210323856016607}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -105,23 +105,20 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -188,9 +185,9 @@ RectTransform:
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 616210323856016607}
m_RootOrder: 2
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -264,23 +261,20 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -350,10 +344,10 @@ RectTransform:
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1806945232979884877}
m_Father: {fileID: 616210323856016607}
m_RootOrder: 3
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -431,7 +425,6 @@ MonoBehaviour:
atime6: 0
atime7: 0
m_Mode: 0
m_ColorSpace: -1
m_NumColorKeys: 2
m_NumAlphaKeys: 2
_gradientType: 0
@@ -571,9 +564,9 @@ RectTransform:
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 7057983938046569337}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -647,23 +640,20 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -711,15 +701,13 @@ RectTransform:
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 2291298599107070085}
- {fileID: 8906549728612534620}
- {fileID: 6686534948942979506}
- {fileID: 6700597635350808166}
- {fileID: 8218751192785770547}
- {fileID: 7057983938046569337}
m_Father: {fileID: 0}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -808,295 +796,31 @@ MonoBehaviour:
playerCountField: {fileID: 8350902200036365361}
joinButton: {fileID: 437043893514773017}
buttonText: {fileID: 2170549136187147757}
dropButton: {fileID: 19903828017934214}
--- !u!1 &7127645211350815762
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 8218751192785770547}
- component: {fileID: 3533697289353260343}
m_Layer: 5
m_Name: spacer
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &8218751192785770547
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7127645211350815762}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 616210323856016607}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &3533697289353260343
--- !u!114 &2580770409874757745
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7127645211350815762}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
m_IgnoreLayout: 0
m_MinWidth: 10
m_MinHeight: -1
m_PreferredWidth: 10
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &7934454793097069971
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 6700597635350808166}
- component: {fileID: 5023923008883954850}
- component: {fileID: 19903828017934214}
- component: {fileID: 2151248441631091217}
- component: {fileID: 7406323257372084547}
m_Layer: 5
m_Name: Drop Game Button
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &6700597635350808166
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7934454793097069971}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1846732971937087380}
m_Father: {fileID: 616210323856016607}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &5023923008883954850
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7934454793097069971}
m_CullTransparentMesh: 0
--- !u!114 &19903828017934214
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7934454793097069971}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Navigation:
m_Mode: 0
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 0.42745098, b: 0.42745098, a: 1}
m_HighlightedColor: {r: 0.84313726, g: 0.84313726, b: 0.84313726, a: 1}
m_PressedColor: {r: 0, g: 0, b: 0, a: 1}
m_SelectedColor: {r: 0.84313726, g: 0.84313726, b: 0.84313726, a: 1}
m_DisabledColor: {r: 1, g: 1, b: 1, a: 0.39215687}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Highlighted
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 2151248441631091217}
m_OnClick:
m_PersistentCalls:
m_Calls:
- m_Target: {fileID: 7332722509251528870}
m_TargetAssemblyTypeName: AvailableGameItem, Assembly-CSharp
m_MethodName: DropClicked
m_Mode: 1
m_Arguments:
m_ObjectArgument: {fileID: 0}
m_ObjectArgumentAssemblyTypeName: UnityEngine.Object, UnityEngine
m_IntArgument: 0
m_FloatArgument: 0
m_StringArgument:
m_BoolArgument: 0
m_CallState: 2
--- !u!114 &2151248441631091217
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7934454793097069971}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: f03f625cc7127f244a2f4da835b92720, type: 3}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!114 &7406323257372084547
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7934454793097069971}
m_GameObject: {fileID: 8906549728612534621}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 50
m_MinWidth: 700
m_MinHeight: -1
m_PreferredWidth: 50
m_PreferredWidth: 700
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &8839297347726735035
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1846732971937087380}
- component: {fileID: 2464975641276684442}
- component: {fileID: 4051352560264867900}
m_Layer: 5
m_Name: Icon
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1846732971937087380
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8839297347726735035}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 6700597635350808166}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &2464975641276684442
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8839297347726735035}
m_CullTransparentMesh: 1
--- !u!114 &4051352560264867900
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8839297347726735035}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 1344c3c82d62a2a41a3576d8abb8e3ea, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.RawImage
m_Material: {fileID: 0}
m_Color: {r: 0, g: 0, b: 0, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Texture: {fileID: 2800000, guid: 8c998213d53883d4b8fdf7a3eb3c2909, type: 3}
m_UVRect:
serializedVersion: 2
x: 0
y: 0
width: 1
height: 1
--- !u!1001 &3324269289066796519
PrefabInstance:
m_ObjectHideFlags: 0
serializedVersion: 2
m_Modification:
serializedVersion: 3
m_TransformParent: {fileID: 616210323856016607}
m_Modifications:
- target: {fileID: 6176764421848850767, guid: 59599ef87ad824f52ada88f126d58c5f,
@@ -1109,11 +833,6 @@ PrefabInstance:
propertyPath: m_fontSizeBase
value: 24
objectReference: {fileID: 0}
- target: {fileID: 6176764421848850767, guid: 59599ef87ad824f52ada88f126d58c5f,
type: 3}
propertyPath: 'm_ActiveFontFeatures.Array.data[0]'
value: 1801810542
objectReference: {fileID: 0}
- target: {fileID: 6176764421927073094, guid: 59599ef87ad824f52ada88f126d58c5f,
type: 3}
propertyPath: m_fontSize
@@ -1240,13 +959,6 @@ PrefabInstance:
value: 0
objectReference: {fileID: 0}
m_RemovedComponents: []
m_RemovedGameObjects: []
m_AddedGameObjects: []
m_AddedComponents:
- targetCorrespondingSourceObject: {fileID: 6176764422977772730, guid: 59599ef87ad824f52ada88f126d58c5f,
type: 3}
insertIndex: -1
addedObject: {fileID: 2580770409874757745}
m_SourcePrefab: {fileID: 100100000, guid: 59599ef87ad824f52ada88f126d58c5f, type: 3}
--- !u!114 &8906549728612534595 stripped
MonoBehaviour:
@@ -1272,23 +984,3 @@ GameObject:
type: 3}
m_PrefabInstance: {fileID: 3324269289066796519}
m_PrefabAsset: {fileID: 0}
--- !u!114 &2580770409874757745
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8906549728612534621}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 700
m_MinHeight: -1
m_PreferredWidth: 700
m_PreferredHeight: -1
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -16,10 +16,8 @@ public class AvailableGameItem : MonoBehaviour {
public TextMeshProUGUI playerCountField;
public Button joinButton;
public TextMeshProUGUI buttonText;
public Button dropButton;
public Action<long, string> JoinCallback;
public Action<long> DropCallback;
private List<AvailableLeader> availableLeaders;
private long gameId;
@@ -40,8 +38,6 @@ public class AvailableGameItem : MonoBehaviour {
public void JoinClicked() { JoinCallback(gameId, SelectedLeaderTextId); }
public void DropClicked() { DropCallback?.Invoke(gameId); }
public void
SetAvailableNewGame(long gameId, string playersString, List<AvailableLeader> leaders) {
availableLeaders = leaders;
@@ -7,18 +7,15 @@ using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Auth;
using common;
using common.GUIUtils;
using eagle;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Api.Auth;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using Object = System.Object;
public class AuthInterceptor : Interceptor {
@@ -85,23 +82,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public TMP_InputField passwordField;
public TMP_Dropdown resolutionDropdown;
[Header("OAuth UI")]
public GameObject oauthPanel;
public Button discordLoginButton;
public Button googleLoginButton;
public TextMeshProUGUI oauthStatusText;
[Header("Display Name Setup")]
public GameObject displayNamePanel;
public TMP_InputField displayNameField;
public Button setDisplayNameButton;
public TextMeshProUGUI displayNameErrorText;
[Header("Legacy Auth (for testing)")]
public GameObject legacyAuthPanel;
public Button useLegacyAuthButton;
public TextMeshProUGUI useLegacyAuthButtonText;
[Header("Status Display")]
public TextMeshProUGUI connectionStatusText;
@@ -110,11 +90,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public GameObject customBattlePanel;
public ErrorHandler errorHandler;
[Header("Lobby Controls")]
public Button logoutButton;
public TextMeshProUGUI lobbyEnvironmentText;
public TextMeshProUGUI lobbyUserText;
public GameObject runningGamesListArea;
public GameObject runningGamesListItemPrefab;
@@ -123,8 +98,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public GameObject waitingGamesListItemPrefab;
public GameObject createGameListItemPrefab;
public DropGameConfirmationPanel dropGameConfirmationPanel;
public Canvas connectionCanvas;
public Canvas eagleCanvas;
public Canvas shardokCanvas;
@@ -139,8 +112,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
private bool listen = false;
private CancellationTokenSource _cancellationTokenSource;
private readonly Object pendingReplyLock = new Object();
private int _connectedEnvironmentIndex = -1; // -1 means not connected
private bool _useOAuth = false; // Default to legacy until server-side OAuth is ready
public ClientPregeneratedText clientPregeneratedText;
@@ -154,27 +125,12 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
private static readonly List<string> EnvironmentDisplayNames =
new() { "prod.", "qa.", "(none)" };
/// <summary>
/// Returns the connected environment name ("prod." or "qa.") or null if not connected.
/// </summary>
public string ConnectedEnvironmentName =>
_connectedEnvironmentIndex >= 0 &&
_connectedEnvironmentIndex < EnvironmentDisplayNames.Count
? EnvironmentDisplayNames[_connectedEnvironmentIndex]
: null;
private string GetUrlFromEnvironment() {
int envIndex = environmentDropdown.value;
string prefix = EnvironmentOptions[envIndex];
return prefix + BaseDomain;
}
/// <summary>
/// Get full URL with scheme for the selected environment.
/// Remote servers use HTTPS, could be extended for local HTTP.
/// </summary>
private string GetFullUrlFromEnvironment() { return "https://" + GetUrlFromEnvironment(); }
public void ReceiveLobbyUpdate(LobbyResponse lobbyResponse) {
_handleLobbyResponse(lobbyResponse);
}
@@ -210,214 +166,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
errorHandler.gameObject.SetActive(true);
// Initialize OAuth UI
SetupOAuthUI();
// Initialize Lobby UI (logout button, etc.)
SetupLobbyUI();
// Initialize status text
UpdateConnectionStatus();
// Try to restore existing OAuth session
TryRestoreSession();
}
private void SetupOAuthUI() {
// Set up OAuth button click handlers
if (discordLoginButton != null) {
discordLoginButton.onClick.AddListener(
() => OnOAuthLoginClicked(OAuthProvider.Discord));
}
if (googleLoginButton != null) {
googleLoginButton.onClick.AddListener(() => OnOAuthLoginClicked(OAuthProvider.Google));
}
if (setDisplayNameButton != null) {
setDisplayNameButton.onClick.AddListener(OnSetDisplayNameClicked);
}
if (useLegacyAuthButton != null) {
useLegacyAuthButton.onClick.AddListener(OnUseLegacyAuthClicked);
}
// Subscribe to OAuthManager events
if (OAuthManager.Instance != null) {
OAuthManager.Instance.OnLoginSuccess += OnOAuthLoginSuccess;
OAuthManager.Instance.OnLoginFailed += OnOAuthLoginFailed;
OAuthManager.Instance.OnNewUserNeedsDisplayName += OnNewUserNeedsDisplayName;
OAuthManager.Instance.OnLogout += OnOAuthLogout;
}
// Show appropriate panel based on auth state
ShowAuthPanel();
}
private void ShowAuthPanel() {
// Hide all auth panels first
if (oauthPanel != null) oauthPanel.SetActive(false);
if (displayNamePanel != null) displayNamePanel.SetActive(false);
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(false);
// Show appropriate panel based on _useOAuth
if (_useOAuth) {
if (oauthPanel != null) oauthPanel.SetActive(true);
if (useLegacyAuthButtonText != null)
useLegacyAuthButtonText.text = "Use Classic Sign-in";
} else {
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(true);
if (useLegacyAuthButtonText != null) useLegacyAuthButtonText.text = "Use OAuth Sign-in";
}
}
private void SetupLobbyUI() {
if (logoutButton != null) { logoutButton.onClick.AddListener(OnLogoutClicked); }
}
private void UpdateLobbyStatusDisplays() {
// Show environment (prod/qa)
if (lobbyEnvironmentText != null) {
lobbyEnvironmentText.text = ConnectedEnvironmentName ?? "";
}
// Show current user - prefer OAuth display name, fall back to classic login name
if (lobbyUserText != null) {
if (_useOAuth && !string.IsNullOrEmpty(TokenStorage.DisplayName)) {
lobbyUserText.text = TokenStorage.DisplayName;
} else {
lobbyUserText.text = nameField.text;
}
}
}
private async void OnLogoutClicked() {
Debug.Log("[ConnectionHandler] Logout button clicked");
// Clear OAuth tokens
if (OAuthManager.Instance != null) { await OAuthManager.Instance.LogoutAsync(); }
// Disconnect from server
_persistentClientConnection?.Dispose();
eagleConnection?.Dispose();
_httpClient?.Dispose();
_cancellationTokenSource?.Dispose();
_persistentClientConnection = null;
eagleConnection = null;
_httpClient = null;
_cancellationTokenSource = null;
_connectedEnvironmentIndex = -1;
// Return to connection screen
gameSelectionPanel.SetActive(false);
connectionPanel.SetActive(true);
// Re-initialize the auth UI
ShowAuthPanel();
UpdateConnectionStatus();
}
private async void TryRestoreSession() {
if (OAuthManager.Instance == null) return;
// Configure OAuthManager with current environment URL
OAuthManager.Instance.SetServerUrl(GetFullUrlFromEnvironment());
if (oauthStatusText != null) { oauthStatusText.text = "Checking for existing session..."; }
var restored = await OAuthManager.Instance.TryRestoreSessionAsync();
if (restored) {
// Session restored, connect to lobby
ConnectWithOAuth();
} else if (oauthStatusText != null) {
oauthStatusText.text = "";
}
}
private async void OnOAuthLoginClicked(OAuthProvider provider) {
// Configure OAuthManager with current environment URL (user may have changed dropdown)
OAuthManager.Instance.SetServerUrl(GetFullUrlFromEnvironment());
if (oauthStatusText != null) {
oauthStatusText.text = $"Opening browser for {provider} login...";
}
try {
await OAuthManager.Instance.LoginAsync(provider);
// OnLoginSuccess or OnNewUserNeedsDisplayName will be called
} catch (Exception ex) {
Debug.LogError($"OAuth login failed: {ex.Message}");
if (oauthStatusText != null) { oauthStatusText.text = $"Login failed: {ex.Message}"; }
}
}
private void OnOAuthLoginSuccess(UserInfo user) {
MainQueue.Q.Enqueue(() => {
if (oauthStatusText != null) { oauthStatusText.text = $"Welcome, {user.DisplayName}!"; }
ConnectWithOAuth();
});
}
private void OnOAuthLoginFailed(string error) {
MainQueue.Q.Enqueue(() => {
if (oauthStatusText != null) { oauthStatusText.text = $"Login failed: {error}"; }
});
}
private void OnNewUserNeedsDisplayName(bool isNewUser) {
MainQueue.Q.Enqueue(() => {
// Show display name panel
if (oauthPanel != null) oauthPanel.SetActive(false);
if (displayNamePanel != null) displayNamePanel.SetActive(true);
if (displayNameErrorText != null) displayNameErrorText.text = "";
});
}
private async void OnSetDisplayNameClicked() {
if (displayNameField == null || OAuthManager.Instance == null) return;
var displayName = displayNameField.text.Trim();
if (string.IsNullOrEmpty(displayName)) {
if (displayNameErrorText != null) {
displayNameErrorText.text = "Please enter a display name";
}
return;
}
if (displayNameErrorText != null) { displayNameErrorText.text = "Setting display name..."; }
var success = await OAuthManager.Instance.SetDisplayNameAsync(displayName);
if (!success && displayNameErrorText != null) {
displayNameErrorText.text = "Name already taken or invalid. Try another.";
}
// OnLoginSuccess will be called if successful
}
private void OnOAuthLogout() {
MainQueue.Q.Enqueue(() => {
ShowAuthPanel();
if (oauthStatusText != null) { oauthStatusText.text = ""; }
});
}
private void OnUseLegacyAuthClicked() {
_useOAuth = !_useOAuth; // Toggle instead of just setting false
if (_useOAuth) {
// Show OAuth panel
if (oauthPanel != null) oauthPanel.SetActive(true);
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(false);
if (useLegacyAuthButtonText != null)
useLegacyAuthButtonText.text = "Use Classic Sign-in";
} else {
// Show legacy panel
if (oauthPanel != null) oauthPanel.SetActive(false);
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(true);
if (useLegacyAuthButtonText != null) useLegacyAuthButtonText.text = "Use OAuth Sign-in";
}
}
private void ConnectWithOAuth() {
_useOAuth = true;
_internalConnectEagle();
}
private float _statusUpdateTimer = 0f;
@@ -512,8 +262,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
_persistentClientConnection = null;
eagleConnection?.Dispose();
eagleConnection = null;
_connectedEnvironmentIndex = -1;
}
public void OnResolutionChanged(int dropdownIndex) {
@@ -541,9 +289,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
connectionPanel.gameObject.SetActive(false);
gameSelectionPanel.gameObject.SetActive(true);
// Update lobby status displays
UpdateLobbyStatusDisplays();
// Set up running games table
foreach (Transform row in runningGamesListArea.transform) { Destroy(row.gameObject); }
foreach (var runningGame in lobbyResponse.RunningGames) {
@@ -552,10 +297,10 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
runningGamesListArea.transform,
true);
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
var runningGameItem = listItem.GetComponent<RunningGameItem>();
runningGameItem.SetRunningGame(runningGame.GameId, runningGame.Leader);
runningGameItem.GoCallback = this.SelectEagleGame;
runningGameItem.DropCallback = this.DropGame;
listItem.GetComponent<RunningGameItem>().SetRunningGame(
runningGame.GameId,
runningGame.Leader);
listItem.GetComponent<RunningGameItem>().GoCallback = this.SelectEagleGame;
}
// Set up available/waiting games table
@@ -591,16 +336,14 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
availableGamesListArea.transform,
true);
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
var availableGameItem = listItem.GetComponent<AvailableGameItem>();
availableGameItem.SetAvailableNewGame(
listItem.GetComponent<AvailableGameItem>().SetAvailableNewGame(
availableGame.GameId,
string.Format(
"{0} / {1}",
availableGame.CurrentHumanPlayerCount,
availableGame.MaxHumanPlayerCount),
availableGame.AvailableLeaders.ToList());
availableGameItem.JoinCallback = this.JoinGame;
availableGameItem.DropCallback = this.DropGame;
listItem.GetComponent<AvailableGameItem>().JoinCallback = this.JoinGame;
}
});
}
@@ -614,8 +357,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
private void _createConnection() {
_connectedEnvironmentIndex = environmentDropdown.value;
PlayerPrefs.SetInt(EnvironmentKey, _connectedEnvironmentIndex);
PlayerPrefs.SetInt(EnvironmentKey, environmentDropdown.value);
PlayerPrefs.SetString(NameKey, nameField.text);
PlayerPrefs.SetString(PasswordKey, passwordField.text);
@@ -629,26 +371,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
_cancellationTokenSource = new CancellationTokenSource();
string url = GetUrlFromEnvironment();
if (_useOAuth && TokenStorage.HasValidToken) {
// Use JWT-based connection
eagleConnection = EagleConnection.CreateWithJwt(url);
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TokenStorage.AccessToken);
} else {
// Legacy Basic Auth connection
PlayerPrefs.SetString(NameKey, nameField.text);
PlayerPrefs.SetString(PasswordKey, passwordField.text);
eagleConnection = new EagleConnection(nameField.text, passwordField.text, url);
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", eagleConnection.authHeader);
}
eagleConnection = new EagleConnection(nameField.text, passwordField.text, url);
_persistentClientConnection = new PersistentClientConnection(
eagleConnection.EagleGrpcClient,
eagleConnection.credentials,
@@ -656,6 +379,10 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
_persistentClientConnection.Connect();
errorHandler.PersistentClientConnection = _persistentClientConnection;
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", eagleConnection.authHeader);
ResourceFetcher.SetUpConnection(_httpClient);
}
@@ -713,18 +440,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
var game = _internalJoinEagleGame(gameId, leaderName);
}
private void DropGame(long gameId) {
if (dropGameConfirmationPanel != null) {
dropGameConfirmationPanel.OnConfirm = ConfirmDropGame;
dropGameConfirmationPanel.Show(gameId, "Are you sure you want to leave this game?");
} else {
// Fallback if no confirmation panel is configured
ConfirmDropGame(gameId);
}
}
private void ConfirmDropGame(long gameId) { _internalDropGame(gameId); }
private void CreateGame(string leaderNameTextId, int humanPlayerCount, int totalPlayerCount) {
var game = _internalCreateGame(leaderNameTextId, humanPlayerCount, totalPlayerCount);
}
@@ -801,18 +516,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
}
private async Task<bool> _internalDropGame(long gameId) {
try {
return await _persistentClientConnection.SendUpdateStreamRequestAsync(
new UpdateStreamRequest {
DropGameRequest = new DropGameRequest { GameId = gameId }
});
} catch (WebException e) {
Debug.Log(String.Format("Caught exception in DropGame {0}", e.Message));
return false;
}
}
private void SetLobbyActive(bool active) {
runningGamesListArea.SetActive(active);
availableGamesListArea.SetActive(active);
@@ -830,8 +533,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
eagleCanvas.GetComponent<EagleGameController>().SetUpGame(
gameInfo.GameId,
gameInfo.FactionId,
_persistentClientConnection,
ConnectedEnvironmentName);
_persistentClientConnection);
connectionCanvas.enabled = false;
connectionCanvas.gameObject.SetActive(false);
eagleCanvas.gameObject.SetActive(true);
@@ -1,24 +0,0 @@
using System;
using TMPro;
using UnityEngine;
public class DropGameConfirmationPanel : MonoBehaviour {
public TextMeshProUGUI messageText;
public Action<long> OnConfirm;
private long _pendingGameId;
public void Show(long gameId, string message) {
_pendingGameId = gameId;
if (messageText != null) { messageText.text = message; }
gameObject.SetActive(true);
}
public void ConfirmClicked() {
gameObject.SetActive(false);
OnConfirm?.Invoke(_pendingGameId);
}
public void CancelClicked() { gameObject.SetActive(false); }
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 558d4c7321daa4fe18dc4759a130d919
@@ -29,9 +29,9 @@ RectTransform:
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 909271087739711220}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -105,23 +105,20 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -188,9 +185,9 @@ RectTransform:
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 909271087739711220}
m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -264,23 +261,20 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -318,216 +312,6 @@ MonoBehaviour:
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &4189605341159952945
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 7336928442537586311}
- component: {fileID: 3344684898730655831}
m_Layer: 5
m_Name: spacer
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &7336928442537586311
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4189605341159952945}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 909271087739711220}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &3344684898730655831
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4189605341159952945}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
m_IgnoreLayout: 0
m_MinWidth: 10
m_MinHeight: -1
m_PreferredWidth: 10
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &4678275134637000934
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1348356180262758599}
- component: {fileID: 8617884038396701234}
- component: {fileID: 4081637582368108930}
- component: {fileID: 5167949463556339992}
- component: {fileID: 7482580409419131173}
m_Layer: 5
m_Name: Drop Game Button
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1348356180262758599
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4678275134637000934}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 6454652926630703861}
m_Father: {fileID: 909271087739711220}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &8617884038396701234
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4678275134637000934}
m_CullTransparentMesh: 0
--- !u!114 &4081637582368108930
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4678275134637000934}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Navigation:
m_Mode: 0
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 0.42745098, b: 0.42745098, a: 1}
m_HighlightedColor: {r: 0.84313726, g: 0.84313726, b: 0.84313726, a: 1}
m_PressedColor: {r: 0, g: 0, b: 0, a: 1}
m_SelectedColor: {r: 0.84313726, g: 0.84313726, b: 0.84313726, a: 1}
m_DisabledColor: {r: 1, g: 1, b: 1, a: 0.39215687}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Highlighted
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 5167949463556339992}
m_OnClick:
m_PersistentCalls:
m_Calls:
- m_Target: {fileID: 7788402836057688155}
m_TargetAssemblyTypeName: RunningGameItem, Assembly-CSharp
m_MethodName: DropClicked
m_Mode: 1
m_Arguments:
m_ObjectArgument: {fileID: 0}
m_ObjectArgumentAssemblyTypeName: UnityEngine.Object, UnityEngine
m_IntArgument: 0
m_FloatArgument: 0
m_StringArgument:
m_BoolArgument: 0
m_CallState: 2
--- !u!114 &5167949463556339992
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4678275134637000934}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: f03f625cc7127f244a2f4da835b92720, type: 3}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!114 &7482580409419131173
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4678275134637000934}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 0
m_MinHeight: -1
m_PreferredWidth: 50
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &4703848583262435150
GameObject:
m_ObjectHideFlags: 0
@@ -556,9 +340,9 @@ RectTransform:
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 7341333336313446738}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -632,23 +416,20 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -698,10 +479,10 @@ RectTransform:
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 2089905691925287270}
m_Father: {fileID: 909271087739711220}
m_RootOrder: 2
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -779,7 +560,6 @@ MonoBehaviour:
atime6: 0
atime7: 0
m_Mode: 0
m_ColorSpace: -1
m_NumColorKeys: 2
m_NumAlphaKeys: 2
_gradientType: 0
@@ -922,14 +702,12 @@ RectTransform:
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 2008335870574158510}
- {fileID: 6674238761151365000}
- {fileID: 1348356180262758599}
- {fileID: 7336928442537586311}
- {fileID: 7341333336313446738}
m_Father: {fileID: 0}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -1015,8 +793,7 @@ MonoBehaviour:
item: {fileID: 5643565463360785033}
gameIdField: {fileID: 8058295588038032232}
leaderField: {fileID: 3311450659768657245}
goButton: {fileID: 145214844678457394}
dropButton: {fileID: 4081637582368108930}
goButton: {fileID: 0}
--- !u!114 &2208043217657811503
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -1037,75 +814,3 @@ MonoBehaviour:
m_FlexibleWidth: 1
m_FlexibleHeight: 1
m_LayoutPriority: 1
--- !u!1 &7814378479006347708
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 6454652926630703861}
- component: {fileID: 374667974001408770}
- component: {fileID: 5621315126805048060}
m_Layer: 5
m_Name: Icon
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &6454652926630703861
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7814378479006347708}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1348356180262758599}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &374667974001408770
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7814378479006347708}
m_CullTransparentMesh: 1
--- !u!114 &5621315126805048060
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7814378479006347708}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 1344c3c82d62a2a41a3576d8abb8e3ea, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.RawImage
m_Material: {fileID: 0}
m_Color: {r: 0, g: 0, b: 0, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Texture: {fileID: 2800000, guid: 8c998213d53883d4b8fdf7a3eb3c2909, type: 3}
m_UVRect:
serializedVersion: 2
x: 0
y: 0
width: 1
height: 1
@@ -10,10 +10,8 @@ public class RunningGameItem : MonoBehaviour {
public TextMeshProUGUI gameIdField;
public TextMeshProUGUI leaderField;
public Button goButton;
public Button dropButton;
public Action<long> GoCallback;
public Action<long> DropCallback;
private long gameId;
@@ -22,8 +20,6 @@ public class RunningGameItem : MonoBehaviour {
public void GoClicked() { GoCallback(gameId); }
public void DropClicked() { DropCallback?.Invoke(gameId); }
public void SetRunningGame(long gameId, AvailableLeader leader) {
this.gameId = gameId;
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using EagleGUIUtils;
@@ -6,7 +6,6 @@ using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace eagle {
@@ -15,44 +14,16 @@ namespace eagle {
public class ImproveCommandSelector : CommandSelector {
public HeroDropdownController heroDropdownController;
public TMP_Dropdown typeDropdown;
public Toggle lockToggle;
// Toggle buttons for each improvement type (assign to same ToggleGroup in Unity)
public Toggle economyToggle;
public Toggle agricultureToggle;
public Toggle infrastructureToggle;
public Toggle devastationToggle;
public ToggleGroup improvementToggleGroup;
// Labels showing type name and value
public TMP_Text economyLabel;
public TMP_Text agricultureLabel;
public TMP_Text infrastructureLabel;
public TMP_Text devastationLabel;
List<HeroView> orderedHeroes;
private int SelectedTypeIndex => typeDropdown.value;
private ImproveAvailableCommand ImproveAvailableCommand => _availableCommand.ImproveCommand;
private ProvinceId ActingProvinceId => ImproveAvailableCommand.ActingProvinceId;
private ImprovementType SelectedType {
get {
if (economyToggle != null && economyToggle.isOn && economyToggle.interactable)
return ImprovementType.Economy;
if (agricultureToggle != null && agricultureToggle.isOn &&
agricultureToggle.interactable)
return ImprovementType.Agriculture;
if (infrastructureToggle != null && infrastructureToggle.isOn &&
infrastructureToggle.interactable)
return ImprovementType.Infrastructure;
if (devastationToggle != null && devastationToggle.isOn &&
devastationToggle.interactable)
return ImprovementType.Devastation;
// Fallback: return first available type
return ImproveAvailableCommand.AvailableTypes.FirstOrDefault();
}
}
private ImprovementType SelectedType =>
ImproveAvailableCommand.AvailableTypes[SelectedTypeIndex];
private float OriginalImprovementValueForType(ImprovementType type) {
var province = _model.Provinces[ActingProvinceId];
@@ -86,142 +57,60 @@ namespace eagle {
}
}
private ImprovementType GetMinimumImprovementType() {
ImprovementType minType = ImproveAvailableCommand.AvailableTypes[0];
float minValue = OriginalImprovementValueForType(minType);
private int MinimumImprovementIndex(ProvinceView province) {
var minIndex = 0;
float minValue =
OriginalImprovementValueForType(ImproveAvailableCommand.AvailableTypes[0]);
for (int i = 1; i < ImproveAvailableCommand.AvailableTypes.Count; i++) {
var thisType = ImproveAvailableCommand.AvailableTypes[i];
float thisVal = OriginalImprovementValueForType(thisType);
float thisVal =
OriginalImprovementValueForType(ImproveAvailableCommand.AvailableTypes[i]);
if (thisVal < minValue) {
minType = thisType;
minIndex = i;
minValue = thisVal;
}
}
return minType;
return minIndex;
}
private ImprovementType GetDefaultImprovementType() {
// Prefer devastation if available
if (ImproveAvailableCommand.AvailableTypes.Contains(ImprovementType.Devastation)) {
return ImprovementType.Devastation;
}
// Otherwise pick the lowest stat
return GetMinimumImprovementType();
}
private Toggle GetToggleForType(ImprovementType type) {
switch (type) {
case ImprovementType.Economy: return economyToggle;
case ImprovementType.Agriculture: return agricultureToggle;
case ImprovementType.Infrastructure: return infrastructureToggle;
case ImprovementType.Devastation: return devastationToggle;
default: return null;
}
}
private TMP_Text GetLabelForType(ImprovementType type) {
switch (type) {
case ImprovementType.Economy: return economyLabel;
case ImprovementType.Agriculture: return agricultureLabel;
case ImprovementType.Infrastructure: return infrastructureLabel;
case ImprovementType.Devastation: return devastationLabel;
default: return null;
}
}
private void SelectType(ImprovementType type) {
// Turn on the selected toggle - ToggleGroup handles turning off others
var toggle = GetToggleForType(type);
if (toggle != null && toggle.interactable) { toggle.isOn = true; }
}
private const float DisabledAlpha = 0.35f;
private void ConfigureToggle(ImprovementType type, bool available) {
var toggle = GetToggleForType(type);
var label = GetLabelForType(type);
if (toggle == null) return;
// Always show the toggle, but disable if not available
toggle.gameObject.SetActive(true);
toggle.interactable = available;
// Turn off unavailable toggles so they don't appear selected
if (!available) { toggle.isOn = false; }
// Only add to toggle group if available
toggle.group = available ? improvementToggleGroup : null;
// Gray out unavailable toggles using CanvasGroup
var canvasGroup = toggle.GetComponent<CanvasGroup>();
if (canvasGroup == null) {
canvasGroup = toggle.gameObject.AddComponent<CanvasGroup>();
}
canvasGroup.alpha = available ? 1f : DisabledAlpha;
// Always show label with current value
if (label != null) { label.text = LabelStringForType(type); }
}
private string LabelStringForType(ImprovementType type) {
var originalStat =
type == ImprovementType.Devastation
? ProvinceStatUtils.RoundedDevastation(
OriginalImprovementValueForType(type))
: ProvinceStatUtils.RoundedStat(OriginalImprovementValueForType(type));
if (type == ImprovementType.Devastation) {
return GUIUtils.ColoredString(Color.red, originalStat.ToString());
}
var devastatedStat =
ProvinceStatUtils.RoundedStat(EffectiveImprovementValueForType(type));
if (devastatedStat == originalStat) {
return $"{devastatedStat}";
private void ChooseDefaultImprovement(ProvinceView province) {
var devastationIndex =
ImproveAvailableCommand.AvailableTypes.IndexOf(ImprovementType.Devastation);
if (devastationIndex == -1) {
typeDropdown.value = MinimumImprovementIndex(province);
} else {
return $"{GUIUtils.ColoredString(Color.red, devastatedStat.ToString())} / {originalStat}";
typeDropdown.value = devastationIndex;
}
}
protected override void SetUpUI() {
var province = _model.Provinces[ImproveAvailableCommand.ActingProvinceId];
var improveCommand = ImproveAvailableCommand;
typeDropdown.ClearOptions();
orderedHeroes = ImproveAvailableCommand.AvailableHeroIds.Select(id => _model.Heroes[id])
.ToList();
heroDropdownController.AvailableHeroes = orderedHeroes;
heroDropdownController.SelectedHeroId = improveCommand.RecommendedHeroId;
// Configure each toggle based on available types
ConfigureToggle(
ImprovementType.Economy,
improveCommand.AvailableTypes.Contains(ImprovementType.Economy));
ConfigureToggle(
ImprovementType.Agriculture,
improveCommand.AvailableTypes.Contains(ImprovementType.Agriculture));
ConfigureToggle(
ImprovementType.Infrastructure,
improveCommand.AvailableTypes.Contains(ImprovementType.Infrastructure));
ConfigureToggle(
ImprovementType.Devastation,
improveCommand.AvailableTypes.Contains(ImprovementType.Devastation));
typeDropdown.AddOptions(
improveCommand.AvailableTypes.Select(DropdownStringForType).ToList());
// Determine which type to select
ImprovementType selectedType;
if (improveCommand.LockedType != ImprovementType.None) {
var lockedType = improveCommand.LockedType;
if (improveCommand.AvailableTypes.Contains(lockedType)) {
selectedType = lockedType;
var lockedTypeIndex = improveCommand.AvailableTypes.IndexOf(lockedType);
if (lockedTypeIndex > -1) {
typeDropdown.value = lockedTypeIndex;
lockToggle.isOn = true;
} else {
selectedType = GetDefaultImprovementType();
ChooseDefaultImprovement(province);
lockToggle.isOn = false;
}
} else {
selectedType = GetDefaultImprovementType();
ChooseDefaultImprovement(province);
lockToggle.isOn = false;
}
SelectType(selectedType);
}
public override AvailableCommand.SealedValueOneofCase CommandType =>
@@ -248,5 +137,23 @@ namespace eagle {
LockType = lockToggle.isOn
}
};
private string DropdownStringForType(ImprovementType type) {
var originalStat =
type == ImprovementType.Devastation
? ProvinceStatUtils.RoundedDevastation(
OriginalImprovementValueForType(type))
: ProvinceStatUtils.RoundedStat(OriginalImprovementValueForType(type));
if (type == ImprovementType.Devastation) {
return $"{type} ({GUIUtils.ColoredString(UnityEngine.Color.red, originalStat.ToString())})";
}
var devastatedStat =
ProvinceStatUtils.RoundedStat(EffectiveImprovementValueForType(type));
if (devastatedStat == originalStat) {
return $"{type} ({devastatedStat})";
} else {
return $"{type} ({GUIUtils.ColoredString(UnityEngine.Color.red, devastatedStat.ToString())} / {originalStat})";
}
}
}
}
}
@@ -26,10 +26,6 @@ namespace eagle {
private TextMeshProUGUI _textComponent;
private PersistentClientConnection _connection;
private IGameStateProvider _gameStateProvider;
private string _environmentName;
[Tooltip("Optional text component to display connected environment (prod/qa)")]
public TextMeshProUGUI environmentIndicatorText;
[Tooltip("Optional button to force immediate reconnection when server is down")]
public Button retryButton;
@@ -68,15 +64,6 @@ namespace eagle {
_gameStateProvider = provider;
}
/// <summary>
/// Set the environment name to display (e.g., "prod." or "qa.").
/// Pass null to clear the indicator.
/// </summary>
public void SetEnvironment(string environmentName) {
_environmentName = environmentName;
UpdateEnvironmentIndicator();
}
void Update() {
if (_connection == null || _textComponent == null) { return; }
@@ -178,20 +165,5 @@ namespace eagle {
private void OnRetryClicked() {
if (_connection != null) { _connection.ForceReconnect(); }
}
private void UpdateEnvironmentIndicator() {
if (environmentIndicatorText == null) return;
if (string.IsNullOrEmpty(_environmentName)) {
environmentIndicatorText.text = "";
return;
}
// Color prod green, qa yellow
environmentIndicatorText.text =
_environmentName switch { "prod." => $"<color=green>{_environmentName}</color>",
"qa." => $"<color=yellow>{_environmentName}</color>",
_ => _environmentName };
}
}
}
@@ -1,117 +0,0 @@
fileFormatVersion: 2
guid: 1fb1f6deb68f9475281c9f7c427a7024
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -243,8 +243,7 @@ namespace eagle {
public void SetUpGame(
long gameId,
int? playerId,
PersistentClientConnection persistentClientConnection,
string environmentName = null) {
PersistentClientConnection persistentClientConnection) {
ModelUpdater = new GameModelUpdater(
gameId,
playerId,
@@ -270,12 +269,9 @@ namespace eagle {
ModelUpdater.ErrorHandler = errorHandler;
// Set up game state provider and environment for connection status UI
// Set up game state provider for connection status UI
var statusUI = connectionStatusLabel.GetComponent<ConnectionStatusUI>();
if (statusUI != null) {
statusUI.SetGameStateProvider(ModelUpdater);
statusUI.SetEnvironment(environmentName);
}
if (statusUI != null) { statusUI.SetGameStateProvider(ModelUpdater); }
// Fire-and-forget - subscription is awaited internally and failures are logged
MainQueue.Q.EnqueueForNextUpdate(
@@ -348,10 +348,6 @@ namespace eagle {
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
var specResponse = updateItem.ShardokActionResultResponse.ShardokGameResponses;
// Collect ended games to remove AFTER the UI callback, so the UI
// can see the final battle results before the models are removed.
var endedGames = new List<ShardokGameId>();
foreach (var oneResponse in specResponse) {
if (!_currentModel.ShardokGameModels.TryGetValue(
oneResponse.ShardokGameId,
@@ -392,22 +388,14 @@ namespace eagle {
_currentModel.ShardokGameModels[oneResponse.ShardokGameId] =
shardokGameModel;
} else {
// Game ended - keep model for UI callback, mark for removal after
_currentModel.ShardokGameModels[oneResponse.ShardokGameId] =
shardokGameModel;
endedGames.Add(oneResponse.ShardokGameId);
// Game ended - remove from active models so UI knows battle is over
_currentModel.ShardokGameModels.TryRemove(
oneResponse.ShardokGameId,
out _);
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
}
}
// Invoke UI callback BEFORE removing ended games, so the UI can
// see the final battle results (with GameStatus = Victory/Defeat)
if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
// Now remove ended games from active models
foreach (var endedGameId in endedGames) {
_currentModel.ShardokGameModels.TryRemove(endedGameId, out _);
_shardokResultCounts.TryRemove(endedGameId, out _);
}
break;
case GameUpdate.GameUpdateDetailsOneofCase.StreamingTextResponse:
@@ -514,22 +502,13 @@ namespace eagle {
_connectionLogger.LogLine(
$"[STATE_RESYNC] timestamp={timestamp} round={startingState.CurrentRoundId} factions={startingState.Factions.Count} heroes={startingState.Heroes.Count}");
// Clear all stale state before applying new starting state.
// This handles rewind/reset scenarios where the server sends an earlier state.
_currentModel.ShardokGameModels.Clear();
_shardokResultCounts.Clear();
_shardokNeedsResync.Clear();
_currentModel.CommandToken = null;
_currentModel.LastPostedToken = -1;
_currentModel.AvailableCommandsByProvince.Clear();
_commandSubmittedTime = null;
_currentModel.GsView = startingState;
_currentModel.BattalionTypes =
startingState.BattalionTypes.ToDictionary(bt => bt.TypeId, bt => bt);
_currentModel.ChronicleEntries = startingState.ChronicleEntries.ToList();
// For any outstanding battles in the new state, create models and mark for resync.
// 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)) {
@@ -36,10 +36,10 @@ namespace eagle.Notifications.ARNNotifications {
$"{DisplayNames.ResolvedFactionString(lastFaction, currentModel)}'s hero {{HeroName}}";
}
textTemplate = $"{executingFactionName} has executed {victimDescription}!\n\n";
textTemplate = $"{executingFactionName} has executed {victimDescription}!";
heroPlaceholders["HeroName"] = (hero.NameTextId, "the hero");
} else {
textTemplate = $"{executingFactionName} has executed {{ExecutedHero}}!\n\n";
textTemplate = $"{executingFactionName} has executed {{ExecutedHero}}!";
heroPlaceholders["ExecutedHero"] = (hero.NameTextId, "the prisoner");
}
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: e661753a1e43a4f2da81b76457cf55c8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,117 +0,0 @@
fileFormatVersion: 2
guid: 8d524cd6fa08d47c99b2462c2686b8d0
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,117 +0,0 @@
fileFormatVersion: 2
guid: 1e69ea88b87d34a48bbb45a1fbad81fc
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,117 +0,0 @@
fileFormatVersion: 2
guid: 1850a7332b6e84e1a9b69f5f5b4cf482
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -46,7 +46,8 @@ namespace eagle {
private readonly Logger _timingsLogger = Logger.GetLogger("ConnectionLogger");
// Counter to diagnose duplicate logging - if logs show same seq# twice, two threads
// are processing same message. If seq# increments but lines doubled, Logger issue.
// are processing the same message. If seq# increments but lines appear doubled,
// it's a StreamWriter/Logger issue.
private int _logFlowSeq = 0;
/// <summary>Timestamped log for connection flow tracing.</summary>
@@ -89,23 +90,7 @@ namespace eagle {
_retryTimer?.Dispose();
NextReconnectAttempt = null;
LogConnectionEvent("force_reconnect", "User requested immediate reconnect");
RunConnectAsync();
}
/// <summary>
/// Fire-and-forget wrapper for Connect() that ensures exceptions are logged
/// rather than silently swallowed by Task.Run().
/// </summary>
private void RunConnectAsync() {
Task.Run(async () => {
try {
await Connect();
} catch (Exception e) {
// Log but don't rethrow - this is fire-and-forget
Console.WriteLine($"[CONNECT] Exception in Connect: {e}");
_remoteEagleClientLogger.LogLine($"Exception in Connect: {e}");
}
});
Task.Run(() => Connect());
}
private DateTime? GetDeadlineFromNow() {
@@ -159,7 +144,7 @@ namespace eagle {
_retryTimer = new Timer();
_retryTimer.AutoReset = false;
_retryTimer.Interval = backoffSeconds * 1000;
_retryTimer.Elapsed += (sender, args) => RunConnectAsync();
_retryTimer.Elapsed += (sender, args) => Task.Run(() => Connect());
_retryTimer.Enabled = true;
}
@@ -178,7 +163,6 @@ namespace eagle {
private readonly Dictionary<EagleGameId, TaskCompletionSource<SubscriptionAck>>
_pendingSubscriptionAcks = new();
private const int SubscriptionAckTimeoutMs = 10000; // 10 seconds
private const int WriteAsyncTimeoutMs = 10000; // 10 seconds for WriteAsync operations
public PersistentClientConnection(
Eagle.EagleClient grpcClient,
@@ -426,18 +410,6 @@ namespace eagle {
_pendingCommands.Add(pendingCommands.Dequeue());
}
}
// Clean up resources before reconnecting
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
_threadCancellationTokenSource?.Cancel();
}
MarkAllShardokGamesForResync();
CancelAllPendingSubscriptionAcks();
// Schedule reconnect - without this, the connection dies silently
ScheduleReconnect($"ConnectFailed-{e.GetType().Name}");
}
} finally { _isConnecting = false; }
}
@@ -475,35 +447,26 @@ namespace eagle {
lock (this) { _pendingCommands.Add(nextCommand); }
}
} else {
// CurrentEagleToken is null - server hasn't sent us
// commands yet, or we skipped them. Retry the pending
// command anyway; if the server already processed it, it
// will be ignored as duplicate.
_remoteEagleClientLogger.LogLine(
$"No current token, retrying pending command with token {providedToken}");
await PostRequest(nextCommand);
"No matching command for token " + providedToken);
}
break;
case UniversalCommand.SealedValueOneofCase.ShardokCommand: {
Int64 providedShardokToken =
nextCommand.Command.ShardokCommand.ShardokToken;
var currentShardokToken = subscriber.CurrentShardokToken(
nextCommand.Command.ShardokCommand.GameId);
if (currentShardokToken is Int64 shardokToken &&
if (subscriber.CurrentShardokToken(
nextCommand.Command.ShardokCommand.GameId)
is Int64 shardokToken &&
shardokToken == providedShardokToken) {
_remoteEagleClientLogger.LogLine(
"Found a matching pending command with token " +
shardokToken);
await PostRequest(nextCommand);
} else if (currentShardokToken == null) {
// No current token - retry anyway
_remoteEagleClientLogger.LogLine(
$"No current Shardok token, retrying pending command with token {providedShardokToken}");
await PostRequest(nextCommand);
} else {
_remoteEagleClientLogger.LogLine(
$"Shardok token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
"No matching command for token " +
providedShardokToken);
}
break;
@@ -513,27 +476,25 @@ namespace eagle {
Int64 providedShardokToken =
nextCommand.Command.ShardokPlacementCommands
.ShardokToken;
var currentShardokToken = subscriber.CurrentShardokToken(
nextCommand.Command.ShardokPlacementCommands.GameId);
if (currentShardokToken is Int64 shardokToken &&
if (subscriber.CurrentShardokToken(
nextCommand.Command.ShardokCommand.GameId)
is Int64 shardokToken &&
shardokToken == providedShardokToken) {
_remoteEagleClientLogger.LogLine(
"Found a matching pending placement command with token " +
"Found a matching pending command with token " +
shardokToken);
await PostRequest(nextCommand);
} else if (currentShardokToken == null) {
// No current token - retry anyway
_remoteEagleClientLogger.LogLine(
$"No current Shardok token, retrying pending placement command with token {providedShardokToken}");
await PostRequest(nextCommand);
} else {
_remoteEagleClientLogger.LogLine(
$"Shardok placement token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
"No matching command for token " +
providedShardokToken);
}
break;
}
}
await PostRequest(nextCommand);
}
}
@@ -652,25 +613,13 @@ namespace eagle {
try {
lock (this) { _pendingCommands.Add(request); }
var success = await DoWithStreamingCall(async (streamingCall) => {
await DoWithStreamingCall(async (streamingCall) => {
await streamingCall.RequestStream.WriteAsync(
new UpdateStreamRequest { PostCommandRequest = request });
return true;
});
// Only remove from pending if successfully sent.
// If connection was dead, leave in queue for retry after reconnect.
if (success) {
lock (this) { _pendingCommands.Remove(request); }
} else {
_remoteEagleClientLogger.LogLine(
$"[POST] Command not sent (connection dead), keeping in pending queue for retry");
}
} catch (Exception e) {
Console.WriteLine("Failed to post command: " + e);
_remoteEagleClientLogger.LogLine($"[POST] Exception posting command: {e.Message}");
// Leave in _pendingCommands for retry after reconnect
}
lock (this) { _pendingCommands.Remove(request); }
} catch (Exception e) { Console.WriteLine("Failed to post command: " + e); }
}
private async Task
@@ -747,38 +696,9 @@ namespace eagle {
private delegate Task<bool> WithStreamingCallDelegate(
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> call);
/// <summary>
/// Execute an action with the streaming call, with a timeout to prevent indefinite
/// blocking. If the action takes longer than WriteAsyncTimeoutMs, we consider the
/// connection dead and return false. This prevents thread pool exhaustion on dead
/// connections.
/// </summary>
private async Task<bool> DoWithStreamingCall(WithStreamingCallDelegate action) {
var sc = _streamingCall;
if (sc == null) return false;
try {
using var timeoutCts = new CancellationTokenSource(WriteAsyncTimeoutMs);
var actionTask = action(sc);
var timeoutTask = Task.Delay(WriteAsyncTimeoutMs, timeoutCts.Token);
var completedTask =
await Task.WhenAny(actionTask, timeoutTask).ConfigureAwait(false);
if (completedTask == timeoutTask) {
// WriteAsync timed out - connection is likely dead
LogConnectionEvent(
"write_timeout",
$"WriteAsync timed out after {WriteAsyncTimeoutMs}ms");
_remoteEagleClientLogger.LogLine(
$"[WRITE_TIMEOUT] WriteAsync timed out after {WriteAsyncTimeoutMs}ms, connection may be dead");
return false;
}
// Cancel the timeout task since action completed
timeoutCts.Cancel();
return await actionTask.ConfigureAwait(false);
return await action(_streamingCall);
} catch (RpcException e) {
if (e.StatusCode == StatusCode.Cancelled) {
// This is expected when the connection is closed.
@@ -786,15 +706,9 @@ namespace eagle {
} else {
throw;
}
} catch (OperationCanceledException) {
// Timeout was triggered
return false;
}
}
/// <summary>
/// Send a request on the streaming call with a timeout to prevent indefinite blocking.
/// </summary>
public async Task<bool> SendUpdateStreamRequestAsync(UpdateStreamRequest request) {
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc = null;
lock (this) {
@@ -803,30 +717,7 @@ namespace eagle {
}
try {
using var timeoutCts = new CancellationTokenSource(WriteAsyncTimeoutMs);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
_cancellationToken,
timeoutCts.Token);
var writeTask = sc.RequestStream.WriteAsync(request, linkedCts.Token);
var timeoutTask = Task.Delay(WriteAsyncTimeoutMs, timeoutCts.Token);
var completedTask =
await Task.WhenAny(writeTask, timeoutTask).ConfigureAwait(false);
if (completedTask == timeoutTask) {
// WriteAsync timed out - connection is likely dead
LogConnectionEvent(
"write_timeout",
$"SendUpdateStreamRequestAsync timed out after {WriteAsyncTimeoutMs}ms");
_remoteEagleClientLogger.LogLine(
$"[WRITE_TIMEOUT] SendUpdateStreamRequestAsync timed out after {WriteAsyncTimeoutMs}ms");
return false;
}
// Cancel the timeout task since write completed
timeoutCts.Cancel();
await writeTask.ConfigureAwait(false); // Observe any exception
await sc.RequestStream.WriteAsync(request, _cancellationToken);
return true;
} catch (RpcException e) {
if (e.StatusCode == StatusCode.Cancelled) {
@@ -835,9 +726,6 @@ namespace eagle {
} else {
throw;
}
} catch (OperationCanceledException) {
// Timeout was triggered
return false;
}
}
@@ -914,13 +802,7 @@ namespace eagle {
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
var shardokResp = gameUpdate.ShardokActionResultResponse;
var shardokGameCount = shardokResp.ShardokGameResponses?.Count ?? 0;
// Log each Shardok game's sequence to detect missing results
foreach (var sgr in shardokResp.ShardokGameResponses) {
var resultCount = sgr.ActionResultViews?.Count ?? 0;
LogFlow($"UPDATE ShardokResult game={gameUpdate.GameId} " +
$"shardok={sgr.ShardokGameId} countAfter={sgr.NewResultViewCount} " +
$"results={resultCount}");
}
LogFlow($"UPDATE ShardokResult game={gameUpdate.GameId} shardokGames={shardokGameCount}");
// Update result counts IMMEDIATELY on the gRPC thread, before enqueueing.
lock (this) {
@@ -1137,19 +1019,6 @@ namespace eagle {
CancelAllPendingSubscriptionAcks();
ScheduleReconnect("ObjectDisposed");
} catch (Exception e) {
// Catch-all for any unexpected exceptions. Without this, exceptions in an
// async void method can crash the process or be silently lost, leaving the
// connection dead with no recovery.
_lastDisconnect = DateTime.UtcNow;
LogConnectionEvent("disconnect", $"UnexpectedException: {e.GetType().Name}");
_remoteEagleClientLogger.LogLine(
$"UNEXPECTED exception in HandleStreamingCall: {e}");
MarkAllShardokGamesForResync();
CancelAllPendingSubscriptionAcks();
ScheduleReconnect($"UnexpectedException-{e.GetType().Name}");
}
return;
@@ -1184,15 +1053,9 @@ namespace eagle {
Interval = 5000 // Check every 5 seconds
};
_idleCheckTimer.Elapsed += (sender, args) => {
try {
// Log BEFORE callback to confirm timer is firing
Console.WriteLine($"[IDLE_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
CheckForIdleTimeout();
} catch (Exception e) {
// Timer callbacks must not throw - it can stop the timer from firing again
Console.WriteLine($"[IDLE_TIMER] Exception in callback: {e}");
_remoteEagleClientLogger.LogLine($"Exception in idle check timer: {e}");
}
// Log BEFORE callback to confirm timer is firing
Console.WriteLine($"[IDLE_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
CheckForIdleTimeout();
};
_idleCheckTimer.Enabled = true;
}
@@ -1238,7 +1101,7 @@ namespace eagle {
if (toCancel != null) {
toCancel.Dispose();
lock (this) { _streamingCall = null; }
RunConnectAsync();
Task.Run(() => Connect());
}
}
}
@@ -1248,24 +1111,9 @@ namespace eagle {
_heartbeatTimer =
new Timer { AutoReset = true, Interval = HeartbeatIntervalSeconds * 1000 };
_heartbeatTimer.Elapsed += (sender, args) => {
try {
// Log BEFORE Task.Run to confirm timer is firing
Console.WriteLine(
$"[HEARTBEAT_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
Task.Run(async () => {
try {
await SendHeartbeat();
} catch (Exception e) {
// Log but don't rethrow - exceptions in Task.Run are silently lost
Console.WriteLine($"[HEARTBEAT] Exception in SendHeartbeat: {e}");
_remoteEagleClientLogger.LogLine($"Exception in SendHeartbeat: {e}");
}
});
} catch (Exception e) {
// Timer callbacks must not throw
Console.WriteLine($"[HEARTBEAT_TIMER] Exception in callback: {e}");
_remoteEagleClientLogger.LogLine($"Exception in heartbeat timer: {e}");
}
// Log BEFORE Task.Run to confirm timer is firing
Console.WriteLine($"[HEARTBEAT_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
Task.Run(() => SendHeartbeat());
};
_heartbeatTimer.Enabled = true;
}
@@ -1,6 +1,5 @@
using System;
using System.Threading;
using Auth;
using Cysharp.Net.Http;
using eagle;
using Grpc.Core;
@@ -58,25 +57,6 @@ public class EagleConnection : IDisposable {
};
}
private CallInvoker MakeJwtInvoker(string url) {
var jwtInterceptor = new JwtAuthInterceptor();
_channel = GrpcChannel.ForAddress(
"https://" + url,
new GrpcChannelOptions {
HttpHandler = CreateHttpHandler(),
DisposeHttpClient = true,
LoggerFactory = _loggerFactory,
MaxReceiveMessageSize = null
});
var invoker = _channel.Intercept(jwtInterceptor);
return invoker;
}
/// <summary>
/// Create connection using Basic Auth (legacy).
/// </summary>
public EagleConnection(string playerName, string password, string url) {
this.playerName = playerName;
credentials = new Metadata { { "user", playerName } };
@@ -88,26 +68,6 @@ public class EagleConnection : IDisposable {
EagleGrpcClient = new Eagle.EagleClient(MakeInvoker(playerName, password, url));
}
/// <summary>
/// Create connection using JWT Bearer token auth.
/// Tokens are read from TokenStorage on each request.
/// </summary>
public static EagleConnection CreateWithJwt(string url) {
return new EagleConnection(url, useJwt: true);
}
private EagleConnection(string url, bool useJwt) {
// For JWT auth, get display name from TokenStorage
playerName = TokenStorage.DisplayName ?? "Unknown";
credentials = new Metadata { { "user", TokenStorage.UserId ?? "" } };
authHeader = null; // Not used for JWT
_loggerFactory = LoggerFactory.Create(
builder => { builder.AddProvider(new CustomFileLoggerProvider()); });
EagleGrpcClient = new Eagle.EagleClient(MakeJwtInvoker(url));
}
public void Dispose() {
try {
_channel?.Dispose();
File diff suppressed because it is too large Load Diff
@@ -413,7 +413,6 @@ public class HexGrid : MonoBehaviour {
}
public void SetUnitInfoLabels(int cellIndex, string mainValue, string secondaryValue = null) {
if (cells == null) return;
cells[cellIndex].UpperUnitInfoLabel.text = mainValue;
cells[cellIndex].LowerUnitInfoLabel.text = secondaryValue;
File diff suppressed because one or more lines are too long
@@ -1,22 +1,12 @@
using System;
using System.Collections.Concurrent;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using UnityEngine;
namespace common {
/// <summary>
/// Non-blocking logger that queues messages and writes them on a dedicated background thread.
/// This ensures that file I/O issues (antivirus, cloud sync, slow disk) never block the caller.
/// </summary>
public sealed class Logger : IDisposable {
private readonly StreamWriter _sw;
private readonly ConcurrentQueue<string> _messageQueue = new ConcurrentQueue<string>();
private readonly Thread _writerThread;
private readonly AutoResetEvent _messageAvailable = new AutoResetEvent(false);
private volatile bool _disposed;
private readonly string _name;
private readonly object _lock = new object();
private static Dictionary<String, Logger> _loggers = new Dictionary<String, Logger>();
private static readonly object _loggersLock = new object();
@@ -28,12 +18,11 @@ namespace common {
}
}
private static string CurrentTimeString() {
private string CurrentTimeString() {
return DateTime.UtcNow.ToString("yyyyMMdd_HHmmss.fff");
}
private Logger(string name) {
_name = name;
var directoryPath =
Path.Combine(Application.persistentDataPath, "eagle0", "Resources", name);
@@ -41,94 +30,19 @@ namespace common {
var logFilePath = Path.Combine(directoryPath, $"{name}_{CurrentTimeString()}.txt");
_sw = new StreamWriter(logFilePath, append: true);
// Start dedicated writer thread
_writerThread = new Thread(WriterLoop) {
Name = $"Logger-{name}",
IsBackground = true // Won't prevent app exit
};
_writerThread.Start();
}
/// <summary>
/// Queue a log message. This method never blocks on file I/O.
/// </summary>
public void LogLine(string line) {
if (_disposed) return;
// Format the message with timestamp immediately (captures accurate time)
var formattedMessage = CurrentTimeString() + " " + line;
_messageQueue.Enqueue(formattedMessage);
_messageAvailable.Set(); // Signal the writer thread
}
/// <summary>
/// Background thread that processes the message queue and writes to file.
/// File I/O blocking only affects this thread, not the callers of LogLine().
/// </summary>
private void WriterLoop() {
while (!_disposed) {
try {
// Wait for messages with timeout so we can check _disposed periodically
_messageAvailable.WaitOne(1000);
// Process all queued messages
while (_messageQueue.TryDequeue(out var message)) {
try {
_sw.WriteLine(message);
} catch (Exception ex) {
// File write failed - log to Console as fallback
Console.WriteLine($"[Logger-{_name}] Write failed: {ex.Message}");
}
}
// Flush after processing batch
try {
_sw.Flush();
} catch (Exception ex) {
Console.WriteLine($"[Logger-{_name}] Flush failed: {ex.Message}");
}
} catch (Exception ex) {
// Don't let any exception kill the writer thread
Console.WriteLine($"[Logger-{_name}] WriterLoop error: {ex.Message}");
}
}
// Drain remaining messages on shutdown
while (_messageQueue.TryDequeue(out var message)) {
try {
_sw.WriteLine(message);
} catch { /* ignore on shutdown */
}
}
try {
lock (_lock) {
_sw.WriteLine(CurrentTimeString() + " " + line);
_sw.Flush();
} catch { /* ignore on shutdown */
}
}
public void Close() { Dispose(); }
public void Close() { _sw.Close(); }
public void Dispose() {
if (_disposed) return;
_disposed = true;
// Signal writer thread to exit and wait for it
_messageAvailable.Set();
if (_writerThread != null && _writerThread.IsAlive) {
_writerThread.Join(5000); // Wait up to 5 seconds for clean shutdown
}
try {
_sw?.Dispose();
} catch { /* ignore */
}
try {
_messageAvailable?.Dispose();
} catch { /* ignore */
}
if (_sw != null) { _sw.Dispose(); }
}
}
}
}
@@ -6,7 +6,6 @@ using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Cysharp.Net.Http;
using eagle;
using Google.Protobuf;
using Net.Eagle0.Eagle.Api;
@@ -25,9 +24,10 @@ namespace common {
// Client that doesn't follow redirects, so we can retry each hop independently
private readonly HttpClient _noRedirectClient;
// Public CDN URL for headshots (no auth required)
private static readonly Uri BaseUri =
new("https://eagle0-headshots.sfo3.cdn.digitaloceanspaces.com/");
// Auth header to send only to eagle0.net (not to S3 signed URLs)
private readonly System.Net.Http.Headers.AuthenticationHeaderValue _authHeader;
private static readonly Uri BaseUri = new("https://eagle0.net/assets/headshots/");
private const int MaxRetryAttempts = 5;
private const int MaxRedirectHops = 5;
@@ -45,10 +45,6 @@ namespace common {
private readonly HashSet<string> _failedPaths = new();
private readonly object _failedPathsLock = new();
// Track paths currently being fetched to avoid duplicate requests
private readonly HashSet<string> _inFlightPaths = new();
private readonly object _inFlightLock = new();
private Timer _periodicRetryTimer;
ResourceFetcher(string relativeLocalPath, HttpClient httpClient) {
@@ -59,19 +55,14 @@ namespace common {
relativeLocalPath);
Directory.CreateDirectory(_localPath);
// Use HTTP/2 handler for better connection reuse and multiplexing.
// YetAnotherHttpHandler doesn't follow redirects automatically,
// which is what we want so we can retry each hop independently.
var handler = new YetAnotherHttpHandler {
Http2Only = true,
// Keep connections alive to improve reliability on poor networks
Http2KeepAliveInterval = TimeSpan.FromSeconds(15),
Http2KeepAliveTimeout = TimeSpan.FromSeconds(5),
Http2KeepAliveWhileIdle = true,
ConnectTimeout = TimeSpan.FromSeconds(RequestTimeoutSeconds)
};
// Create a client that doesn't follow redirects automatically,
// so we can retry each hop independently
var handler = new HttpClientHandler { AllowAutoRedirect = false };
_noRedirectClient = new HttpClient(
handler) { Timeout = TimeSpan.FromSeconds(RequestTimeoutSeconds) };
// Store auth header to add per-request only for eagle0.net
// (sending it to S3 signed URLs causes HTTP 400)
_authHeader = httpClient.DefaultRequestHeaders.Authorization;
// Start periodic retry timer
_periodicRetryTimer = new Timer(
@@ -112,45 +103,32 @@ namespace common {
}
private async Task<bool> FetchRemote(string path) {
// Check if this path is already being fetched
lock (_inFlightLock) {
if (_inFlightPaths.Contains(path)) {
// Already being fetched; the existing fetch will handle _imageLoadQueue
return false;
}
_inFlightPaths.Add(path);
}
Uri currentUri = new Uri(BaseUri, path);
try {
Uri currentUri = new Uri(BaseUri, path);
for (int hop = 0; hop < MaxRedirectHops; hop++) {
var result = await FetchOneHopWithRetry(currentUri, path, hop);
for (int hop = 0; hop < MaxRedirectHops; hop++) {
var result = await FetchOneHopWithRetry(currentUri, path, hop);
if (result.GotContent) {
// Successfully received and saved content
ClearFailed(path);
return true;
}
if (result.RedirectUri != null) {
// Follow the redirect
currentUri = result.RedirectUri;
continue;
}
// Failed and not a redirect
MarkFailed(path);
return false;
if (result.GotContent) {
// Successfully received and saved content
ClearFailed(path);
return true;
}
// Too many redirects
Debug.Log($"ResourceFetcher: Too many redirects for {path}");
if (result.RedirectUri != null) {
// Follow the redirect
currentUri = result.RedirectUri;
continue;
}
// Failed and not a redirect
MarkFailed(path);
return false;
} finally {
lock (_inFlightLock) { _inFlightPaths.Remove(path); }
}
// Too many redirects
Debug.Log($"ResourceFetcher: Too many redirects for {path}");
MarkFailed(path);
return false;
}
// Fetch a single hop with retries. Returns content, redirect, or failure.
@@ -159,6 +137,10 @@ namespace common {
try {
var request =
new HttpRequestMessage { Method = HttpMethod.Get, RequestUri = uri };
// Only send auth header to eagle0.net, not to S3 signed URLs
if (_authHeader != null && uri.Host.EndsWith("eagle0.net")) {
request.Headers.Authorization = _authHeader;
}
var response = await _noRedirectClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
@@ -199,9 +181,9 @@ namespace common {
if (attempt < MaxRetryAttempts - 1) {
await Task.Delay(RetryDelaysMs[attempt]);
}
} catch (OperationCanceledException e) {
} catch (TaskCanceledException e) {
Debug.Log(
$"ResourceFetcher: hop {hopIndex} canceled for {path} (attempt {attempt + 1}/{MaxRetryAttempts}): {e.Message}");
$"ResourceFetcher: hop {hopIndex} timeout for {path} (attempt {attempt + 1}/{MaxRetryAttempts}): {e.Message}");
if (attempt < MaxRetryAttempts - 1) {
await Task.Delay(RetryDelaysMs[attempt]);
}
@@ -309,13 +291,10 @@ namespace common {
public void Prefetch(IEnumerable<string> paths) {
List<string> toFetch;
lock (_failedPathsLock) {
lock (_inFlightLock) {
toFetch = paths.Where(path => !string.IsNullOrEmpty(path) &&
!File.Exists(FullLocalPath(path)) &&
!_failedPaths.Contains(path) &&
!_inFlightPaths.Contains(path))
.ToList();
}
toFetch = paths.Where(path => !string.IsNullOrEmpty(path) &&
!File.Exists(FullLocalPath(path)) &&
!_failedPaths.Contains(path))
.ToList();
}
foreach (var path in toFetch) { FetchRemote(path); }
@@ -5,7 +5,7 @@
"depth": 0,
"source": "git",
"dependencies": {},
"hash": "5719afbc6b1d8cfa8729f24f0fe6a0e9dfa70f3a"
"hash": ""
},
"com.unity.2d.sprite": {
"version": "1.0.0",
@@ -253,8 +253,5 @@
<Protobuf Include="..\..\..\..\..\..\..\..\..\src\main\protobuf\net\eagle0\eagle\api\command\util\expanded_combat_unit.proto" ProtoRoot="..\..\..\..\..\..\..\..\..\">
<Link>src\main\protobuf\net\eagle0\eagle\api\command\util\expanded_combat_unit.proto</Link>
</Protobuf>
<Protobuf Include="..\..\..\..\..\..\..\..\..\src\main\protobuf\net\eagle0\eagle\api\auth.proto" ProtoRoot="..\..\..\..\..\..\..\..\..\" GrpcServices="Client">
<Link>src\main\protobuf\net\eagle0\eagle\api\auth.proto</Link>
</Protobuf>
</ItemGroup>
</Project>
@@ -3,10 +3,6 @@ load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "admin_server_lib",
srcs = ["admin_server.go"],
embedsrcs = glob([
"static/*",
"templates/*.html",
]),
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/admin_server",
visibility = ["//visibility:private"],
deps = [
@@ -21,10 +17,6 @@ go_binary(
embed = [":admin_server_lib"],
pure = "on",
visibility = ["//visibility:public"],
x_defs = {
"main.buildTime": "{BUILD_TIMESTAMP}",
"main.gitCommit": "{STABLE_GIT_COMMIT}",
},
)
# Linux x86_64 target for Docker deployment
@@ -35,8 +27,4 @@ go_binary(
goos = "linux",
pure = "on",
visibility = ["//visibility:public"],
x_defs = {
"main.buildTime": "{BUILD_TIMESTAMP}",
"main.gitCommit": "{STABLE_GIT_COMMIT}",
},
)
File diff suppressed because it is too large Load Diff
@@ -1,565 +0,0 @@
/* Admin Server Styles - supplements Pico CSS */
:root {
--pico-font-size: 16px;
}
/* Navigation */
nav {
background: var(--pico-card-background-color);
padding: 1rem;
margin-bottom: 2rem;
border-bottom: 1px solid var(--pico-muted-border-color);
}
nav ul {
display: flex;
gap: 1.5rem;
list-style: none;
margin: 0;
padding: 0;
align-items: center;
}
nav .brand {
font-weight: bold;
font-size: 1.25rem;
margin-right: auto;
}
/* Game cards */
.game-card {
background: var(--pico-card-background-color);
border: 1px solid var(--pico-muted-border-color);
border-radius: var(--pico-border-radius);
padding: 1.5rem;
margin-bottom: 1rem;
}
.game-card h3 {
margin-top: 0;
margin-bottom: 0.5rem;
}
.game-card .meta {
color: var(--pico-muted-color);
font-size: 0.875rem;
margin-bottom: 0.75rem;
}
.game-card .players {
margin-bottom: 1rem;
}
.game-card .actions {
display: flex;
gap: 0.5rem;
}
/* Player badges */
.player-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: var(--pico-border-radius);
font-size: 0.875rem;
margin-right: 0.5rem;
margin-bottom: 0.25rem;
}
.player-badge.human {
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
}
.player-badge.ai {
background: var(--pico-secondary-background);
color: var(--pico-secondary-inverse);
}
/* Status badges */
.status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: var(--pico-border-radius);
font-size: 0.75rem;
text-transform: uppercase;
}
.status-badge.running {
background: #22c55e;
color: white;
}
.status-badge.finished {
background: var(--pico-muted-color);
color: white;
}
/* History table */
.history-table {
width: 100%;
}
.history-table th,
.history-table td {
text-align: left;
padding: 0.75rem;
}
.history-table tr:hover {
background: var(--pico-card-background-color);
}
.action-type {
font-family: monospace;
font-size: 0.875rem;
}
/* Loading indicator */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-request.htmx-indicator {
display: inline;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem;
color: var(--pico-muted-color);
}
/* Page header */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header h1 {
margin: 0;
}
/* Breadcrumbs */
.breadcrumb {
display: flex;
gap: 0.5rem;
font-size: 0.875rem;
margin-bottom: 1rem;
color: var(--pico-muted-color);
}
.breadcrumb a {
color: var(--pico-muted-color);
}
.breadcrumb .separator {
color: var(--pico-muted-color);
}
/* Clickable action rows */
.action-row {
cursor: pointer;
transition: background-color 0.15s ease;
}
.action-row:hover {
background: var(--pico-primary-background) !important;
color: var(--pico-primary-inverse);
}
/* Action detail panel */
.action-detail-row {
background: var(--pico-card-background-color);
}
.action-detail {
padding: 1rem;
border: 1px solid var(--pico-muted-border-color);
border-radius: var(--pico-border-radius);
margin: 0.5rem 0;
}
.action-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--pico-muted-border-color);
}
.action-detail .close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0 0.5rem;
color: var(--pico-muted-color);
line-height: 1;
}
.action-detail .close-btn:hover {
color: var(--pico-del-color);
}
.action-json {
background: var(--pico-code-background-color);
padding: 1rem;
border-radius: var(--pico-border-radius);
overflow-x: auto;
font-size: 0.875rem;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Settings page */
.settings-search {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.settings-search input {
flex: 1;
max-width: 400px;
margin: 0;
}
.settings-count {
color: var(--pico-muted-color);
font-size: 0.875rem;
}
.settings-table {
width: 100%;
border-collapse: collapse;
}
.settings-table th,
.settings-table td {
text-align: left;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--pico-muted-border-color);
vertical-align: middle;
}
.settings-table th {
background: var(--pico-card-background-color);
font-weight: 600;
font-size: 0.75rem;
}
.setting-name {
font-family: monospace;
font-size: 0.8rem;
}
.setting-type {
font-size: 0.7rem;
color: var(--pico-muted-color);
text-transform: uppercase;
}
.setting-value form {
display: flex;
gap: 0.25rem;
margin: 0;
align-items: center;
}
.setting-input {
width: 100px;
padding: 2px 6px !important;
margin: 0 !important;
font-size: 0.75rem;
height: 22px !important;
line-height: 1 !important;
border-width: 1px;
}
.setting-default {
font-size: 0.8rem;
color: var(--pico-muted-color);
}
.setting-row.modified {
background: rgba(255, 193, 7, 0.1);
}
.setting-row.modified .setting-name {
font-weight: bold;
}
.btn-small {
padding: 2px 8px !important;
font-size: 0.7rem;
margin: 0 !important;
height: 22px !important;
line-height: 1 !important;
}
.actions-cell {
white-space: nowrap;
}
.actions-cell button {
margin-right: 0.25rem;
}
.settings-note {
margin-top: 2rem;
padding: 1rem;
background: var(--pico-card-background-color);
border-radius: var(--pico-border-radius);
font-size: 0.875rem;
color: var(--pico-muted-color);
}
.error {
color: var(--pico-del-color);
}
.success {
color: var(--pico-ins-color);
}
/* Players table */
.players-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 2rem;
}
.players-table th,
.players-table td {
text-align: left;
padding: 0.75rem;
border-bottom: 1px solid var(--pico-muted-border-color);
}
.players-table th {
background: var(--pico-card-background-color);
font-weight: 600;
}
.player-type {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: var(--pico-border-radius);
font-size: 0.75rem;
text-transform: uppercase;
}
.player-type.human {
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
}
.player-type.ai {
background: var(--pico-secondary-background);
color: var(--pico-secondary-inverse);
}
/* Button variants */
.btn-primary {
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
border: none;
}
.btn-secondary {
background: var(--pico-secondary-background);
color: var(--pico-secondary-inverse);
border: none;
}
.btn-danger {
background: var(--pico-del-color);
color: white;
border: none;
}
.btn-danger:hover {
background: #c0392b;
}
/* Modal dialogs */
dialog {
border: 1px solid var(--pico-muted-border-color);
border-radius: var(--pico-border-radius);
padding: 1.5rem;
min-width: 300px;
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}
dialog h3 {
margin-top: 0;
margin-bottom: 1rem;
}
dialog .form-group {
margin-bottom: 1rem;
}
dialog .form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
dialog .form-group input {
width: 100%;
margin: 0;
}
dialog .modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
dialog .modal-actions button {
min-width: 80px;
}
/* Rewind button and cell */
.rewind-cell {
text-align: center;
}
.btn-rewind {
padding: 2px 8px !important;
font-size: 0.7rem;
margin: 0 !important;
height: 22px !important;
line-height: 1 !important;
background: var(--pico-del-color);
border-color: var(--pico-del-color);
color: white;
cursor: pointer;
}
.btn-rewind:hover {
background: #c82333;
border-color: #c82333;
}
/* Alert messages */
.alert {
padding: 1rem;
border-radius: var(--pico-border-radius);
margin-bottom: 1rem;
}
.alert-success,
.alert.success {
background: rgba(34, 197, 94, 0.2);
color: #16a34a;
border: 1px solid #22c55e;
}
.alert-error,
.alert.error {
background: rgba(239, 68, 68, 0.2);
color: #dc2626;
border: 1px solid #ef4444;
}
.alert a {
color: inherit;
font-weight: bold;
}
/* Table action buttons */
.players-table .actions {
display: flex;
gap: 0.5rem;
}
/* JFR controls in nav */
#jfr-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.jfr-status {
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.5rem;
border-radius: var(--pico-border-radius);
text-transform: uppercase;
}
.jfr-on {
background: #22c55e;
color: white;
}
.jfr-off {
background: var(--pico-muted-color);
color: white;
}
.jfr-loading {
color: var(--pico-muted-color);
font-size: 0.875rem;
}
.jfr-error {
color: var(--pico-del-color);
font-size: 0.875rem;
}
#jfr-controls .btn-small {
padding: 0.25rem 0.5rem !important;
font-size: 0.75rem;
height: auto !important;
line-height: 1.2 !important;
text-decoration: none;
display: inline-block;
}
#jfr-controls button.btn-small {
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
border: none;
cursor: pointer;
}
#jfr-controls button.btn-small.secondary {
background: var(--pico-secondary-background);
color: var(--pico-secondary-inverse);
}
#jfr-controls a.btn-small {
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
border-radius: var(--pico-border-radius);
}
/* Result div */
.rewind-result:empty {
display: none;
}
/* Build info in header */
.build-info {
font-size: 0.7rem;
font-weight: normal;
color: var(--pico-muted-color);
}
@@ -1,11 +0,0 @@
<tr class="action-detail-row">
<td colspan="6">
<div class="action-detail">
<div class="action-detail-header">
<strong>Action #{{.Index}}</strong> - {{.ActionType}} (Round {{.RoundId}})
<button class="close-btn" onclick="this.closest('tr').remove()">&times;</button>
</div>
<pre class="action-json">{{.ActionJSON}}</pre>
</div>
</td>
</tr>
@@ -1,191 +0,0 @@
{{define "content"}}
<nav class="breadcrumb">
<a href="/">Games</a>
<span class="separator">/</span>
<span>{{.Game.GameID}}</span>
</nav>
<div class="page-header">
<h1>Game {{.Game.GameID}}</h1>
<span class="status-badge {{if eq .Game.RunStatus "Running"}}running{{else}}finished{{end}}">
{{.Game.RunStatus}}
</span>
</div>
<article>
<header>
<strong>Round {{.Game.CurrentRound}}</strong> &bull; {{.Game.ActionCount}} total actions
</header>
</article>
<h2>Players</h2>
<div id="player-management-result"></div>
<table class="players-table">
<thead>
<tr>
<th>Faction ID</th>
<th>Faction Name</th>
<th>Leader</th>
<th>Type</th>
<th>User</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Game.Players}}
<tr>
<td>{{.FactionID}}</td>
<td>{{.FactionName}}</td>
<td>{{.LeaderName}}</td>
<td>
<span class="player-type {{if .IsHuman}}human{{else}}ai{{end}}">
{{if .IsHuman}}Human{{else}}AI{{end}}
</span>
</td>
<td>{{if .IsHuman}}{{.UserName}}{{else}}-{{end}}</td>
<td class="actions">
{{if .IsHuman}}
<button class="btn-small btn-danger"
onclick="confirmDropToAi('{{$.Game.GameID}}', {{.FactionID}}, '{{.UserName}}')">
Drop to AI
</button>
<button class="btn-small"
onclick="showReassignModal('{{$.Game.GameID}}', {{.FactionID}}, '{{.UserName}}')">
Reassign
</button>
{{else}}
<button class="btn-small btn-primary"
onclick="showAssignUserModal('{{$.Game.GameID}}', {{.FactionID}})">
Assign User
</button>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
<!-- Modal for Assign User -->
<dialog id="assign-user-modal">
<form method="dialog">
<h3>Assign User to AI Faction</h3>
<div class="form-group">
<label for="assign-username">Username:</label>
<input type="text" id="assign-username" name="username" required>
</div>
<input type="hidden" id="assign-faction-id" name="faction_id">
<input type="hidden" id="assign-game-id" name="game_id">
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="document.getElementById('assign-user-modal').close()">Cancel</button>
<button type="button" class="btn-primary" onclick="submitAssignUser()">Assign</button>
</div>
</form>
</dialog>
<!-- Modal for Reassign -->
<dialog id="reassign-modal">
<form method="dialog">
<h3>Reassign Faction</h3>
<p id="reassign-current-user"></p>
<div class="form-group">
<label for="reassign-username">New Username:</label>
<input type="text" id="reassign-username" name="username" required>
</div>
<input type="hidden" id="reassign-faction-id" name="faction_id">
<input type="hidden" id="reassign-game-id" name="game_id">
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="document.getElementById('reassign-modal').close()">Cancel</button>
<button type="button" class="btn-primary" onclick="submitReassign()">Reassign</button>
</div>
</form>
</dialog>
<script>
function showAssignUserModal(gameId, factionId) {
document.getElementById('assign-game-id').value = gameId;
document.getElementById('assign-faction-id').value = factionId;
document.getElementById('assign-username').value = '';
document.getElementById('assign-user-modal').showModal();
}
function submitAssignUser() {
const gameId = document.getElementById('assign-game-id').value;
const factionId = document.getElementById('assign-faction-id').value;
const username = document.getElementById('assign-username').value;
if (!username.trim()) {
alert('Username is required');
return;
}
htmx.ajax('POST', '/games/' + gameId + '/player-management/ai-to-human', {
values: {faction_id: factionId, username: username},
target: '#player-management-result',
swap: 'innerHTML'
});
document.getElementById('assign-user-modal').close();
}
function confirmDropToAi(gameId, factionId, username) {
if (confirm('Are you sure you want to convert "' + username + '" to AI control?')) {
htmx.ajax('POST', '/games/' + gameId + '/player-management/human-to-ai', {
values: {faction_id: factionId},
target: '#player-management-result',
swap: 'innerHTML'
});
}
}
function showReassignModal(gameId, factionId, currentUsername) {
document.getElementById('reassign-game-id').value = gameId;
document.getElementById('reassign-faction-id').value = factionId;
document.getElementById('reassign-current-user').textContent = 'Currently assigned to: ' + currentUsername;
document.getElementById('reassign-username').value = '';
document.getElementById('reassign-modal').showModal();
}
function submitReassign() {
const gameId = document.getElementById('reassign-game-id').value;
const factionId = document.getElementById('reassign-faction-id').value;
const username = document.getElementById('reassign-username').value;
if (!username.trim()) {
alert('Username is required');
return;
}
htmx.ajax('POST', '/games/' + gameId + '/player-management/reassign', {
values: {faction_id: factionId, username: username},
target: '#player-management-result',
swap: 'innerHTML'
});
document.getElementById('reassign-modal').close();
}
</script>
<h2>Action History</h2>
<div id="rewind-result" class="rewind-result"></div>
<table class="history-table">
<thead>
<tr>
<th style="width: 60px">#</th>
<th>Action Type</th>
<th>Faction</th>
<th style="width: 120px">Date</th>
<th style="width: 80px">Round</th>
<th style="width: 80px">Actions</th>
</tr>
</thead>
<tbody id="history-body"
hx-get="/games/{{.Game.GameID}}/history?start=0&limit=50"
hx-trigger="load"
hx-swap="innerHTML">
<tr>
<td colspan="6" class="htmx-indicator">Loading history...</td>
</tr>
</tbody>
</table>
{{end}}
@@ -1,11 +0,0 @@
<tr class="game-state-row">
<td colspan="6">
<div class="action-detail">
<div class="action-detail-header">
<strong>Game State after Action #{{.ActionIndex}}</strong>
<button class="close-btn" onclick="this.closest('tr').remove()">&times;</button>
</div>
<pre class="action-json">{{.GameStateJSON}}</pre>
</div>
</td>
</tr>
@@ -1,37 +0,0 @@
{{define "content"}}
<div class="page-header">
<h1>Running Games</h1>
<span>{{len .Games}} game{{if ne (len .Games) 1}}s{{end}}</span>
</div>
{{if .Games}}
{{range .Games}}
<article class="game-card">
<h3>
<a href="/games/{{.GameID}}">Game {{.GameID}}</a>
<span class="status-badge {{if eq .RunStatus "Running"}}running{{else}}finished{{end}}">
{{.RunStatus}}
</span>
</h3>
<div class="meta">
Round {{.CurrentRound}} &bull; {{.ActionCount}} actions
</div>
<div class="players">
{{range .Players}}
<span class="player-badge {{if .IsHuman}}human{{else}}ai{{end}}">
{{.FactionName}}{{if .IsHuman}} ({{.UserName}}){{end}}
</span>
{{end}}
</div>
<div class="actions">
<a href="/games/{{.GameID}}" role="button" class="outline">View History</a>
</div>
</article>
{{end}}
{{else}}
<div class="empty-state">
<p>No games currently running.</p>
<p>Start a game from the Unity client to see it here.</p>
</div>
{{end}}
{{end}}
@@ -1,58 +0,0 @@
{{range .Entries}}
<tr class="action-row" onclick="toggleActionDetail(this)">
<td>{{.Index}}</td>
<td class="action-type">{{.ActionType}}</td>
<td>{{.FactionName}}</td>
<td>{{.GameDate}}</td>
<td>{{.RoundId}}</td>
<td class="actions-cell">
<button class="btn-small"
hx-get="/games/{{$.GameID}}/state/{{.Index}}"
hx-target="closest tr"
hx-swap="afterend"
onclick="event.stopPropagation()">
State
</button>
<button class="btn-rewind"
hx-post="/games/{{$.GameID}}/rewind"
hx-vals='{"targetAction": "{{.Index}}"}'
hx-confirm="Rewind game to action {{.Index}}? This will disconnect all clients and cannot be undone."
hx-target="#rewind-result"
hx-swap="innerHTML"
onclick="event.stopPropagation()">
Rewind
</button>
</td>
</tr>
<tr class="action-detail-row" style="display: none;">
<td colspan="6">
<div class="action-detail">
<div class="action-detail-header">
<strong>Action #{{.Index}}</strong> - {{.ActionType}} (Round {{.RoundId}})
<button class="close-btn" onclick="event.stopPropagation(); this.closest('tr').style.display='none'">&times;</button>
</div>
<pre class="action-json">{{.ActionJSON}}</pre>
</div>
</td>
</tr>
{{end}}
{{if .HasMore}}
<tr hx-get="/games/{{.GameID}}/history?start={{.NextStart}}&limit=50"
hx-trigger="revealed"
hx-swap="outerHTML">
<td colspan="6" class="htmx-indicator">Loading more...</td>
</tr>
{{end}}
{{if and (not .Entries) (not .HasMore)}}
<tr>
<td colspan="6" class="empty-state">No actions recorded yet.</td>
</tr>
{{end}}
<script>
function toggleActionDetail(row) {
const detailRow = row.nextElementSibling;
if (detailRow && detailRow.classList.contains('action-detail-row')) {
detailRow.style.display = detailRow.style.display === 'none' ? '' : 'none';
}
}
</script>
@@ -1,67 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - Eagle Admin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="/static/style.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<nav>
<ul>
<li class="brand">Eagle Admin <span class="build-info">{{if .BuildInfo.GitCommit}}({{.BuildInfo.GitCommit}}{{if .BuildInfo.BuildTime}}, {{.BuildInfo.BuildTime}}{{end}}){{end}}</span></li>
<li><a href="/">Games</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/health">Health</a></li>
<li id="jfr-controls" hx-get="/jfr/status" hx-trigger="load" hx-swap="innerHTML">
<span class="jfr-loading">JFR: ...</span>
</li>
</ul>
</nav>
<main class="container">
{{template "content" .}}
</main>
<script>
// Handle JFR status response and update controls
document.body.addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail.pathInfo.requestPath === '/jfr/status') {
try {
const data = JSON.parse(evt.detail.xhr.responseText);
updateJfrControls(data.recording);
} catch(e) {
document.getElementById('jfr-controls').innerHTML =
'<span class="jfr-error">JFR: Error</span>';
}
} else if (evt.detail.pathInfo.requestPath === '/jfr/start' ||
evt.detail.pathInfo.requestPath === '/jfr/stop') {
try {
const data = JSON.parse(evt.detail.xhr.responseText);
updateJfrControls(data.recording);
} catch(e) {
// Refresh status on error
htmx.ajax('GET', '/jfr/status', '#jfr-controls');
}
}
});
function updateJfrControls(isRecording) {
const container = document.getElementById('jfr-controls');
if (isRecording) {
container.innerHTML = `
<span class="jfr-status jfr-on">JFR: ON</span>
<button hx-post="/jfr/stop" hx-swap="none" class="btn-small secondary">Stop</button>
<a href="/jfr/download" class="btn-small">Download</a>
`;
} else {
container.innerHTML = `
<span class="jfr-status jfr-off">JFR: OFF</span>
<button hx-post="/jfr/start" hx-swap="none" class="btn-small">Start</button>
`;
}
htmx.process(container);
}
</script>
</body>
</html>
@@ -1,52 +0,0 @@
{{define "content"}}
<h1>Settings</h1>
<div class="settings-search">
<input type="text"
id="settings-filter"
name="filter"
placeholder="Search settings..."
value="{{.Filter}}"
hx-get="/settings/search"
hx-trigger="input changed delay:300ms, keyup[key=='Enter']"
hx-target="#settings-body"
hx-swap="innerHTML">
<span class="settings-count">{{len .Settings}} settings</span>
</div>
<table class="settings-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Current Value</th>
<th>Default</th>
</tr>
</thead>
<tbody id="settings-body">
{{range .Settings}}
<tr class="setting-row{{if .IsModified}} modified{{end}}" id="setting-{{.Name}}">
<td class="setting-name">{{.Name}}</td>
<td class="setting-type">{{.SettingType}}</td>
<td class="setting-value">
<form hx-post="/settings/update" hx-target="#setting-{{.Name}}" hx-swap="outerHTML">
<input type="hidden" name="name" value="{{.Name}}">
<input type="text" name="value" value="{{.CurrentValue}}" class="setting-input">
<button type="submit" class="btn-small">Save</button>
</form>
</td>
<td class="setting-default">{{.DefaultValue}}</td>
</tr>
{{end}}
{{if not .Settings}}
<tr>
<td colspan="4" class="empty-state">No settings found.</td>
</tr>
{{end}}
</tbody>
</table>
<p class="settings-note">
<strong>Note:</strong> Settings changes are in-memory only. A server restart will reset all values to defaults.
</p>
{{end}}
@@ -1,19 +0,0 @@
{{range .Settings}}
<tr class="setting-row{{if .IsModified}} modified{{end}}" id="setting-{{.Name}}">
<td class="setting-name">{{.Name}}</td>
<td class="setting-type">{{.SettingType}}</td>
<td class="setting-value">
<form hx-post="/settings/update" hx-target="#setting-{{.Name}}" hx-swap="outerHTML">
<input type="hidden" name="name" value="{{.Name}}">
<input type="text" name="value" value="{{.CurrentValue}}" class="setting-input">
<button type="submit" class="btn-small">Save</button>
</form>
</td>
<td class="setting-default">{{.DefaultValue}}</td>
</tr>
{{end}}
{{if not .Settings}}
<tr>
<td colspan="4" class="empty-state">No settings match "{{.Filter}}"</td>
</tr>
{{end}}
@@ -15,9 +15,8 @@ func usage() {
}
type Setting struct {
Name string
Type string
DefaultValue string
Name string
Type string
}
func parseSettings(buildFile string) ([]Setting, error) {
@@ -33,38 +32,30 @@ func parseSettings(buildFile string) ([]Setting, error) {
// Regex patterns to extract setting info
settingNameRegex := regexp.MustCompile(`setting_name = "([^"]+)"`)
settingTypeRegex := regexp.MustCompile(`setting_type = "([^"]+)"`)
settingValueRegex := regexp.MustCompile(`setting_value = "([^"]+)"`)
var currentName, currentType, currentValue string
var currentName, currentType string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Look for setting_name
if matches := settingNameRegex.FindStringSubmatch(line); matches != nil {
currentName = matches[1]
}
// Look for setting_type
if matches := settingTypeRegex.FindStringSubmatch(line); matches != nil {
currentType = matches[1]
}
// Look for setting_value
if matches := settingValueRegex.FindStringSubmatch(line); matches != nil {
currentValue = matches[1]
}
// When we hit the end of a scala_setting_library block, save the setting
if strings.Contains(line, ")") && currentName != "" && currentType != "" {
settings = append(settings, Setting{
Name: currentName,
Type: currentType,
DefaultValue: currentValue,
Name: currentName,
Type: currentType,
})
currentName = ""
currentType = ""
currentValue = ""
}
}
@@ -101,30 +92,6 @@ func generateSettingsLoader(settings []Setting) string {
sb.WriteString(" case _ => throw NoSuchSettingException(key)\n")
sb.WriteString(" }\n\n")
// Generate the getAllSettings method that returns current values and metadata
sb.WriteString(" case class SettingInfo(\n")
sb.WriteString(" name: String,\n")
sb.WriteString(" settingType: String,\n")
sb.WriteString(" currentValue: String,\n")
sb.WriteString(" defaultValue: String\n")
sb.WriteString(" )\n\n")
sb.WriteString(" def getAllSettings: Vector[SettingInfo] = Vector(\n")
for i, setting := range settings {
if setting.Type == "Int" {
sb.WriteString(fmt.Sprintf(" SettingInfo(\"%s\", \"Int\", %s.intValue.toString, \"%s\")",
setting.Name, setting.Name, setting.DefaultValue))
} else {
sb.WriteString(fmt.Sprintf(" SettingInfo(\"%s\", \"Double\", %s.doubleValue.toString, \"%s\")",
setting.Name, setting.Name, setting.DefaultValue))
}
if i < len(settings)-1 {
sb.WriteString(",")
}
sb.WriteString("\n")
}
sb.WriteString(" )\n\n")
// Add the rest of the class
sb.WriteString(` case class SettingLine(
key: String,
@@ -1,25 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "jfr_server_lib",
srcs = ["jfr_server.go"],
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/jfr_server",
visibility = ["//visibility:private"],
)
go_binary(
name = "jfr_server",
embed = [":jfr_server_lib"],
pure = "on",
visibility = ["//visibility:public"],
)
# Linux x86_64 target for Docker deployment
go_binary(
name = "jfr_server_linux_amd64",
embed = [":jfr_server_lib"],
goarch = "amd64",
goos = "linux",
pure = "on",
visibility = ["//visibility:public"],
)
@@ -1,237 +0,0 @@
// Package main provides a simple HTTP server for JFR (Java Flight Recorder) control.
// It runs as a sidecar container with shared PID namespace to access the Eagle JVM.
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)
const recordingName = "admin"
var (
port = flag.Int("port", 8081, "HTTP server port")
targetProc = flag.String("target", "eagle_server", "Target process name to find in jcmd output")
)
// StatusResponse is the JSON response for /status endpoint
type StatusResponse struct {
Recording bool `json:"recording"`
Details string `json:"details,omitempty"`
}
func main() {
flag.Parse()
http.HandleFunc("/status", handleStatus)
http.HandleFunc("/start", handleStart)
http.HandleFunc("/stop", handleStop)
http.HandleFunc("/dump", handleDump)
http.HandleFunc("/health", handleHealth)
addr := fmt.Sprintf(":%d", *port)
log.Printf("JFR sidecar server starting on %s", addr)
log.Fatal(http.ListenAndServe(addr, nil))
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}
func handleStatus(w http.ResponseWriter, r *http.Request) {
log.Printf("JFR status requested from %s", r.RemoteAddr)
pid, err := findJvmPid()
if err != nil {
log.Printf("Error finding JVM PID: %v", err)
http.Error(w, fmt.Sprintf("Failed to find JVM: %v", err), http.StatusInternalServerError)
return
}
// Check JFR recording status
cmd := exec.Command("jcmd", strconv.Itoa(pid), "JFR.check")
output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("jcmd JFR.check failed: %s - %v", output, err)
http.Error(w, fmt.Sprintf("jcmd failed: %s - %v", output, err), http.StatusInternalServerError)
return
}
outputStr := string(output)
log.Printf("JFR.check output: %s", outputStr)
// Check if our named recording is active
recording := strings.Contains(outputStr, fmt.Sprintf("name=%s", recordingName)) &&
strings.Contains(outputStr, "running")
resp := StatusResponse{
Recording: recording,
Details: outputStr,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func handleStart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
log.Printf("JFR start requested from %s", r.RemoteAddr)
pid, err := findJvmPid()
if err != nil {
log.Printf("Error finding JVM PID: %v", err)
http.Error(w, fmt.Sprintf("Failed to find JVM: %v", err), http.StatusInternalServerError)
return
}
// Start JFR recording with profile settings
cmd := exec.Command("jcmd", strconv.Itoa(pid), "JFR.start",
fmt.Sprintf("name=%s", recordingName),
"settings=profile",
"maxage=1h",
"maxsize=100m")
output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("jcmd JFR.start failed: %s - %v", output, err)
http.Error(w, fmt.Sprintf("jcmd failed: %s - %v", output, err), http.StatusInternalServerError)
return
}
log.Printf("JFR.start output: %s", output)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(StatusResponse{Recording: true, Details: string(output)})
}
func handleStop(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
log.Printf("JFR stop requested from %s", r.RemoteAddr)
pid, err := findJvmPid()
if err != nil {
log.Printf("Error finding JVM PID: %v", err)
http.Error(w, fmt.Sprintf("Failed to find JVM: %v", err), http.StatusInternalServerError)
return
}
// Stop JFR recording
cmd := exec.Command("jcmd", strconv.Itoa(pid), "JFR.stop", fmt.Sprintf("name=%s", recordingName))
output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("jcmd JFR.stop failed: %s - %v", output, err)
http.Error(w, fmt.Sprintf("jcmd failed: %s - %v", output, err), http.StatusInternalServerError)
return
}
log.Printf("JFR.stop output: %s", output)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(StatusResponse{Recording: false, Details: string(output)})
}
func handleDump(w http.ResponseWriter, r *http.Request) {
log.Printf("JFR dump requested from %s", r.RemoteAddr)
// Find the target JVM PID
pid, err := findJvmPid()
if err != nil {
log.Printf("Error finding JVM PID: %v", err)
http.Error(w, fmt.Sprintf("Failed to find JVM: %v", err), http.StatusInternalServerError)
return
}
log.Printf("Found target JVM at PID %d", pid)
// Generate filename with timestamp
timestamp := time.Now().Format("2006-01-02T15-04-05")
filename := fmt.Sprintf("profile-%s.jfr", timestamp)
tempPath := "/tmp/" + filename
// Run jcmd to dump JFR (use our named recording)
dumpCmd := exec.Command("jcmd", strconv.Itoa(pid), "JFR.dump",
fmt.Sprintf("name=%s", recordingName),
"filename="+tempPath)
output, err := dumpCmd.CombinedOutput()
if err != nil {
log.Printf("jcmd JFR.dump failed: %s - %v", output, err)
http.Error(w, fmt.Sprintf("jcmd failed: %s - %v", output, err), http.StatusInternalServerError)
return
}
log.Printf("JFR dump output: %s", output)
// Clean up temp file after we're done
defer os.Remove(tempPath)
// Read the file
data, err := os.ReadFile(tempPath)
if err != nil {
log.Printf("Failed to read JFR file: %v", err)
http.Error(w, fmt.Sprintf("Failed to read JFR file: %v", err), http.StatusInternalServerError)
return
}
log.Printf("Serving JFR file: %s (%d bytes)", filename, len(data))
// Serve as download
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.Write(data)
}
// findJvmPid finds the PID of the target JVM process using jcmd -l
func findJvmPid() (int, error) {
cmd := exec.Command("jcmd", "-l")
output, err := cmd.Output()
if err != nil {
return 0, fmt.Errorf("jcmd -l failed: %v", err)
}
// Parse jcmd output - format is "PID MainClass" per line
// Example: "1 net.eagle0.eagle.Main"
scanner := bufio.NewScanner(strings.NewReader(string(output)))
pidRegex := regexp.MustCompile(`^(\d+)\s+(.*)$`)
for scanner.Scan() {
line := scanner.Text()
matches := pidRegex.FindStringSubmatch(line)
if len(matches) == 3 {
pid, _ := strconv.Atoi(matches[1])
mainClass := matches[2]
// Skip jcmd itself
if strings.Contains(mainClass, "jcmd") || mainClass == "jdk.jcmd/sun.tools.jcmd.JCmd" {
continue
}
// Look for our target process
if strings.Contains(mainClass, *targetProc) || strings.Contains(mainClass, "eagle") {
return pid, nil
}
// If there's only one non-jcmd JVM, use it (likely PID 1 in container)
if pid == 1 {
return pid, nil
}
}
}
// Default to PID 1 if nothing else found (common in containers with shared PID namespace)
return 1, nil
}
@@ -21,12 +21,15 @@ option java_outer_classname = "EagleInterface";
option objc_class_prefix = "E0G";
service ShardokInternalInterface {
// Server-side streaming for game updates
// Server-side streaming for game updates - replaces polling via GetGameStatus
rpc SubscribeToGame(GameSubscriptionRequest) returns (stream GameStatusResponse) {}
rpc PostCommand(PostCommandRequest) returns (GameStatusResponse) {}
rpc PostPlacementCommands(PlacementCommandsRequest) returns (GameStatusResponse) {}
// Deprecated: Use SubscribeToGame for streaming updates instead
rpc GetGameStatus(GameStatusRequest) returns (GameStatusResponse) {}
rpc GetHexMap(HexMapRequest) returns (HexMapResponse) {}
rpc GetHexMapNames(HexMapNamesRequest) returns (HexMapNamesResponse) {}
}
@@ -55,6 +58,11 @@ message PlacementCommandsRequest {
GameSetupInfo game_setup_info = 6;
}
message GameStatusRequest {
string game_id = 1;
GameSetupInfo game_setup_info = 2;
}
message GameStatusResponse {
string game_id = 1;
@@ -13,13 +13,11 @@ option objc_class_prefix = "E0A";
// Authentication service for OAuth login
service Auth {
// Get OAuth URL to open in system browser.
// The server handles the OAuth callback - client polls CheckOAuthStatus.
// Get OAuth URL to open in system browser
rpc GetOAuthUrl(GetOAuthUrlRequest) returns (GetOAuthUrlResponse) {}
// Poll for OAuth completion. Call this after opening the URL from GetOAuthUrl.
// The server receives the OAuth callback and exchanges the code for tokens.
rpc CheckOAuthStatus(CheckOAuthStatusRequest) returns (CheckOAuthStatusResponse) {}
// Exchange authorization code for JWT tokens
rpc ExchangeCode(ExchangeCodeRequest) returns (ExchangeCodeResponse) {}
// Set or update display name (requires valid JWT)
rpc SetDisplayName(SetDisplayNameRequest) returns (SetDisplayNameResponse) {}
@@ -44,36 +42,28 @@ enum OAuthProvider {
// Request to initiate OAuth flow
message GetOAuthUrlRequest {
OAuthProvider provider = 1;
string redirect_uri = 2; // e.g., "eagle0://auth/callback"
}
message GetOAuthUrlResponse {
string auth_url = 1; // Full OAuth URL to open in system browser
string state = 2; // State parameter - use this to poll CheckOAuthStatus
string state = 2; // State parameter for CSRF protection
}
// Poll for OAuth completion status
message CheckOAuthStatusRequest {
string state = 1; // State from GetOAuthUrlResponse
// 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 CheckOAuthStatusResponse {
OAuthStatus status = 1;
// Only set when status is OAUTH_STATUS_SUCCESS:
string access_token = 2;
string refresh_token = 3;
int64 expires_at = 4;
UserInfo user = 5;
bool is_new_user = 6;
// Only set when status is OAUTH_STATUS_FAILED:
string error_message = 7;
}
enum OAuthStatus {
OAUTH_STATUS_UNSPECIFIED = 0;
OAUTH_STATUS_PENDING = 1; // User hasn't completed OAuth yet
OAUTH_STATUS_SUCCESS = 2; // OAuth complete, tokens available
OAUTH_STATUS_FAILED = 3; // OAuth failed (user denied, etc.)
OAUTH_STATUS_EXPIRED = 4; // State token expired (typically 10 minutes)
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
@@ -32,15 +32,6 @@ service Eagle {
// Admin endpoints
rpc GetRunningGames(GetRunningGamesRequest) returns (GetRunningGamesResponse) {}
rpc GetGameHistory(GetGameHistoryRequest) returns (GetGameHistoryResponse) {}
rpc GetActionDetail(GetActionDetailRequest) returns (GetActionDetailResponse) {}
rpc GetGameStateAtAction(GetGameStateAtActionRequest) returns (GetGameStateAtActionResponse) {}
rpc GetSettings(GetSettingsRequest) returns (GetSettingsResponse) {}
// Admin player management
rpc ConvertAiToHuman(ConvertAiToHumanRequest) returns (ConvertAiToHumanResponse) {}
rpc ConvertHumanToAi(ConvertHumanToAiRequest) returns (ConvertHumanToAiResponse) {}
rpc ReassignFaction(ReassignFactionRequest) returns (ReassignFactionResponse) {}
rpc RewindGame(RewindGameRequest) returns (RewindGameResponse) {}
}
message PostCommandRequest {
@@ -84,7 +75,6 @@ message UpdateStreamRequest {
HexMapsRequest hex_maps_request = 8;
PostCommandRequest post_command_request = 9;
ErrorRequest error_request = 10;
DropGameRequest drop_game_request = 11;
}
message StreamGameRequest {
@@ -131,7 +121,6 @@ message UpdateStreamResponse {
HexMapResponse hex_map_response = 8;
PostCommandResponse post_command_response = 9;
SubscriptionAck subscription_ack = 10;
DropGameResponse drop_game_response = 11;
}
}
@@ -423,134 +412,4 @@ message GameHistoryEntry {
.google.protobuf.Int32Value acting_faction_id = 4;
.google.protobuf.Int32Value province_id = 5;
string summary = 6; // human-readable summary of the action
string action_json = 7; // full action result as JSON
string faction_name = 8; // name of the acting faction, if any
string game_date = 9; // in-game date (e.g. "March 453")
}
message GetActionDetailRequest {
int64 game_id = 1;
int32 action_index = 2;
}
message GetActionDetailResponse {
int64 game_id = 1;
int32 action_index = 2;
string action_type = 3;
int32 round_id = 4;
string action_json = 5; // Full action result as JSON
}
message GetGameStateAtActionRequest {
int64 game_id = 1;
int32 action_index = 2; // Get state after this action (-1 for initial state)
}
message GetGameStateAtActionResponse {
int64 game_id = 1;
int32 action_index = 2;
string game_state_json = 3; // Full game state as JSON
}
message GetSettingsRequest {
string filter = 1; // Optional name filter (substring match)
}
message GetSettingsResponse {
repeated Setting settings = 1;
}
message Setting {
string name = 1;
string setting_type = 2; // "Int" or "Double"
string current_value = 3; // Current value as string
string default_value = 4; // Default from BUILD.bazel
}
message DropGameRequest {
int64 game_id = 1;
}
enum DropGameResult {
UNKNOWN_DROP_GAME_RESULT = 0;
SUCCESS_DROP_GAME_RESULT = 1;
GAME_NOT_FOUND_DROP_GAME_RESULT = 2;
USER_NOT_IN_GAME_DROP_GAME_RESULT = 3;
GAME_ALREADY_COMPLETED_DROP_GAME_RESULT = 4;
}
message DropGameResponse {
DropGameResult result = 1;
int64 game_id = 2;
}
// Admin player management messages
message ConvertAiToHumanRequest {
int64 game_id = 1;
int32 faction_id = 2;
string new_username = 3;
}
message ConvertAiToHumanResponse {
enum Result {
UNKNOWN = 0;
SUCCESS = 1;
GAME_NOT_FOUND = 2;
FACTION_NOT_FOUND = 3;
FACTION_ALREADY_HUMAN = 4;
USERNAME_ALREADY_IN_USE = 5;
INVALID_USERNAME = 6;
}
Result result = 1;
string error_message = 2;
}
message ConvertHumanToAiRequest {
int64 game_id = 1;
int32 faction_id = 2;
}
message ConvertHumanToAiResponse {
enum Result {
UNKNOWN = 0;
SUCCESS = 1;
GAME_NOT_FOUND = 2;
FACTION_NOT_FOUND = 3;
FACTION_ALREADY_AI = 4;
}
Result result = 1;
string error_message = 2;
}
message ReassignFactionRequest {
int64 game_id = 1;
int32 faction_id = 2;
string new_username = 3;
}
message ReassignFactionResponse {
enum Result {
UNKNOWN = 0;
SUCCESS = 1;
GAME_NOT_FOUND = 2;
FACTION_NOT_FOUND = 3;
FACTION_IS_AI = 4;
USERNAME_ALREADY_IN_USE = 5;
INVALID_USERNAME = 6;
}
Result result = 1;
string previous_username = 2;
string error_message = 3;
}
message RewindGameRequest {
int64 game_id = 1;
int32 target_action_count = 2; // Rewind to state after this many actions
}
message RewindGameResponse {
bool success = 1;
string error = 2;
int32 new_action_count = 3;
int32 resynced_clients = 4; // Number of clients that were resynced with the new state
}
@@ -16,7 +16,4 @@ option objc_class_prefix = "E0G";
message ChronicleEntry {
string generated_text_id = 1;
.net.eagle0.eagle.common.Date date = 2;
// The style/chronicler used when generating this entry (e.g., "J.R.R. Tolkien", "Homer")
// Used to label entries so the LLM knows which chronicler wrote each one
string chronicler_style = 3;
}
@@ -143,15 +143,10 @@ message ChronicleUpdateMessage {
message PreviousChronicleEntry {
.net.eagle0.eagle.common.Date date = 1;
string generated_text_id = 2;
// The chronicler style used for this entry (e.g., "J.R.R. Tolkien", "Homer")
string chronicler_style = 3;
}
repeated PreviousChronicleEntry previous_entries = 2;
repeated EventForChronicle new_entries = 3;
// The chronicler style to use for the new entry
string chronicler_style = 4;
}
message DivineMessage {
File diff suppressed because it is too large Load Diff
@@ -271,4 +271,4 @@ priceIndexShiftPerGold 0.0001 Double
aiTruceAcceptanceBaseChance 100 Double
aiAllianceAcceptanceBaseChance 100 Double
primeStatMinForProfession 85 Int
professionGainChance 0.07 Double
professionGainChance 0.1 Double
1 actionVigorCost 15 Int
271 aiTruceAcceptanceBaseChance 100 Double
272 aiAllianceAcceptanceBaseChance 100 Double
273 primeStatMinForProfession 85 Int
274 professionGainChance 0.07 0.1 Double
+224 -225
View File
@@ -1,225 +1,224 @@
lightningWisdomFactor 1 double
lightningAgilityFactor 0.25 double
lightningDamageFactor 250 double
meteorBaseDamage 2500 double
meteorSplashFactor 0.4 double
meteorDirectFireBaseChance 90 double
meteorSplashFireBaseChance 50 double
meteorStructureDamage 30 double
meteorFrozenIntegrityReduced 80 double
meteorSnowIntegrityReduced 80 double
raiseDeadBaseOdds 100 int16
dismissBaseOdds 60 int16
fearBaseOdds 75 int16
meleeKnowledgeIncrement 10 int8
scoutDirectKnowledgeIncrement 35 int8
scoutAdjacentKnowledgeIncrement 20 int8
damageReceivedMultiplierFromStun 1.2 double
damageGivenMultiplierFromStun 0.5 double
minimumCastleToPreventCharge 15 double
holyWaveBaseChance 75 double
holyWaveInspireBonus 25 double
holyWaveVigorBuf 5 double
maxInspireOverBase 10 double
holyWaveFailedDebuf -10 double
holyWaveDamagePercentage 0.7 double
minMoraleInCastle 15 double
fleeActionPointCost 5 int8
firePropensityCity 10 int16
firePropensityHill 0 int16
firePropensityPlains 0 int16
firePropensityForest 20 int16
firePropensityWater -500 int16
firePropensitySwamp -70 int16
firePropensityMountain -10 int16
firePropensityBridge 10 int16
firePropensityCastle 0 int16
firePropensityIce -500 int16
firePropensitySun 10 int16
firePropensityClouds 0 int16
firePropensityRain -60 int16
firePropensitySnow -60 int16
firePropensityThunderstorm -85 int16
firePropensityBlizzard -85 int16
firePropensityWindSpeedMultiplier 1 double
extinguishOwnTileBuf 25 int16
fireOutNaturallyBaseOdds 30 int16
extinguishBaseOdds 70 int16
startFireBaseOdds 40 int16
freezeWaterBaseOdds 60 int16
freezeWaterBonusSun -10 int16
freezeWaterBonusClouds 0 int16
freezeWaterBonusRain 10 int16
freezeWaterBonusThunderstorm 10 int16
freezeWaterBonusSnow 50 int16
freezeWaterBonusBlizzard 100 int16
scoutBaseOdds 75 int16
ambushBaseOdds 50 int16
moraleShiftPerCasualty 0.1 double
minimumMoraleToAct 10 double
minimumVigorToAct 10 double
minimumVigorForCombat 20 double
minimumVigorForMeteor 30 double
extraCostForZocMovement 1 int8
holyWaveActionPointCost 5 int8
fallInWaterDamagePerTroop 150 double
fallInWaterBaseEscapeOdds 50 int16
retreatActionPointCost 5 int8
reinforceActionPointCost 5 int8
braveWaterActionPointCost 8 int8
braveWaterBaseOdds 50 int16
braveWaterOddsRain -20 int16
braveWaterOddsThunderstorm -50 int16
braveWaterOddsSnow -35 int16
braveWaterOddsBlizzard -70 int16
braveWaterAgilityFactor 0.2 double
braveWaterWisdomFactor 0.1 double
braveWaterRangerBonus 20 double
braveWaterArmamentFactor -0.5 double
braveWaterMaxLoss 0.2 double
lightningRange 3 int8
meteorRange 3 int8
duelDeclinedMoraleAdjustment -15 double
duelWonMoraleBoost 15 double
minimumKnowledgeForProfession 20 int8
minimumKnowledgeForName 30 int8
minimumKnowledgeForBattalionName 30 int8
minimumKnowledgeForBattalionStats 50 int8
minimumKnowledgeForHeroStats 75 int8
duelBaseAcceptOdds 50 int16
meteorImpactStartFireBaseOdds 90 int16
meteorSplashStartFireBaseOdds 50 int16
meteorImpactCastleMinDamage 0 double
meteorImpactCastleMaxDamage 30 double
meteorImpactBridgeMinDamage 25 double
meteorImpactBridgeMaxDamage 100 double
meteorSplashBridgeMinDamage 12.5 double
meteorSplashBridgeMaxDamage 60 double
burningBridgeDamage 25 double
burningCastleDamage 15 double
buildBridgeBaseOdds 30 int16
buildBridgeForestBonus 30 int16
meleeActionPointCost 5 int8
archeryActionPointCost 5 int8
lightningActionPointCost 5 int8
meteorStartActionPointCost 5 int8
meteorCancelActionPointCost 1 int8
meteorTargetActionPointCost 1 int8
meteorCastActionPointCost 1 int8
extinguishActionPointCost 5 int8
startFireActionPointCost 5 int8
freezeWaterActionPointCost 5 int8
buildBridgeActionPointCost 5 int8
repairActionPointCost 5 int8
reduceActionPointCost 5 int8
scoutActionPointCost 5 int8
raiseDeadActionPointCost 5 int8
fearActionPointCost 5 int8
challengeDuelActionPointCost 5 int8
scoutRange 4 int8
raiseDeadRange 2 int8
fearRange 3 int8
reduceRange 3 int8
heroDamageFactor 12.5 double
moraleAdjustmentForNotEnoughFood -10 double
sizeMultiplierForNotEnoughFood 0.95 double
freezeWaterIntegrity 80 double
repairBuf 25 double
averageReduceDebuf 10 double
reduceForestBonus 50 double
unitDamagePerReduceDebuf 800 double
fearDebuf -25 double
fearFailedBuf 10 double
actionCostToVigorCost 0.5 double
moraleMeanReversion 0.1 double
meteorCastVigorCost 20 double
meleeStrengthXp 1 int8
archeryAgilityXp 1 int8
ambushAgilityXp 1 int8
archeryAmbushAgilityXp 2 int8
meteorCastWisdomXp 5 int8
lightningBoltWisdomXp 1 int8
startFireWisdomXp 1 int8
startFireAgilityXp 1 int8
extinguishAgilityXp 1 int8
extinguishWisdomXp 1 int8
duelStrengthXp 10 int8
duelAgilityXp 10 int8
duelWinnerCharismaXp 20 int8
minimumVigorToAcceptDuel 30 double
chargeAgilityXp 1 int8
iceIntegrityAdjustmentRiver -20 double
iceIntegrityAdjustmentSun -20 double
iceIntegrityAdjustmentClouds 0 double
iceIntegrityAdjustmentRain 0 double
iceIntegrityAdjustmentThunderstorm 0 double
iceIntegrityAdjustmentSnow 10 double
iceIntegrityAdjustmentBlizzard 25 double
iceIntegrityAdjustmentFire -50 double
initialWinterIceIntegrity 30 double
armamentToVolleys 0.1 double
defenderBaseKnowledgeGain 2 int8
attackerBaseKnowledgeGain 1 int8
adjacentHeroKnowledgeGain 4 int8
maxRounds 30 int8
holyWaveCharismaXp 5 int8
snowIntegrityAdjustmentSun -20 double
snowIntegrityAdjustmentClouds 0 double
snowIntegrityAdjustmentRain 0 double
snowIntegrityAdjustmentThunderstorm 0 double
snowIntegrityAdjustmentSnow 10 double
snowIntegrityAdjustmentBlizzard 20 double
snowIntegrityAdjustmentFire -100 double
freezeWaterWisdomXp 3 int8
buildBridgeAgilityXp 2 int8
buildBridgeStrengthXp 2 int8
repairAgilityXp 1 int8
repairStrengthXp 1 int8
reduceAgilityXp 2 int8
reduceStrengthXp 2 int8
scoutAgilityXp 2 int8
scoutWisdomXp 2 int8
hideActionPointCost 5 int8
fortifyStrengthXp 1 int8
fortifyAgilityXp 1 int8
hideAgilityXp 2 int8
fortifyActionPointCost 5 int8
fortifyDamageTakenMultiplier 0.9 double
raiseDeadWisdomXp 1 int8
raiseDeadCharismaXp 2 int8
fearCharismaXp 2 int8
undeadDecayRate 0.25 double
deadReinforceRate 0.3 double
raiseDeadStartingSize 100 int16
undeadDecayFloor 100 int16
winterMoraleAdjustment -20 double
stealthForestModifier 10 int16
stealthSwampModifier 10 int16
stealthWaterModifier -20 int16
lightningBoltDamageCap 0.2 double
vipCapturedMoraleAdjustment -20 double
fleeSuccessBaseChance 120 int16
fleeSuccessPerAdjacentEnemy -20 int16
fleeSuccessPerAdjacentFriendly 10 int16
fleeSuccessAdjustmentForTwoAway 0.5 double
fleeSuccessPerAdjacentImpassableTile -20 int16
heroBraverySwing 25 double
heroDamageAmbushAdjustment 50 double
heroMortalityFactor 0.005 double
baseDeadliness 0.0012 double
aiUtilityRepeatCount 5 int8
maxLookaheadTurnsClose 2 int8
maxLookaheadTurnsFar 2 int8
saveAll FALSE bool
holyWaveVigorCost 10 double
minLookaheadTurns 1 int8
lookaheadTimeBudgetCloseInSeconds 3 double
lookaheadTimeBudgetFarInSeconds 1.5 double
lookaheadTimeBudgetSetup 0.5 double
lookaheadTimeBudgetPerCommandCloseMs 100 double
lookaheadTimeBudgetPerCommandFarMs 50 double
lookaheadTimeBudgetPerCommandSetupMs 25 double
aiMinimumFleeOddsThreshold 30 int16
aiDesperateFleeThreshold 10 int16
lookaheadTimeBudgetMaximumSeconds 3 double
allAiBattleTimeBudgetMaximum 0.5 double
lightningWisdomFactor 1 double
lightningAgilityFactor 0.25 double
lightningDamageFactor 250 double
meteorBaseDamage 2500 double
meteorSplashFactor 0.4 double
meteorDirectFireBaseChance 90 double
meteorSplashFireBaseChance 50 double
meteorStructureDamage 30 double
meteorFrozenIntegrityReduced 80 double
meteorSnowIntegrityReduced 80 double
raiseDeadBaseOdds 100 int16
dismissBaseOdds 60 int16
fearBaseOdds 75 int16
meleeKnowledgeIncrement 10 int8
scoutDirectKnowledgeIncrement 35 int8
scoutAdjacentKnowledgeIncrement 20 int8
damageReceivedMultiplierFromStun 1.2 double
damageGivenMultiplierFromStun 0.5 double
minimumCastleToPreventCharge 15 double
holyWaveBaseChance 75 double
holyWaveInspireBonus 25 double
holyWaveVigorBuf 5 double
maxInspireOverBase 10 double
holyWaveFailedDebuf -10 double
holyWaveDamagePercentage 0.7 double
minMoraleInCastle 15 double
fleeActionPointCost 5 int8
firePropensityCity 10 int16
firePropensityHill 0 int16
firePropensityPlains 0 int16
firePropensityForest 20 int16
firePropensityWater -500 int16
firePropensitySwamp -70 int16
firePropensityMountain -10 int16
firePropensityBridge 10 int16
firePropensityCastle 0 int16
firePropensityIce -500 int16
firePropensitySun 10 int16
firePropensityClouds 0 int16
firePropensityRain -60 int16
firePropensitySnow -60 int16
firePropensityThunderstorm -85 int16
firePropensityBlizzard -85 int16
firePropensityWindSpeedMultiplier 1 double
extinguishOwnTileBuf 25 int16
fireOutNaturallyBaseOdds 30 int16
extinguishBaseOdds 70 int16
startFireBaseOdds 40 int16
freezeWaterBaseOdds 60 int16
freezeWaterBonusSun -10 int16
freezeWaterBonusClouds 0 int16
freezeWaterBonusRain 10 int16
freezeWaterBonusThunderstorm 10 int16
freezeWaterBonusSnow 50 int16
freezeWaterBonusBlizzard 100 int16
scoutBaseOdds 75 int16
ambushBaseOdds 50 int16
moraleShiftPerCasualty 0.1 double
minimumMoraleToAct 10 double
minimumVigorToAct 10 double
minimumVigorForCombat 20 double
minimumVigorForMeteor 30 double
extraCostForZocMovement 1 int8
holyWaveActionPointCost 5 int8
fallInWaterDamagePerTroop 150 double
fallInWaterBaseEscapeOdds 50 int16
retreatActionPointCost 5 int8
reinforceActionPointCost 5 int8
braveWaterActionPointCost 8 int8
braveWaterBaseOdds 50 int16
braveWaterOddsRain -20 int16
braveWaterOddsThunderstorm -50 int16
braveWaterOddsSnow -35 int16
braveWaterOddsBlizzard -70 int16
braveWaterAgilityFactor 0.2 double
braveWaterWisdomFactor 0.1 double
braveWaterRangerBonus 20 double
braveWaterArmamentFactor -0.5 double
braveWaterMaxLoss 0.2 double
lightningRange 3 int8
meteorRange 3 int8
duelDeclinedMoraleAdjustment -15 double
duelWonMoraleBoost 15 double
minimumKnowledgeForProfession 20 int8
minimumKnowledgeForName 30 int8
minimumKnowledgeForBattalionName 30 int8
minimumKnowledgeForBattalionStats 50 int8
minimumKnowledgeForHeroStats 75 int8
duelBaseAcceptOdds 50 int16
meteorImpactStartFireBaseOdds 90 int16
meteorSplashStartFireBaseOdds 50 int16
meteorImpactCastleMinDamage 0 double
meteorImpactCastleMaxDamage 30 double
meteorImpactBridgeMinDamage 25 double
meteorImpactBridgeMaxDamage 100 double
meteorSplashBridgeMinDamage 12.5 double
meteorSplashBridgeMaxDamage 60 double
burningBridgeDamage 25 double
burningCastleDamage 15 double
buildBridgeBaseOdds 30 int16
buildBridgeForestBonus 30 int16
meleeActionPointCost 5 int8
archeryActionPointCost 5 int8
lightningActionPointCost 5 int8
meteorStartActionPointCost 5 int8
meteorCancelActionPointCost 1 int8
meteorTargetActionPointCost 1 int8
meteorCastActionPointCost 1 int8
extinguishActionPointCost 5 int8
startFireActionPointCost 5 int8
freezeWaterActionPointCost 5 int8
buildBridgeActionPointCost 5 int8
repairActionPointCost 5 int8
reduceActionPointCost 5 int8
scoutActionPointCost 5 int8
raiseDeadActionPointCost 5 int8
fearActionPointCost 5 int8
challengeDuelActionPointCost 5 int8
scoutRange 4 int8
raiseDeadRange 2 int8
fearRange 3 int8
reduceRange 3 int8
heroDamageFactor 12.5 double
moraleAdjustmentForNotEnoughFood -10 double
sizeMultiplierForNotEnoughFood 0.95 double
freezeWaterIntegrity 80 double
repairBuf 25 double
averageReduceDebuf 10 double
reduceForestBonus 50 double
unitDamagePerReduceDebuf 800 double
fearDebuf -25 double
fearFailedBuf 10 double
actionCostToVigorCost 0.5 double
moraleMeanReversion 0.1 double
meteorCastVigorCost 20 double
meleeStrengthXp 1 int8
archeryAgilityXp 1 int8
ambushAgilityXp 1 int8
archeryAmbushAgilityXp 2 int8
meteorCastWisdomXp 5 int8
lightningBoltWisdomXp 1 int8
startFireWisdomXp 1 int8
startFireAgilityXp 1 int8
extinguishAgilityXp 1 int8
extinguishWisdomXp 1 int8
duelStrengthXp 10 int8
duelAgilityXp 10 int8
duelWinnerCharismaXp 20 int8
minimumVigorToAcceptDuel 30 double
chargeAgilityXp 1 int8
iceIntegrityAdjustmentRiver -20 double
iceIntegrityAdjustmentSun -20 double
iceIntegrityAdjustmentClouds 0 double
iceIntegrityAdjustmentRain 0 double
iceIntegrityAdjustmentThunderstorm 0 double
iceIntegrityAdjustmentSnow 10 double
iceIntegrityAdjustmentBlizzard 25 double
iceIntegrityAdjustmentFire -50 double
initialWinterIceIntegrity 30 double
armamentToVolleys 0.1 double
defenderBaseKnowledgeGain 2 int8
attackerBaseKnowledgeGain 1 int8
adjacentHeroKnowledgeGain 4 int8
maxRounds 30 int8
holyWaveCharismaXp 5 int8
snowIntegrityAdjustmentSun -20 double
snowIntegrityAdjustmentClouds 0 double
snowIntegrityAdjustmentRain 0 double
snowIntegrityAdjustmentThunderstorm 0 double
snowIntegrityAdjustmentSnow 10 double
snowIntegrityAdjustmentBlizzard 20 double
snowIntegrityAdjustmentFire -100 double
freezeWaterWisdomXp 3 int8
buildBridgeAgilityXp 2 int8
buildBridgeStrengthXp 2 int8
repairAgilityXp 1 int8
repairStrengthXp 1 int8
reduceAgilityXp 2 int8
reduceStrengthXp 2 int8
scoutAgilityXp 2 int8
scoutWisdomXp 2 int8
hideActionPointCost 5 int8
fortifyStrengthXp 1 int8
fortifyAgilityXp 1 int8
hideAgilityXp 2 int8
fortifyActionPointCost 5 int8
fortifyDamageTakenMultiplier 0.9 double
raiseDeadWisdomXp 1 int8
raiseDeadCharismaXp 2 int8
fearCharismaXp 2 int8
undeadDecayRate 0.25 double
deadReinforceRate 0.3 double
raiseDeadStartingSize 100 int16
undeadDecayFloor 100 int16
winterMoraleAdjustment -20 double
stealthForestModifier 10 int16
stealthSwampModifier 10 int16
stealthWaterModifier -20 int16
lightningBoltDamageCap 0.2 double
vipCapturedMoraleAdjustment -20 double
fleeSuccessBaseChance 120 int16
fleeSuccessPerAdjacentEnemy -20 int16
fleeSuccessPerAdjacentFriendly 10 int16
fleeSuccessAdjustmentForTwoAway 0.5 double
fleeSuccessPerAdjacentImpassableTile -20 int16
heroBraverySwing 25 double
heroDamageAmbushAdjustment 50 double
heroMortalityFactor 0.005 double
baseDeadliness 0.0012 double
aiUtilityRepeatCount 5 int8
maxLookaheadTurnsClose 2 int8
maxLookaheadTurnsFar 2 int8
saveAll FALSE bool
holyWaveVigorCost 10 double
minLookaheadTurns 1 int8
lookaheadTimeBudgetCloseInSeconds 3 double
lookaheadTimeBudgetFarInSeconds 1.5 double
lookaheadTimeBudgetSetup 0.5 double
lookaheadTimeBudgetPerCommandCloseMs 100 double
lookaheadTimeBudgetPerCommandFarMs 50 double
lookaheadTimeBudgetPerCommandSetupMs 25 double
aiMinimumFleeOddsThreshold 30 int16
aiDesperateFleeThreshold 10 int16
lookaheadTimeBudgetMaximumSeconds 5 double
1 lightningWisdomFactor 1 double
2 lightningAgilityFactor 0.25 double
3 lightningDamageFactor 250 double
4 meteorBaseDamage 2500 double
5 meteorSplashFactor 0.4 double
6 meteorDirectFireBaseChance 90 double
7 meteorSplashFireBaseChance 50 double
8 meteorStructureDamage 30 double
9 meteorFrozenIntegrityReduced 80 double
10 meteorSnowIntegrityReduced 80 double
11 raiseDeadBaseOdds 100 int16
12 dismissBaseOdds 60 int16
13 fearBaseOdds 75 int16
14 meleeKnowledgeIncrement 10 int8
15 scoutDirectKnowledgeIncrement 35 int8
16 scoutAdjacentKnowledgeIncrement 20 int8
17 damageReceivedMultiplierFromStun 1.2 double
18 damageGivenMultiplierFromStun 0.5 double
19 minimumCastleToPreventCharge 15 double
20 holyWaveBaseChance 75 double
21 holyWaveInspireBonus 25 double
22 holyWaveVigorBuf 5 double
23 maxInspireOverBase 10 double
24 holyWaveFailedDebuf -10 double
25 holyWaveDamagePercentage 0.7 double
26 minMoraleInCastle 15 double
27 fleeActionPointCost 5 int8
28 firePropensityCity 10 int16
29 firePropensityHill 0 int16
30 firePropensityPlains 0 int16
31 firePropensityForest 20 int16
32 firePropensityWater -500 int16
33 firePropensitySwamp -70 int16
34 firePropensityMountain -10 int16
35 firePropensityBridge 10 int16
36 firePropensityCastle 0 int16
37 firePropensityIce -500 int16
38 firePropensitySun 10 int16
39 firePropensityClouds 0 int16
40 firePropensityRain -60 int16
41 firePropensitySnow -60 int16
42 firePropensityThunderstorm -85 int16
43 firePropensityBlizzard -85 int16
44 firePropensityWindSpeedMultiplier 1 double
45 extinguishOwnTileBuf 25 int16
46 fireOutNaturallyBaseOdds 30 int16
47 extinguishBaseOdds 70 int16
48 startFireBaseOdds 40 int16
49 freezeWaterBaseOdds 60 int16
50 freezeWaterBonusSun -10 int16
51 freezeWaterBonusClouds 0 int16
52 freezeWaterBonusRain 10 int16
53 freezeWaterBonusThunderstorm 10 int16
54 freezeWaterBonusSnow 50 int16
55 freezeWaterBonusBlizzard 100 int16
56 scoutBaseOdds 75 int16
57 ambushBaseOdds 50 int16
58 moraleShiftPerCasualty 0.1 double
59 minimumMoraleToAct 10 double
60 minimumVigorToAct 10 double
61 minimumVigorForCombat 20 double
62 minimumVigorForMeteor 30 double
63 extraCostForZocMovement 1 int8
64 holyWaveActionPointCost 5 int8
65 fallInWaterDamagePerTroop 150 double
66 fallInWaterBaseEscapeOdds 50 int16
67 retreatActionPointCost 5 int8
68 reinforceActionPointCost 5 int8
69 braveWaterActionPointCost 8 int8
70 braveWaterBaseOdds 50 int16
71 braveWaterOddsRain -20 int16
72 braveWaterOddsThunderstorm -50 int16
73 braveWaterOddsSnow -35 int16
74 braveWaterOddsBlizzard -70 int16
75 braveWaterAgilityFactor 0.2 double
76 braveWaterWisdomFactor 0.1 double
77 braveWaterRangerBonus 20 double
78 braveWaterArmamentFactor -0.5 double
79 braveWaterMaxLoss 0.2 double
80 lightningRange 3 int8
81 meteorRange 3 int8
82 duelDeclinedMoraleAdjustment -15 double
83 duelWonMoraleBoost 15 double
84 minimumKnowledgeForProfession 20 int8
85 minimumKnowledgeForName 30 int8
86 minimumKnowledgeForBattalionName 30 int8
87 minimumKnowledgeForBattalionStats 50 int8
88 minimumKnowledgeForHeroStats 75 int8
89 duelBaseAcceptOdds 50 int16
90 meteorImpactStartFireBaseOdds 90 int16
91 meteorSplashStartFireBaseOdds 50 int16
92 meteorImpactCastleMinDamage 0 double
93 meteorImpactCastleMaxDamage 30 double
94 meteorImpactBridgeMinDamage 25 double
95 meteorImpactBridgeMaxDamage 100 double
96 meteorSplashBridgeMinDamage 12.5 double
97 meteorSplashBridgeMaxDamage 60 double
98 burningBridgeDamage 25 double
99 burningCastleDamage 15 double
100 buildBridgeBaseOdds 30 int16
101 buildBridgeForestBonus 30 int16
102 meleeActionPointCost 5 int8
103 archeryActionPointCost 5 int8
104 lightningActionPointCost 5 int8
105 meteorStartActionPointCost 5 int8
106 meteorCancelActionPointCost 1 int8
107 meteorTargetActionPointCost 1 int8
108 meteorCastActionPointCost 1 int8
109 extinguishActionPointCost 5 int8
110 startFireActionPointCost 5 int8
111 freezeWaterActionPointCost 5 int8
112 buildBridgeActionPointCost 5 int8
113 repairActionPointCost 5 int8
114 reduceActionPointCost 5 int8
115 scoutActionPointCost 5 int8
116 raiseDeadActionPointCost 5 int8
117 fearActionPointCost 5 int8
118 challengeDuelActionPointCost 5 int8
119 scoutRange 4 int8
120 raiseDeadRange 2 int8
121 fearRange 3 int8
122 reduceRange 3 int8
123 heroDamageFactor 12.5 double
124 moraleAdjustmentForNotEnoughFood -10 double
125 sizeMultiplierForNotEnoughFood 0.95 double
126 freezeWaterIntegrity 80 double
127 repairBuf 25 double
128 averageReduceDebuf 10 double
129 reduceForestBonus 50 double
130 unitDamagePerReduceDebuf 800 double
131 fearDebuf -25 double
132 fearFailedBuf 10 double
133 actionCostToVigorCost 0.5 double
134 moraleMeanReversion 0.1 double
135 meteorCastVigorCost 20 double
136 meleeStrengthXp 1 int8
137 archeryAgilityXp 1 int8
138 ambushAgilityXp 1 int8
139 archeryAmbushAgilityXp 2 int8
140 meteorCastWisdomXp 5 int8
141 lightningBoltWisdomXp 1 int8
142 startFireWisdomXp 1 int8
143 startFireAgilityXp 1 int8
144 extinguishAgilityXp 1 int8
145 extinguishWisdomXp 1 int8
146 duelStrengthXp 10 int8
147 duelAgilityXp 10 int8
148 duelWinnerCharismaXp 20 int8
149 minimumVigorToAcceptDuel 30 double
150 chargeAgilityXp 1 int8
151 iceIntegrityAdjustmentRiver -20 double
152 iceIntegrityAdjustmentSun -20 double
153 iceIntegrityAdjustmentClouds 0 double
154 iceIntegrityAdjustmentRain 0 double
155 iceIntegrityAdjustmentThunderstorm 0 double
156 iceIntegrityAdjustmentSnow 10 double
157 iceIntegrityAdjustmentBlizzard 25 double
158 iceIntegrityAdjustmentFire -50 double
159 initialWinterIceIntegrity 30 double
160 armamentToVolleys 0.1 double
161 defenderBaseKnowledgeGain 2 int8
162 attackerBaseKnowledgeGain 1 int8
163 adjacentHeroKnowledgeGain 4 int8
164 maxRounds 30 int8
165 holyWaveCharismaXp 5 int8
166 snowIntegrityAdjustmentSun -20 double
167 snowIntegrityAdjustmentClouds 0 double
168 snowIntegrityAdjustmentRain 0 double
169 snowIntegrityAdjustmentThunderstorm 0 double
170 snowIntegrityAdjustmentSnow 10 double
171 snowIntegrityAdjustmentBlizzard 20 double
172 snowIntegrityAdjustmentFire -100 double
173 freezeWaterWisdomXp 3 int8
174 buildBridgeAgilityXp 2 int8
175 buildBridgeStrengthXp 2 int8
176 repairAgilityXp 1 int8
177 repairStrengthXp 1 int8
178 reduceAgilityXp 2 int8
179 reduceStrengthXp 2 int8
180 scoutAgilityXp 2 int8
181 scoutWisdomXp 2 int8
182 hideActionPointCost 5 int8
183 fortifyStrengthXp 1 int8
184 fortifyAgilityXp 1 int8
185 hideAgilityXp 2 int8
186 fortifyActionPointCost 5 int8
187 fortifyDamageTakenMultiplier 0.9 double
188 raiseDeadWisdomXp 1 int8
189 raiseDeadCharismaXp 2 int8
190 fearCharismaXp 2 int8
191 undeadDecayRate 0.25 double
192 deadReinforceRate 0.3 double
193 raiseDeadStartingSize 100 int16
194 undeadDecayFloor 100 int16
195 winterMoraleAdjustment -20 double
196 stealthForestModifier 10 int16
197 stealthSwampModifier 10 int16
198 stealthWaterModifier -20 int16
199 lightningBoltDamageCap 0.2 double
200 vipCapturedMoraleAdjustment -20 double
201 fleeSuccessBaseChance 120 int16
202 fleeSuccessPerAdjacentEnemy -20 int16
203 fleeSuccessPerAdjacentFriendly 10 int16
204 fleeSuccessAdjustmentForTwoAway 0.5 double
205 fleeSuccessPerAdjacentImpassableTile -20 int16
206 heroBraverySwing 25 double
207 heroDamageAmbushAdjustment 50 double
208 heroMortalityFactor 0.005 double
209 baseDeadliness 0.0012 double
210 aiUtilityRepeatCount 5 int8
211 maxLookaheadTurnsClose 2 int8
212 maxLookaheadTurnsFar 2 int8
213 saveAll FALSE bool
214 holyWaveVigorCost 10 double
215 minLookaheadTurns 1 int8
216 lookaheadTimeBudgetCloseInSeconds 3 double
217 lookaheadTimeBudgetFarInSeconds 1.5 double
218 lookaheadTimeBudgetSetup 0.5 double
219 lookaheadTimeBudgetPerCommandCloseMs 100 double
220 lookaheadTimeBudgetPerCommandFarMs 50 double
221 lookaheadTimeBudgetPerCommandSetupMs 25 double
222 aiMinimumFleeOddsThreshold 30 int16
223 aiDesperateFleeThreshold 10 int16
224 lookaheadTimeBudgetMaximumSeconds 3 5 double
allAiBattleTimeBudgetMaximum 0.5 double
@@ -21,9 +21,7 @@ scala_binary(
"//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:oauth_http_handler",
"//src/main/scala/net/eagle0/eagle/service:server_setup_helpers",
"//src/main/scala/net/eagle0/eagle/service/persistence:async_s3_persister",
"//src/main/scala/net/eagle0/eagle/service/persistence:local_file_persister",
"//src/main/scala/net/eagle0/eagle/service/persistence:save_directory",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
+5 -37
View File
@@ -8,9 +8,9 @@ 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, OAuthService, OAuthServiceImpl, UserServiceImpl}
import net.eagle0.eagle.auth.{JwtService, JwtServiceImpl, OAuthServiceImpl, UserServiceImpl}
import net.eagle0.eagle.service.*
import net.eagle0.eagle.service.persistence.{AsyncS3Persister, LocalFilePersister, SaveDirectory}
import net.eagle0.eagle.service.persistence.{LocalFilePersister, SaveDirectory}
object Main {
import ServerSetupHelpers.*
@@ -25,12 +25,6 @@ object Main {
private val gptModelNameKey = Symbol("gptModelName")
private val serverBaseUrlKey = Symbol("serverBaseUrl")
private val defaultServerBaseUrl = "https://prod.eagle0.net"
private val oauthHttpPortKey = Symbol("oauthHttpPort")
private val defaultOauthHttpPort = "8080"
def parsedOptions(args: Array[String]): Map[Symbol, String] = {
def nextOption(
map: Map[Symbol, String],
@@ -49,16 +43,6 @@ object Main {
map ++ Map(gptModelNameKey -> value),
tail
)
case "--server-base-url" +: value +: tail =>
nextOption(
map ++ Map(serverBaseUrlKey -> value),
tail
)
case "--oauth-http-port" +: value +: tail =>
nextOption(
map ++ Map(oauthHttpPortKey -> value),
tail
)
case option +: _ =>
throw new IllegalArgumentException("Unknown option " + option)
@@ -70,9 +54,6 @@ object Main {
def main(args: Array[String]): Unit = {
SimpleTimedLogger.printLogger.logLine("Starting!")
// Register shutdown hook to flush pending S3 saves on graceful shutdown
AsyncS3Persister.registerShutdownHook()
val options = parsedOptions(args)
SimpleTimedLogger.printLogger.logLine(s"Options: $options")
@@ -93,24 +74,11 @@ object Main {
val customBattleManager = newCustomBattleManager(shardokInterfaceAddress)
customBattleManager.begin()
val serverBaseUrl =
options.getOrElse(serverBaseUrlKey, defaultServerBaseUrl)
// Create OAuth service (shared between gRPC and HTTP)
val oauthService = new OAuthServiceImpl(serverBaseUrl)
// Start OAuth HTTP server for callbacks
val oauthHttpPort =
options.getOrElse(oauthHttpPortKey, defaultOauthHttpPort).toInt
val oauthHttpHandler = new OAuthHttpHandler(oauthService, oauthHttpPort)
oauthHttpHandler.start()
val server = buildServer(
gamesManager,
customBattleManager,
options.getOrElse(eagleGrpcPortKey, defaultEagleGrpcPort).toInt,
SeededRandom(random.nextLong()),
oauthService
SeededRandom(random.nextLong())
).start
SimpleTimedLogger.printLogger.logLine(
s"Eagle server is listening on port ${server.getPort}"
@@ -123,8 +91,7 @@ object Main {
gamesManager: GamesManager,
customBattleManager: CustomBattleManager,
grpcPort: Int,
functionalRandom: FunctionalRandom,
oauthService: OAuthService
functionalRandom: FunctionalRandom
): Server = {
val serverBuilder = ServerBuilder.forPort(grpcPort)
@@ -132,6 +99,7 @@ object Main {
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(
@@ -7,6 +7,7 @@ import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.util.*
import net.eagle0.eagle.library.util.command_choice_helpers.{
AlmsCommandSelector,
@@ -15,7 +16,7 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
}
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
import net.eagle0.eagle.library.Engine
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
case class AIClientWithSelectedCommand(
client: AIClient,
@@ -54,13 +55,13 @@ case class AIClient(
else
chooseFrom(
maybeCommandsMap.get.commandsByProvince,
engine.currentState,
GameStateConverter.toProto(engine.currentState),
functionalRandom
)
}
private def chooseMidGameCommandFrom(
gameState: GameState,
gameState: GameStateProto,
oneProvinceAvailableCommands: OneProvinceAvailableCommands,
functionalRandom: FunctionalRandom
): RandomState[AIClientWithSelectedCommand] =
@@ -81,7 +82,7 @@ case class AIClient(
fr
) =>
logger.logLine(
s"${gameState.currentDate.map(_.year).getOrElse("?")} ${gameState.currentDate.map(_.month).getOrElse("?")} ${gameState.currentPhase}"
s"${gameState.currentDate.get.year} ${gameState.currentDate.get.month} ${gameState.currentPhase}"
)
logger.logLine(
s"$actingProvinceId (${gameState.provinces(actingProvinceId).name})"
@@ -115,7 +116,7 @@ case class AIClient(
private def chooseFrom(
acs: Map[ProvinceId, OneProvinceAvailableCommands],
gs: GameState,
gs: GameStateProto,
functionalRandom: FunctionalRandom
): RandomState[AIClientWithSelectedCommand] = {
val opac = acs.head._2
@@ -186,7 +187,7 @@ case class AIClient(
),
(
fid: FactionId,
gameState: GameState,
gs: GameStateProto,
acs: Vector[AvailableCommand],
functionalRandom
) =>
@@ -194,7 +195,7 @@ case class AIClient(
AlmsCommandSelector
.chosenAlmsForSupportWithMinimumCommandForProvince(
actingFactionId = fid,
gameState = gameState,
gameState = GameStateConverter.fromProto(gs),
availableCommands = acs,
minimumFood = 0,
provinceId = opac.provinceId
@@ -1,78 +1,27 @@
package net.eagle0.eagle.ai
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.internal.province.Province as ProvinceProto
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
import net.eagle0.eagle.library.util.hero.{HeroUtils, LegacyHeroUtils}
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.province.ProvinceT
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.hero.LegacyHeroUtils
object AIClientUtils {
def extraHeroCount(pid: ProvinceId, fid: FactionId, gs: GameStateProto): Int =
def extraHeroCount(pid: ProvinceId, fid: FactionId, gs: GameState): Int =
(gs.provinces(pid).rulingFactionHeroIds.size - desiredHeroCount(
pid,
fid,
gs
)).max(0)
/** Protoless overload */
def extraHeroCount(
province: ProvinceT,
fid: FactionId,
allProvinces: Map[ProvinceId, ProvinceT],
factions: Vector[FactionT]
): Int =
(province.rulingFactionHeroIds.size - desiredHeroCount(
province,
fid,
allProvinces,
factions
)).max(0)
def desiredHeroCount(pid: ProvinceId, fid: FactionId, gs: GameStateProto): Int =
def desiredHeroCount(pid: ProvinceId, fid: FactionId, gs: GameState): Int =
if LegacyFactionUtils.hostileNeighbors(pid, fid, gs).nonEmpty then 3
else if gs.provinces(pid).support < 65 then 2
else 1
/** Protoless overload */
def desiredHeroCount(
province: ProvinceT,
fid: FactionId,
allProvinces: Map[ProvinceId, ProvinceT],
factions: Vector[FactionT]
): Int =
if FactionUtils.hostileNeighbors(province, fid, allProvinces, factions).nonEmpty then 3
else if province.support < 65 then 2
else 1
def desiredCountForMarchTowardFocus(
originProvince: ProvinceProto,
destinationProvince: ProvinceProto,
availableHeroIds: Vector[HeroId],
factionLeaders: Vector[HeroId],
favorLeaders: Boolean
): Int =
if favorLeaders &&
originProvince.rulingFactionHeroIds.size == 2 &&
originProvince.rulingFactionHeroIds
.intersect(factionLeaders)
.nonEmpty &&
destinationProvince.rulingFactionHeroIds
.intersect(factionLeaders)
.isEmpty
then 1
else
Vector(
10,
originProvince.rulingFactionHeroIds.size - 2,
availableHeroIds.size
).min
def desiredCountForMarchTowardFocus(
originProvince: ProvinceT,
destinationProvince: ProvinceT,
originProvince: Province,
destinationProvince: Province,
availableHeroIds: Vector[HeroId],
factionLeaders: Vector[HeroId],
favorLeaders: Boolean
@@ -94,11 +43,11 @@ object AIClientUtils {
).min
def takenHeroIdsForMarchTowardFocus(
originProvince: ProvinceProto,
destinationProvince: ProvinceProto,
originProvince: Province,
destinationProvince: Province,
availableHeroIds: Vector[HeroId],
favorLeaders: Boolean,
gs: GameStateProto
gs: GameState
): Vector[HeroId] =
mostPowerfulHeroes(
desiredCountForMarchTowardFocus(
@@ -113,31 +62,9 @@ object AIClientUtils {
favorLeaders
)
def takenHeroIdsForMarchTowardFocus(
originProvince: ProvinceT,
destinationProvince: ProvinceT,
availableHeroIds: Vector[HeroId],
favorLeaders: Boolean,
factions: Vector[FactionT],
heroes: Map[HeroId, HeroT]
): Vector[HeroId] =
mostPowerfulHeroes(
desiredCountForMarchTowardFocus(
originProvince = originProvince,
destinationProvince = destinationProvince,
availableHeroIds = availableHeroIds,
factionLeaders = factions.find(_.id == originProvince.rulingFactionId.get).get.leaderIds,
favorLeaders = favorLeaders
),
availableHeroIds,
favorLeaders,
factions,
heroes
)
def mostPowerfulHeroes(
desiredCount: Int,
gs: GameStateProto,
gs: GameState,
availableHeroIds: Vector[HeroId],
favorLeaders: Boolean
): Vector[HeroId] = {
@@ -157,28 +84,4 @@ object AIClientUtils {
.map(_.id)
.take(desiredCount)
}
def mostPowerfulHeroes(
desiredCount: Int,
availableHeroIds: Vector[HeroId],
favorLeaders: Boolean,
factions: Vector[FactionT],
heroes: Map[HeroId, HeroT]
): Vector[HeroId] = {
val availableHeroes = availableHeroIds
.map(heroes)
if availableHeroes.size <= desiredCount then availableHeroes.map(_.id)
else
availableHeroes
.sortBy(hero =>
(
if favorLeaders then !FactionUtils.isFactionLeader(hero.id, factions)
else FactionUtils.isFactionLeader(hero.id, factions),
-HeroUtils.power(hero)
)
)
.map(_.id)
.take(desiredCount)
}
}
+4 -44
View File
@@ -25,6 +25,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:alms_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:attack_decision_command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
],
)
@@ -50,13 +51,8 @@ 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/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -68,9 +64,6 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/service:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
":resolve_diplomacy_command_selector",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
@@ -93,9 +86,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle/service:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
@@ -103,11 +93,8 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -117,20 +104,12 @@ scala_library(
visibility = [
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/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",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -140,10 +119,7 @@ scala_library(
visibility = [
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
exports = ["//src/main/scala/net/eagle0/eagle/library/util:command_selection"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
@@ -151,9 +127,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:available_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -169,12 +143,11 @@ scala_library(
"//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/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_utils",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//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/command_choice_helpers:province_gold_surplus_calculator",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -188,7 +161,6 @@ scala_library(
exports = [
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
":ai_client_utils",
@@ -227,8 +199,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -238,9 +208,6 @@ scala_library(
visibility = [
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
":faction_leader_province_ranker",
":march_toward_province_command_chooser",
@@ -250,9 +217,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -262,9 +226,6 @@ scala_library(
visibility = [
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
@@ -283,7 +244,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -2,61 +2,25 @@ package net.eagle0.eagle.ai
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.util.command_choice_helpers.{CommandChooser, LegacyCommandChooser}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers.{
chosenCommandWhileTraveling,
chosenDevelopCommand,
chosenUnderAttackCommand
}
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.{
DeterministicCommandChooser,
LegacyDeterministicCommandChooser,
LegacyRandomCommandChooser,
RandomCommandChooser
}
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
import net.eagle0.eagle.library.util.province.{LegacyProvinceUtils, ProvinceUtils}
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooser
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.DeterministicCommandChooser
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
object EarlyGameAIClient {
def isEarlyGame(gs: GameStateProto, factionId: FactionId): Boolean =
def isEarlyGame(gs: GameState, factionId: FactionId): Boolean =
LegacyFactionUtils.provinceCount(factionId, gs) == 1 && !LegacyFactionUtils
.provinces(factionId, gs)
.exists(LegacyProvinceUtils.getsTaxes)
/** Protoless overload */
def isEarlyGame(gs: GameState, factionId: FactionId): Boolean =
FactionUtils.provinceCount(factionId, gs.provinces.values) == 1 && !FactionUtils
.provinces(factionId, gs.provinces.values.toVector)
.exists(ProvinceUtils.getsTaxes)
def chooseEarlyGameCommand(
actingFactionId: FactionId,
gs: GameStateProto,
acs: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[CommandSelection] =
CommandChooser
.choose(
actingFactionId = actingFactionId,
gameState = gs,
availableCommands = acs,
rankedChoosers = Vector[LegacyCommandChooser](
(fid: FactionId, gs: GameStateProto, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
chosenCommandWhileTraveling(fid, gs, acs, fr),
(fid: FactionId, gs: GameStateProto, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
RandomState(chosenUnderAttackCommand(fid, gs, acs), fr),
(fid: FactionId, gs: GameStateProto, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
chosenDevelopCommand(fid, gs, acs, fr)
),
functionalRandom = functionalRandom
)
.map(_.get)
/** Protoless overload */
def chooseEarlyGameCommand(
actingFactionId: FactionId,
gs: GameState,
@@ -69,12 +33,9 @@ object EarlyGameAIClient {
gameState = gs,
availableCommands = acs,
rankedChoosers = Vector[CommandChooser](
(fid: FactionId, gs: GameState, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
chosenCommandWhileTraveling(fid, gs, acs, fr),
(fid: FactionId, gs: GameState, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
RandomState(chosenUnderAttackCommand(fid, gs, acs), fr),
(fid: FactionId, gs: GameState, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
chosenDevelopCommand(fid, gs, acs, fr)
chosenCommandWhileTraveling _,
chosenUnderAttackCommand _,
chosenDevelopCommand _
),
functionalRandom = functionalRandom
)
@@ -1,21 +1,18 @@
package net.eagle0.eagle.ai
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.internal.province.Province as ProvinceProto
import net.eagle0.eagle.library.util.{LegacyProvinceDistances, ProvinceDistances}
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
import net.eagle0.eagle.library.util.province.{LegacyProvinceUtils, ProvinceUtils}
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
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
object FactionLeaderProvinceRanker {
// Proto versions delegate to protoless implementations
private def factionLeaderProvinceOrdering(
factionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
fromProvinceId: ProvinceId
): Ordering[ProvinceProto] = factionLeaderProvinceOrdering(factionId, gameState)
): Ordering[Province] = factionLeaderProvinceOrdering(factionId, gameState)
.orElse(
nearestThroughFriendliesOrdering(factionId, gameState, fromProvinceId)
)
@@ -23,9 +20,9 @@ object FactionLeaderProvinceRanker {
// Orders by nearest-through-friendlies first
private def nearestThroughFriendliesOrdering(
factionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
fromProvinceId: ProvinceId
): Ordering[ProvinceProto] = Ordering.by((p: ProvinceProto) =>
): Ordering[Province] = Ordering.by((p: Province) =>
LegacyProvinceDistances.distanceThroughFriendliesOption(
fromProvinceId,
p.id,
@@ -37,27 +34,27 @@ object FactionLeaderProvinceRanker {
// Orders by most-hostile-neighbors-first
private def hostileNeighborCountOrdering(
factionId: FactionId,
gameState: GameStateProto
): Ordering[ProvinceProto] = Ordering
.by((p: ProvinceProto) => LegacyFactionUtils.hostileNeighbors(p, factionId, gameState).size)
gameState: GameState
): Ordering[Province] = Ordering
.by((p: Province) => LegacyFactionUtils.hostileNeighbors(p, factionId, gameState).size)
.reverse
// Orders by most-neutral-neighbors-first
private def neutralNeighborCountOrdering(
gameState: GameStateProto
): Ordering[ProvinceProto] =
Ordering.by((p: ProvinceProto) => neutralNeighborCountProto(p, gameState)).reverse
gameState: GameState
): Ordering[Province] =
Ordering.by((p: Province) => neutralNeighborCount(p, gameState)).reverse
private def factionLeaderProvinceOrdering(
factionId: FactionId,
gameState: GameStateProto
): Ordering[ProvinceProto] =
gameState: GameState
): Ordering[Province] =
hostileNeighborCountOrdering(factionId = factionId, gameState = gameState)
.orElse(neutralNeighborCountOrdering(gameState))
private def neutralNeighborCountProto(
province: ProvinceProto,
gameState: GameStateProto
private def neutralNeighborCount(
province: Province,
gameState: GameState
): Int =
province.neighbors
.map(_.provinceId)
@@ -68,7 +65,7 @@ object FactionLeaderProvinceRanker {
// Sorts the owned provinces for this faction, with the most desirable for faction leaders coming first
def rankedProvinceIds(
factionId: FactionId,
gameState: GameStateProto
gameState: GameState
): Vector[ProvinceId] = {
val ordering = factionLeaderProvinceOrdering(
factionId = factionId,
@@ -82,7 +79,7 @@ object FactionLeaderProvinceRanker {
def bestProvinceId(
factionId: FactionId,
gameState: GameStateProto
gameState: GameState
): Option[ProvinceId] = {
val ordering = factionLeaderProvinceOrdering(
factionId = factionId,
@@ -96,7 +93,7 @@ object FactionLeaderProvinceRanker {
def bestProvinceId(
factionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
fromProvinceId: ProvinceId
): Option[ProvinceId] = {
val ordering = factionLeaderProvinceOrdering(
@@ -112,7 +109,7 @@ object FactionLeaderProvinceRanker {
def rankedProvinceIds(
factionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
fromProvinceId: ProvinceId
): Vector[ProvinceId] = {
val ordering = factionLeaderProvinceOrdering(
@@ -128,8 +125,8 @@ object FactionLeaderProvinceRanker {
def bestSettlementPidForFactionLeader(
factionId: FactionId,
currentProvince: ProvinceProto,
gameState: GameStateProto
currentProvince: Province,
gameState: GameState
): Option[ProvinceId] = {
val ordering = factionLeaderProvinceOrdering(
factionId = factionId,
@@ -145,158 +142,6 @@ object FactionLeaderProvinceRanker {
.map(_.id)
}
def bestSettlementPidForFactionLeader(
factionId: FactionId,
currentProvinceId: ProvinceId,
gameState: GameStateProto
): Option[ProvinceId] =
bestSettlementPidForFactionLeader(
factionId = factionId,
currentProvince = gameState.provinces(currentProvinceId),
gameState = gameState
)
// ==================== Protoless overloads ====================
private def factionLeaderProvinceOrderingC(
factionId: FactionId,
gameState: GameState,
fromProvinceId: ProvinceId
): Ordering[ProvinceT] = factionLeaderProvinceOrderingC(factionId, gameState)
.orElse(
nearestThroughFriendliesOrderingC(factionId, gameState, fromProvinceId)
)
private def nearestThroughFriendliesOrderingC(
factionId: FactionId,
gameState: GameState,
fromProvinceId: ProvinceId
): Ordering[ProvinceT] = Ordering.by((p: ProvinceT) =>
ProvinceDistances.distanceThroughFriendliesOption(
fromProvinceId,
p.id,
factionId,
gameState.provinces
)
)
private def hostileNeighborCountOrderingC(
factionId: FactionId,
gameState: GameState
): Ordering[ProvinceT] = Ordering
.by((p: ProvinceT) =>
FactionUtils.hostileNeighbors(p, factionId, gameState.provinces, gameState.factions.values.toVector).size
)
.reverse
private def neutralNeighborCountOrderingC(
gameState: GameState
): Ordering[ProvinceT] =
Ordering.by((p: ProvinceT) => neutralNeighborCount(p, gameState)).reverse
private def factionLeaderProvinceOrderingC(
factionId: FactionId,
gameState: GameState
): Ordering[ProvinceT] =
hostileNeighborCountOrderingC(factionId = factionId, gameState = gameState)
.orElse(neutralNeighborCountOrderingC(gameState))
private def neutralNeighborCount(
province: ProvinceT,
gameState: GameState
): Int =
province.neighbors
.map(_.provinceId)
.map(gameState.provinces)
.filterNot(_.rulingFactionId.isDefined)
.size
/** Protoless overload */
def rankedProvinceIds(
factionId: FactionId,
gameState: GameState
): Vector[ProvinceId] = {
val ordering = factionLeaderProvinceOrderingC(
factionId = factionId,
gameState = gameState
)
FactionUtils
.provinces(factionId, gameState.provinces.values.toVector)
.sorted(ordering)
.map(_.id)
}
/** Protoless overload */
def bestProvinceId(
factionId: FactionId,
gameState: GameState
): Option[ProvinceId] = {
val ordering = factionLeaderProvinceOrderingC(
factionId = factionId,
gameState = gameState
)
FactionUtils
.provinces(factionId, gameState.provinces.values.toVector)
.minOption(ordering)
.map(_.id)
}
/** Protoless overload */
def bestProvinceId(
factionId: FactionId,
gameState: GameState,
fromProvinceId: ProvinceId
): Option[ProvinceId] = {
val ordering = factionLeaderProvinceOrderingC(
factionId = factionId,
gameState = gameState,
fromProvinceId = fromProvinceId
)
FactionUtils
.provinces(factionId, gameState.provinces.values.toVector)
.minOption(ordering)
.map(_.id)
}
/** Protoless overload */
def rankedProvinceIds(
factionId: FactionId,
gameState: GameState,
fromProvinceId: ProvinceId
): Vector[ProvinceId] = {
val ordering = factionLeaderProvinceOrderingC(
factionId = factionId,
gameState = gameState,
fromProvinceId = fromProvinceId
)
FactionUtils
.provinces(factionId, gameState.provinces.values.toVector)
.sorted(ordering)
.map(_.id)
}
/** Protoless overload */
def bestSettlementPidForFactionLeader(
factionId: FactionId,
currentProvince: ProvinceT,
gameState: GameState
): Option[ProvinceId] = {
val factions = gameState.factions.values.toVector
val ordering = factionLeaderProvinceOrderingC(
factionId = factionId,
gameState = gameState,
fromProvinceId = currentProvince.id
)
FactionUtils
.provinces(factionId, gameState.provinces.values.toVector)
.filter(ordering.lt(_, currentProvince))
.filterNot(ProvinceUtils.ruledByFactionLeader(_, factions))
.minOption(ordering)
.map(_.id)
}
/** Protoless overload */
def bestSettlementPidForFactionLeader(
factionId: FactionId,
currentProvinceId: ProvinceId,
@@ -4,21 +4,19 @@ import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand}
import net.eagle0.eagle.api.selected_command.MarchSelectedCommand
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.randomSelectionForType
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
object FixLeaderAloneCommandSelector {
def maybeFixLeaderAlone(
actingFactionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
val provinces = gameState.provinces
): RandomState[Option[CommandSelection]] =
randomSelectionForType[MarchAvailableCommand](
availableCommands,
functionalRandom
@@ -27,74 +25,21 @@ object FixLeaderAloneCommandSelector {
frOuter.nextFlatCollectFirst(
ac.oneProvinceCommands
.filter(opmc =>
provinces(opmc.originProvinceId).rulingFactionHeroIds
gameState
.provinces(opmc.originProvinceId)
.rulingFactionHeroIds
.filterNot(LegacyFactionUtils.isFactionLeader(_, gameState))
.size > 1
)
) { opmc => fr =>
opmc.availableDestinationProvinces
.map(dest => provinces(dest.provinceId))
.map(dest => gameState.provinces(dest.provinceId))
.filter(_.rulingFactionId.contains(actingFactionId))
.filter(_.rulingFactionHeroIds.size == 1)
.filter(dest => LegacyFactionUtils.isFactionLeader(dest.rulingFactionHeroIds.head, gameState))
.map { destination =>
fr.nextRandomElement(opmc.availableHeroIds)
.map { hid =>
CombatUnit(factionId = actingFactionId, heroId = hid)
}
.map { combatUnit =>
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = ac.actingProvinceId,
available = ac,
selected = MarchSelectedCommand(
gold = 0,
food = 0,
originProvince = opmc.originProvinceId,
destinationProvinceId = destination.id,
marchingUnits = Vector(
combatUnit
)
),
reason = "Moving a hero so that a faction leader won't be alone"
)
)
}
}
.headOption
.getOrElse(RandomState(None, fr))
}
}
}
/** Protoless overload */
def maybeFixLeaderAlone(
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
val factions = gameState.factions.values.toVector
val provinces = gameState.provinces
randomSelectionForType[MarchAvailableCommand](
availableCommands,
functionalRandom
) {
case (ac, frOuter) =>
frOuter.nextFlatCollectFirst(
ac.oneProvinceCommands
.filter(opmc =>
provinces(opmc.originProvinceId).rulingFactionHeroIds
.filterNot(FactionUtils.isFactionLeader(_, factions))
.size > 1
.filter(dest =>
LegacyFactionUtils
.isFactionLeader(dest.rulingFactionHeroIds.head, gameState)
)
) { opmc => fr =>
opmc.availableDestinationProvinces
.map(dest => provinces(dest.provinceId))
.filter(_.rulingFactionId.contains(actingFactionId))
.filter(_.rulingFactionHeroIds.size == 1)
.filter(dest => FactionUtils.isFactionLeader(dest.rulingFactionHeroIds.head, factions))
.map { destination =>
fr.nextRandomElement(opmc.availableHeroIds)
.map { hid =>
@@ -124,5 +69,4 @@ object FixLeaderAloneCommandSelector {
.getOrElse(RandomState(None, fr))
}
}
}
}
@@ -3,13 +3,12 @@ package net.eagle0.eagle.ai
import net.eagle0.eagle.api.available_command.{AvailableCommand, DiplomacyAvailableCommand}
import net.eagle0.eagle.api.command.util.diplomacy_option.InvitationOption
import net.eagle0.eagle.api.selected_command.DiplomacySelectedCommand
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.settings.MinChanceForAIInvite
import net.eagle0.eagle.library.util.command_choice_helpers.{ProvinceGoldSurplusCalculator, TrustForDiplomacy}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
object InvitationCommandSelector {
@@ -18,29 +17,22 @@ object InvitationCommandSelector {
acceptanceChance: Int
)
def maybeInviteOtherFaction(
actingFactionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
maybeInviteOtherFaction(actingFactionId, GameStateConverter.fromProto(gameState), availableCommands)
/** Protoless overload */
def maybeInviteOtherFaction(
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[DiplomacyAvailableCommand](acs = availableCommands) { diplomacyAvailableCommand =>
val nativeGameState = GameStateConverter.fromProto(gameState)
diplomacyAvailableCommand.options.collect { case io: InvitationOption => io }.filter { invitationOption =>
TrustForDiplomacy.meetsConditionsForInvitation(
actingFactionId = actingFactionId,
targetFactionId = invitationOption.targetFactionId,
gameState = gameState
gameState = nativeGameState
)
}.filter { invitationOption =>
ProvinceGoldSurplusCalculator.provinceGoldSurplus(
gameState.provinces(diplomacyAvailableCommand.actingProvinceId)
nativeGameState.provinces(diplomacyAvailableCommand.actingProvinceId)
) >= invitationOption.goldCost
}.map { invitationOption =>
InvitationOptionWithChance(
@@ -48,8 +40,8 @@ object InvitationCommandSelector {
acceptanceChance = ResolveDiplomacyCommandSelector.invitationAcceptanceChance(
actingFactionId,
invitationOption.targetFactionId,
gameState.factions.values.toVector,
gameState.provinces.values.toVector
nativeGameState.factions.values.toVector,
nativeGameState.provinces.values.toVector
)
)
}.maxByOption(_.acceptanceChance)
@@ -4,10 +4,10 @@ import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.{MarchAvailableCommand, MarchCommandFromOneProvince}
import net.eagle0.eagle.api.selected_command.MarchSelectedCommand
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.library.util.{BattalionUtils, CommandSelection, ProvinceDistances}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.{CommandSelection, LegacyBattalionUtils, LegacyProvinceDistances}
import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusCalculator.provinceGoldSurplus
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.model.state.game_state.GameState
object MarchTowardProvinceCommandChooser {
def marchTowardProvinceCommand(
@@ -23,12 +23,12 @@ object MarchTowardProvinceCommandChooser {
.map(dest => (opmc, gs.provinces(dest.provinceId)))
.flatMap {
case (opmc, p) =>
ProvinceDistances
LegacyProvinceDistances
.distanceThroughFriendliesOption(
destinationProvinceId,
p.id,
fid,
gs.provinces
gs
)
.map(distance => (opmc, p, distance))
}
@@ -51,8 +51,7 @@ object MarchTowardProvinceCommandChooser {
destinationProvince = dest,
availableHeroIds = opmc.availableHeroIds.toVector,
favorLeaders = favorLeaders,
factions = gs.factions.values.toVector,
heroes = gs.heroes
gs = gs
)
Option.when(takenHeroIds.nonEmpty) {
@@ -75,11 +74,11 @@ object MarchTowardProvinceCommandChooser {
CombatUnit(factionId = fid, heroId = hid, battalionId = bid)
}
val foodToTake = 2 * BattalionUtils.monthlyConsumedFood(
val foodToTake = 2 * LegacyBattalionUtils.monthlyConsumedFood(
takenBattalions,
gs.battalionTypes
gs.battalionTypes.toVector
)
val goldToTake = provinceGoldSurplus(originProvince)
val goldToTake = provinceGoldSurplus(originProvince.id, gs)
CommandSelection(
actingFactionId = fid,
actingProvinceId = actingProvinceId,
@@ -9,7 +9,7 @@ import net.eagle0.eagle.api.selected_command.{MarchSelectedCommand, ReconSelecte
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.common.improvement_type.ImprovementType
import net.eagle0.eagle.internal.faction_relationship.FactionRelationship
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.settings.{MinSupportForTaxes, MonthsReconConsideredRecent}
import net.eagle0.eagle.library.util.*
@@ -21,7 +21,6 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
ExpandCommandSelector,
FulfillQuestsCommandSelector,
ImproveCommandSelector,
LegacyCommandChooser,
OrganizeCommandSelector,
ProvinceGoldSurplusCalculator
}
@@ -35,14 +34,12 @@ 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.model.proto_converters.province.ProvinceConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.views.battalion_view.BattalionView
import net.eagle0.eagle.views.province_view.FullProvinceInfo
object MidGameAIClient {
private def validateNoFactionLeaderAlone(
gs: GameStateProto,
gs: GameState,
factionId: FactionId
): Unit =
LegacyFactionUtils
@@ -58,27 +55,11 @@ object MidGameAIClient {
)
}
/** Protoless version - takes native GameState */
def chosenMidGameCommand(
factionId: FactionId,
gs: GameState,
opac: OneProvinceAvailableCommands,
functionalRandom: FunctionalRandom
): RandomState[CommandSelection] =
// Internal methods still use proto, so convert here
chosenMidGameCommand(
factionId = factionId,
gs = GameStateConverter.toProto(gs),
opac = opac,
functionalRandom = functionalRandom
)
/** Proto version */
def chosenMidGameCommand(
factionId: FactionId,
gs: GameStateProto,
opac: OneProvinceAvailableCommands,
functionalRandom: FunctionalRandom
): RandomState[CommandSelection] = {
validateNoFactionLeaderAlone(gs, factionId)
@@ -86,9 +67,8 @@ object MidGameAIClient {
actingFactionId = factionId,
gameState = gs,
availableCommands = opac.commands.toVector,
rankedChoosers = Vector[LegacyCommandChooser](
(fid: FactionId, gs: GameStateProto, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
chosenUniversalCommand(fid, gs, acs, fr),
rankedChoosers = Vector[CommandChooser](
chosenUniversalCommand,
chosenRescueLeaderCommand,
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
@@ -145,7 +125,7 @@ object MidGameAIClient {
// Send supplies to a neighbor that's running low
private def maybeChosenRelaySuppliesCommand(
factionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[SendSuppliesAvailableCommand](availableCommands) {
@@ -172,7 +152,7 @@ object MidGameAIClient {
Math.min(actingProvince.food - foodToHoldBack, foodAvailable)
val maxGoldToSend = Math.min(
ProvinceGoldSurplusCalculator
.provinceGoldSurplus(ProvinceConverter.fromProto(actingProvince)),
.provinceGoldSurplus(actingProvinceId, gameState),
goldAvailable
)
@@ -233,9 +213,9 @@ object MidGameAIClient {
private def chosenGenericCommand(
actingFactionId: FactionId,
gs: GameStateProto,
gs: GameState,
acs: Vector[AvailableCommand],
chooser: LegacyCommandChooser,
chooser: CommandChooser,
functionalRandom: FunctionalRandom,
reason: String
): RandomState[Option[CommandSelection]] =
@@ -245,9 +225,9 @@ object MidGameAIClient {
private def chosenGenericCommandFromRankedOptions(
actingFactionId: FactionId,
gs: GameStateProto,
gs: GameState,
acs: Vector[AvailableCommand],
choosersAndReasons: Vector[(LegacyCommandChooser, String)],
choosersAndReasons: Vector[(CommandChooser, String)],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = choosersAndReasons match {
case cr +: tail =>
@@ -275,7 +255,7 @@ object MidGameAIClient {
private def chosenHostileNeighborsCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -283,7 +263,7 @@ object MidGameAIClient {
actingFactionId = actingFactionId,
gs = gameState,
acs = availableCommands,
choosersAndReasons = Vector[(LegacyCommandChooser, String)](
choosersAndReasons = Vector[(CommandChooser, String)](
(prepareAttackCommand, "prepare attack with hostile neighbors"),
(maybeMoveToRecruitCommand, "travel to recruit"),
(
@@ -346,7 +326,7 @@ object MidGameAIClient {
private def chosenNeutralNeighborsCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -369,7 +349,7 @@ object MidGameAIClient {
(
(
fid: FactionId,
gs: GameStateProto,
gs: GameState,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) =>
@@ -430,7 +410,7 @@ object MidGameAIClient {
private def chosenNoNeutralNeighborsCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
@@ -519,7 +499,7 @@ object MidGameAIClient {
private def chosenAttackCommandWithReconInfo(
actingFactionId: FactionId,
gs: GameStateProto,
gs: GameState,
acs: Vector[AvailableCommand]
): Option[CommandSelection] = {
def reconnedFullInfoFor(provinceId: ProvinceId): Option[FullProvinceInfo] =
@@ -547,7 +527,7 @@ object MidGameAIClient {
private def maybeChosenRaiseSupportCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
availableCommands: Vector[AvailableCommand],
provinceId: ProvinceId
): Option[CommandSelection] =
@@ -573,7 +553,7 @@ object MidGameAIClient {
private def chosenMidGameStrategicCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
opac: OneProvinceAvailableCommands,
functionalRandom: FunctionalRandom
): RandomState[CommandSelection] =
@@ -582,7 +562,7 @@ object MidGameAIClient {
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = opac.commands.toVector,
rankedChoosers = Vector[LegacyCommandChooser](
rankedChoosers = Vector[CommandChooser](
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
MoveLeaderToBetterProvinceCommandChooser
@@ -604,7 +584,7 @@ object MidGameAIClient {
),
(
fid: FactionId,
gs: GameStateProto,
gs: GameState,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) =>
@@ -654,7 +634,7 @@ object MidGameAIClient {
),
(
fid: FactionId,
gs: GameStateProto,
gs: GameState,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) =>
@@ -679,7 +659,7 @@ object MidGameAIClient {
),
(
fid: FactionId,
gs: GameStateProto,
gs: GameState,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) =>
@@ -704,7 +684,7 @@ object MidGameAIClient {
.map(_.get)
def foodSurplus(
gs: GameStateProto,
gs: GameState,
factionId: FactionId
): Int =
gs.provinces.values
@@ -714,7 +694,7 @@ object MidGameAIClient {
.max(0) / 2 // making this up
def maybeChosenImproveForBetterTroopsCommand(
gs: GameStateProto,
gs: GameState,
acs: Vector[AvailableCommand],
factionId: FactionId
): Option[CommandSelection] =
@@ -781,7 +761,7 @@ object MidGameAIClient {
private def prepareAttackCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
@@ -803,7 +783,7 @@ object MidGameAIClient {
actingFactionId = actingFactionId,
gs = gameState,
acs = availableCommands,
choosersAndReasons = Vector[(LegacyCommandChooser, String)](
choosersAndReasons = Vector[(CommandChooser, String)](
(
(fid, gs, acs, fr) =>
RandomState(
@@ -815,7 +795,7 @@ object MidGameAIClient {
(
(
fid: FactionId,
gs: GameStateProto,
gs: GameState,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) =>
@@ -906,7 +886,7 @@ object MidGameAIClient {
def maybeMoveHeroesTowardFactionLeaderCommand(
actingFactionId: FactionId,
gs: GameStateProto,
gs: GameState,
acs: Vector[AvailableCommand]
): Option[CommandSelection] = {
val factionLeaderProvincesNeedingHeroes =
@@ -960,7 +940,7 @@ object MidGameAIClient {
fid = actingFactionId,
opmc = opmc,
destinationProvinceId = destinationProvinceId,
gs = GameStateConverter.fromProto(gs),
gs = gs,
favorLeaders = false
)
}
@@ -971,7 +951,7 @@ object MidGameAIClient {
private def selectedReconCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
ac: ReconAvailableCommand,
targetProvinceId: ProvinceId
): CommandSelection =
@@ -988,7 +968,7 @@ object MidGameAIClient {
def maybeChosenReconCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
acs: Vector[AvailableCommand]
): Option[CommandSelection] = {
import DateProtoUtils._Date
@@ -1065,7 +1045,7 @@ object MidGameAIClient {
def maybeSpreadLeadersCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
acs: Vector[AvailableCommand]
): Option[CommandSelection] = {
val faction = gameState.factions(actingFactionId)
@@ -1124,7 +1104,7 @@ object MidGameAIClient {
private def provincesWithFactionLeader(
factionId: FactionId,
gs: GameStateProto
gs: GameState
): Vector[Province] = LegacyFactionUtils
.provinces(factionId, gs)
.filter(p => LegacyProvinceUtils.ruledByFactionLeader(p, gs))
@@ -2,14 +2,11 @@ package net.eagle0.eagle.ai
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand, MarchCommandFromOneProvince}
import net.eagle0.eagle.internal.faction.Faction as FactionProto
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
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.ProvinceAndDistance
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.game_state.GameState
object MoveLeaderToBetterProvinceCommandChooser {
private case class MCFOPWithDestinationAndDistance(
@@ -18,28 +15,24 @@ object MoveLeaderToBetterProvinceCommandChooser {
)
private type DestinationChooser =
(FactionId, ProvinceId, GameStateProto) => Option[ProvinceId]
private type DestinationChooserC =
(FactionId, ProvinceId, GameState) => Option[ProvinceId]
def maybeMoveLeaderToBetterProvinceCommand(
actingFactionId: FactionId,
gs: GameStateProto,
gs: GameState,
acs: Vector[AvailableCommand]
): Option[CommandSelection] =
maybeMoveLeaderToBetterProvinceCommandWithChooser(
actingFactionId = actingFactionId,
gs = gs,
acs = acs,
betterDestinationChooser = (fid: FactionId, pid: ProvinceId, gameState: GameStateProto) =>
FactionLeaderProvinceRanker.bestSettlementPidForFactionLeader(fid, pid, gameState)
betterDestinationChooser = FactionLeaderProvinceRanker.bestSettlementPidForFactionLeader
)
private def candidatesForMarchCommand(
marchCommand: MarchAvailableCommand,
gs: GameStateProto,
faction: FactionProto
gs: GameState,
faction: Faction
): Vector[MarchCommandFromOneProvince] =
marchCommand.oneProvinceCommands.toVector.filter { opmc =>
val originProvince = gs.provinces(opmc.originProvinceId)
@@ -50,7 +43,7 @@ object MoveLeaderToBetterProvinceCommandChooser {
private def candidateCommandsWithDistances(
actingFactionId: FactionId,
gs: GameStateProto,
gs: GameState,
candidateCommands: Vector[MarchCommandFromOneProvince],
betterDestinationChooser: DestinationChooser
): Vector[MCFOPWithDestinationAndDistance] =
@@ -78,7 +71,7 @@ object MoveLeaderToBetterProvinceCommandChooser {
def maybeMoveLeaderToBetterProvinceCommandWithChooser(
actingFactionId: FactionId,
gs: GameStateProto,
gs: GameState,
acs: Vector[AvailableCommand],
betterDestinationChooser: DestinationChooser
): Option[CommandSelection] = {
@@ -94,97 +87,6 @@ object MoveLeaderToBetterProvinceCommandChooser {
betterDestinationChooser = betterDestinationChooser
)
candidatesWithDistances
.minByOption(_.provinceAndDistance.distance)
.flatMap { mcfopwd =>
MarchTowardProvinceCommandChooser.marchTowardProvinceCommand(
ac = marchCommand,
fid = actingFactionId,
opmc = mcfopwd.mcfop,
destinationProvinceId = mcfopwd.provinceAndDistance.provinceId,
gs = GameStateConverter.fromProto(gs),
favorLeaders = true
)
}
}
cs.flatten
}
// ==================== Protoless overloads ====================
/** Protoless overload */
def maybeMoveLeaderToBetterProvinceCommand(
actingFactionId: FactionId,
gs: GameState,
acs: Vector[AvailableCommand]
): Option[CommandSelection] =
maybeMoveLeaderToBetterProvinceCommandWithChooser(
actingFactionId = actingFactionId,
gs = gs,
acs = acs,
betterDestinationChooser = (fid: FactionId, pid: ProvinceId, gameState: GameState) =>
FactionLeaderProvinceRanker.bestSettlementPidForFactionLeader(fid, pid, gameState)
)
private def candidatesForMarchCommandC(
marchCommand: MarchAvailableCommand,
gs: GameState,
faction: FactionT
): Vector[MarchCommandFromOneProvince] =
marchCommand.oneProvinceCommands.toVector.filter { opmc =>
val originProvince = gs.provinces(opmc.originProvinceId)
if opmc.availableHeroIds.intersect(faction.leaderIds).isEmpty then false
else if originProvince.rulingFactionHeroIds.length < 2 then false
else true
}
private def candidateCommandsWithDistancesC(
actingFactionId: FactionId,
gs: GameState,
candidateCommands: Vector[MarchCommandFromOneProvince],
betterDestinationChooser: DestinationChooserC
): Vector[MCFOPWithDestinationAndDistance] =
candidateCommands.flatMap { mcfop =>
betterDestinationChooser(
actingFactionId,
mcfop.originProvinceId,
gs
).flatMap { desiredPid =>
ProvinceDistances
.closestProvinceThroughFriendliesOption(
desiredPid,
mcfop.availableDestinationProvinces.map(_.provinceId).toVector,
actingFactionId,
gs.provinces
)
.map { provinceAndDistance =>
MCFOPWithDestinationAndDistance(
mcfop = mcfop,
provinceAndDistance = provinceAndDistance
)
}
}
}
/** Protoless overload */
def maybeMoveLeaderToBetterProvinceCommandWithChooser(
actingFactionId: FactionId,
gs: GameState,
acs: Vector[AvailableCommand],
betterDestinationChooser: DestinationChooserC
): Option[CommandSelection] = {
val cs = for {
faction <- gs.factions.get(actingFactionId)
marchCommand <- acs.collectFirst { case ac: MarchAvailableCommand => ac }
} yield {
// Find the march command origin provinces that contain a faction leader
val candidatesWithDistances = candidateCommandsWithDistancesC(
actingFactionId = actingFactionId,
gs = gs,
candidateCommands = candidatesForMarchCommandC(marchCommand, gs, faction),
betterDestinationChooser = betterDestinationChooser
)
candidatesWithDistances
.minByOption(_.provinceAndDistance.distance)
.flatMap { mcfopwd =>
@@ -24,7 +24,7 @@ import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus.{
DIPLOMACY_OFFER_STATUS_REJECTED
}
import net.eagle0.eagle.internal.faction_relationship.FactionRelationship.RelationshipLevel.ALLY
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.settings.{
AiAllianceAcceptanceBaseChance,
AiTruceAcceptanceBaseChance,
@@ -33,7 +33,6 @@ import net.eagle0.eagle.library.settings.{
import net.eagle0.eagle.library.util.command_choice_helpers.{
CommandChooser,
CommandChooserImplicits,
LegacyCommandChooser,
RansomOfferHelpers
}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.{
@@ -45,34 +44,12 @@ import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.FactionId
object ResolveDiplomacyCommandSelector {
import CommandChooserImplicits.*
def resolveDiplomacySelectedCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
CommandChooser.choose(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = availableCommands,
rankedChoosers = Vector[LegacyCommandChooser](
resolveInvitationSelectedCommandProto _,
resolveAllianceOfferSelectedCommandProto _,
resolveBreakAllianceSelectedCommandProto _,
resolveTruceOfferSelectedCommandProto _,
resolveRansomOfferSelectedCommandProto _
),
functionalRandom = functionalRandom
)
/** Protoless overload */
def resolveDiplomacySelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
@@ -93,35 +70,6 @@ object ResolveDiplomacyCommandSelector {
functionalRandom = functionalRandom
)
private def resolveInvitationSelectedCommandProto(
actingFactionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
randomSelectionForType[ResolveInvitationAvailableCommand](
availableCommands,
functionalRandom
) {
case (resolveInvitationAc, fr) =>
selectionForResolveInvitationCommandProto(
actingFactionId = actingFactionId,
ac = resolveInvitationAc,
gs = gameState,
functionalRandom = fr
).map { resolveInviteSelection =>
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = 0,
available = resolveInvitationAc,
selected = resolveInviteSelection,
reason = "resolving invitation"
)
)
}
}
private def resolveInvitationSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
@@ -151,10 +99,10 @@ object ResolveDiplomacyCommandSelector {
}
}
private def selectionForResolveInvitationCommandProto(
private def selectionForResolveInvitationCommand(
actingFactionId: FactionId,
ac: ResolveInvitationAvailableCommand,
gs: GameStateProto,
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
val invitedFid = actingFactionId
@@ -185,53 +133,16 @@ object ResolveDiplomacyCommandSelector {
)
}
private def selectionForResolveInvitationCommand(
actingFactionId: FactionId,
ac: ResolveInvitationAvailableCommand,
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
val invitedFid = actingFactionId
val factions = gs.factions.values.toVector
val provinces = gs.provinces.values.toVector
val bestInvitation = ac.invitations
.maxBy(inv => invitationAcceptanceChance(inv.originatingFactionId, invitedFid, factions, provinces))
internalRequire(
bestInvitation.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
"Cannot handle invitation without ACCEPTED option"
)
internalRequire(
bestInvitation.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_REJECTED),
"Cannot handle invitation without REJECTED option"
)
val bestOriginatingFid = bestInvitation.originatingFactionId
ResolveInvitationSelectedCommand(
originatingFactionId = bestOriginatingFid,
resolution =
if 100.0 * roll < invitationAcceptanceChance(
bestOriginatingFid,
invitedFid,
factions,
provinces
)
then DIPLOMACY_OFFER_STATUS_ACCEPTED
else DIPLOMACY_OFFER_STATUS_REJECTED
)
}
def invitationAcceptanceChance(
originatingFid: FactionId,
invitedFid: FactionId,
gs: GameStateProto
gs: GameState
): Int =
(LegacyFactionUtils.prestige(originatingFid, gs)
- LegacyFactionUtils.prestige(invitedFid, gs)
- MinimumPrestigeDifferenceToInvite.doubleValue).floor.toInt.max(0)
/** Protoless overload */
/** Protoless version */
def invitationAcceptanceChance(
originatingFid: FactionId,
invitedFid: FactionId,
@@ -245,35 +156,6 @@ object ResolveDiplomacyCommandSelector {
- MinimumPrestigeDifferenceToInvite.doubleValue).floor.toInt.max(0)
}
private def resolveTruceOfferSelectedCommandProto(
actingFactionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
randomSelectionForType[ResolveTruceOfferAvailableCommand](
availableCommands,
functionalRandom
) {
case (resolveTruceAc, fr) =>
selectionForResolveTruceOfferSelectedCommandProto(
actingFactionId = actingFactionId,
ac = resolveTruceAc,
gs = gameState,
functionalRandom = fr
).map { resolveTruceOfferSelection =>
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = 0,
available = resolveTruceAc,
selected = resolveTruceOfferSelection,
reason = "resolving truce offer"
)
)
}
}
private def resolveTruceOfferSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
@@ -303,10 +185,10 @@ object ResolveDiplomacyCommandSelector {
}
}
private def selectionForResolveTruceOfferSelectedCommandProto(
private def selectionForResolveTruceOfferSelectedCommand(
actingFactionId: FactionId,
ac: ResolveTruceOfferAvailableCommand,
gs: GameStateProto,
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
val actingFid = actingFactionId
@@ -337,47 +219,10 @@ object ResolveDiplomacyCommandSelector {
)
}
private def selectionForResolveTruceOfferSelectedCommand(
actingFactionId: FactionId,
ac: ResolveTruceOfferAvailableCommand,
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
val actingFid = actingFactionId
val factions = gs.factions.values.toVector
val provinces = gs.provinces.values.toVector
val bestOffer = ac.offers
.maxBy(inv => truceOfferAcceptanceChance(inv.originatingFactionId, actingFid, factions, provinces))
internalRequire(
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
"Cannot handle truce offer without ACCEPTED option"
)
internalRequire(
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_REJECTED),
"Cannot handle truce offer without REJECTED option"
)
val bestOriginatingFid = bestOffer.originatingFactionId
ResolveTruceOfferSelectedCommand(
originatingFactionId = bestOriginatingFid,
resolution =
if 100.0 * roll < truceOfferAcceptanceChance(
originatingFid = bestOriginatingFid,
targetFid = actingFid,
factions = factions,
provinces = provinces
)
then DIPLOMACY_OFFER_STATUS_ACCEPTED
else DIPLOMACY_OFFER_STATUS_REJECTED
)
}
def truceOfferAcceptanceChance(
originatingFid: FactionId,
targetFid: FactionId,
gs: GameStateProto
gs: GameState
): Int =
(
AiTruceAcceptanceBaseChance.doubleValue
@@ -390,31 +235,10 @@ object ResolveDiplomacyCommandSelector {
)
).floor.toInt.max(0)
/** Protoless overload */
def truceOfferAcceptanceChance(
originatingFid: FactionId,
targetFid: FactionId,
factions: Vector[FactionT],
provinces: Vector[ProvinceT]
): Int = {
val originatingFaction = factions.find(_.id == originatingFid).get
val targetFaction = factions.find(_.id == targetFid).get
(
AiTruceAcceptanceBaseChance.doubleValue
+ FactionUtils.prestige(originatingFaction, provinces)
- FactionUtils.prestige(targetFaction, provinces)
+ FactionUtils.trust(
by = targetFid,
of = originatingFid,
factions = factions
)
).floor.toInt.max(0)
}
def allianceOfferAcceptanceChance(
originatingFid: FactionId,
targetFid: FactionId,
gs: GameStateProto
gs: GameState
): Int =
// Always reject if the faction already has an alliance
if gs
@@ -444,61 +268,10 @@ object ResolveDiplomacyCommandSelector {
)
).floor.toInt.max(0)
/** Protoless overload */
def allianceOfferAcceptanceChance(
originatingFid: FactionId,
targetFid: FactionId,
factions: Vector[FactionT],
provinces: Vector[ProvinceT]
): Int = {
import net.eagle0.eagle.model.state.faction.FactionRelationship.RelationshipLevel.Ally
val targetFaction = factions.find(_.id == targetFid).get
val originatingFaction = factions.find(_.id == originatingFid).get
val provincesMap = provinces.map(p => p.id -> p).toMap
// Always reject if the faction already has an alliance
if targetFaction.factionRelationships.exists(_.relationshipLevel == Ally) then 0
// reject if this is our only neighbor and we have no expansion possibilities
else if FactionUtils.provinces(targetFid, provinces).forall { province =>
province.neighbors.map(n => provincesMap(n.provinceId)).forall { neighborProvince =>
neighborProvince.rulingFactionId.contains(originatingFid) ||
neighborProvince.rulingFactionId.contains(targetFid)
}
}
then 0
else
(
AiAllianceAcceptanceBaseChance.doubleValue
+ FactionUtils.prestige(originatingFaction, provinces)
- FactionUtils.prestige(targetFaction, provinces)
+ FactionUtils.trust(
by = targetFid,
of = originatingFid,
factions = factions
)
).floor.toInt.max(0)
end if
}
// Ransom offer doesn't use gameState, so proto version just delegates
private def resolveRansomOfferSelectedCommandProto(
actingFactionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
resolveRansomOfferSelectedCommandCore(actingFactionId, availableCommands)
private def resolveRansomOfferSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
resolveRansomOfferSelectedCommandCore(actingFactionId, availableCommands)
private def resolveRansomOfferSelectedCommandCore(
actingFactionId: FactionId,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
selectionForType[ResolveRansomOfferAvailableCommand](availableCommands) { resolveRansomAc =>
CommandSelection(
@@ -506,14 +279,18 @@ object ResolveDiplomacyCommandSelector {
actingProvinceId = 0,
available = resolveRansomAc,
selected = selectionForResolveRansomOfferSelectedCommand(
ac = resolveRansomAc
actingFactionId = actingFactionId,
ac = resolveRansomAc,
gs = gameState
),
reason = "resolving ransom offer"
)
}
private def selectionForResolveRansomOfferSelectedCommand(
ac: ResolveRansomOfferAvailableCommand
actingFactionId: FactionId,
ac: ResolveRansomOfferAvailableCommand,
gs: GameState
): SelectedCommand =
ac.offers.head.offerDetails match {
case rod: RansomOfferDetails =>
@@ -525,35 +302,6 @@ object ResolveDiplomacyCommandSelector {
case _ => throw new EagleInternalException("Not a ransom offer")
}
private def resolveAllianceOfferSelectedCommandProto(
actingFactionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
randomSelectionForType[ResolveAllianceOfferAvailableCommand](
availableCommands,
functionalRandom
) {
case (resolveAllianceAc, fr) =>
selectionForResolveAllianceOfferSelectedCommandProto(
actingFactionId = actingFactionId,
ac = resolveAllianceAc,
gs = gameState,
functionalRandom = fr
).map { resolveAllianceOfferSelection =>
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = 0,
available = resolveAllianceAc,
selected = resolveAllianceOfferSelection,
reason = "resolving alliance offer"
)
)
}
}
private def resolveAllianceOfferSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
@@ -583,27 +331,11 @@ object ResolveDiplomacyCommandSelector {
}
}
// Break alliance doesn't use gameState, so proto version just delegates
private def resolveBreakAllianceSelectedCommandProto(
actingFactionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
resolveBreakAllianceSelectedCommandCore(actingFactionId, availableCommands, functionalRandom)
private def resolveBreakAllianceSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
resolveBreakAllianceSelectedCommandCore(actingFactionId, availableCommands, functionalRandom)
private def resolveBreakAllianceSelectedCommandCore(
actingFactionId: FactionId,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
randomSelectionForType[ResolveBreakAllianceAvailableCommand](
availableCommands,
@@ -611,7 +343,9 @@ object ResolveDiplomacyCommandSelector {
) {
case (resolveBreakAllianceAc, fr) =>
selectionForResolveBreakAllianceSelectedCommand(
actingFactionId = actingFactionId,
ac = resolveBreakAllianceAc,
gs = gameState,
functionalRandom = fr
).map { resolveBreakAllianceSelection =>
Some(
@@ -626,10 +360,10 @@ object ResolveDiplomacyCommandSelector {
}
}
private def selectionForResolveAllianceOfferSelectedCommandProto(
private def selectionForResolveAllianceOfferSelectedCommand(
actingFactionId: FactionId,
ac: ResolveAllianceOfferAvailableCommand,
gs: GameStateProto,
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
val actingFid = actingFactionId
@@ -660,49 +394,13 @@ object ResolveDiplomacyCommandSelector {
)
}
private def selectionForResolveAllianceOfferSelectedCommand(
// FIXME: always imprison ambassadors for now
private def selectionForResolveBreakAllianceSelectedCommand(
actingFactionId: FactionId,
ac: ResolveAllianceOfferAvailableCommand,
ac: ResolveBreakAllianceAvailableCommand,
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
val actingFid = actingFactionId
val factions = gs.factions.values.toVector
val provinces = gs.provinces.values.toVector
val bestOffer = ac.offers
.maxBy(inv => allianceOfferAcceptanceChance(inv.originatingFactionId, actingFid, factions, provinces))
internalRequire(
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
"Cannot handle alliance offer without ACCEPTED option"
)
internalRequire(
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_REJECTED),
"Cannot handle alliance offer without REJECTED option"
)
val bestOriginatingFid = bestOffer.originatingFactionId
ResolveAllianceOfferSelectedCommand(
originatingFactionId = bestOriginatingFid,
resolution =
if 100.0 * roll < allianceOfferAcceptanceChance(
originatingFid = bestOriginatingFid,
targetFid = actingFid,
factions = factions,
provinces = provinces
)
then DIPLOMACY_OFFER_STATUS_ACCEPTED
else DIPLOMACY_OFFER_STATUS_REJECTED
)
}
// FIXME: always imprison ambassadors for now
// Note: doesn't use gameState or functionalRandom roll
private def selectionForResolveBreakAllianceSelectedCommand(
ac: ResolveBreakAllianceAvailableCommand,
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { _ =>
val offer = ac.offers.head
ResolveBreakAllianceSelectedCommand(
@@ -28,13 +28,9 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
exports = [
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
],
deps = [
":oauth_config",
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"@maven//:org_json4s_json4s_ast_3",
"@maven//:org_json4s_json4s_core_3",
"@maven//:org_json4s_json4s_native_3",
@@ -1,6 +0,0 @@
package net.eagle0.eagle.auth
object GenerateJwtKey {
def main(args: Array[String]): Unit =
println(JwtServiceImpl.generateKey().toJSONString)
}

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