Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 f0484a74fa Add comprehensive timestamped logging for connection flow tracing
Adds a LogFlow() helper method that logs with precise timestamps in
HH:mm:ss.fff format. Uses LogFlow throughout the connection flow to
trace:
- Connection attempts and state transitions
- Subscription requests and acknowledgments
- Game updates (ActionResult, ShardokResult) with counts
- Heartbeat send/receive with sync status
- Sync mismatch detection
- Reconnect scheduling

Also removes debug Console.WriteLine statements and redundant UI
Debug.Log, replacing them with consistent LogFlow output.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 06:57:30 -08:00
adminandClaude Opus 4.5 672cb5cb20 Add diagnostic logging to ReceiveGameUpdate
Log exactly what's being received and whether updates are processed or skipped:
- ServerGameStatus updates
- Whether ActionResultViews are present
- Token comparison (incoming vs current)
- Whether the update was processed or skipped

This will help diagnose why game state isn't updating after sync mismatch reconnect.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 23:02:28 -08:00
adminandClaude Opus 4.5 816d2b6859 Add volatile to _currentState and diagnostic logging
- Make _currentState volatile to ensure visibility across threads
  (main thread reads for UI, background threads write on connect/disconnect)
- Add Debug.Log in ConnectionStatusUI when showing Reconnecting state
- Add more granular Console.WriteLine diagnostics in SendHeartbeat

This helps diagnose cases where the UI shows "Reconnecting..." but
the connection is actually Connected.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 22:46:24 -08:00
adminandClaude Opus 4.5 19b305829b Add diagnostics to distinguish ThreadPool exhaustion vs Logger blocking
Add Console.WriteLine at key points in SendHeartbeat to determine
what's actually causing the heartbeat freezes:

1. "SendHeartbeat starting" - if missing, Task.Run work never started (ThreadPool exhausted)
2. "LogLine completed" - if missing, Logger is blocking
3. "About to call WriteAsync" - timing before network I/O
4. "WriteAsync returned" - if missing, WriteAsync is blocking

This will definitively tell us whether the issue is ThreadPool
exhaustion from blocked WriteAsync or Logger blocking from slow
file I/O.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 21:54:02 -08:00
adminandClaude Opus 4.5 3c382349e6 Fix ThreadPool blocking issues causing timer callbacks to stop
This addresses the root cause of the Windows connection freeze where
heartbeat and idle timers stopped firing for ~28 seconds.

Changes:
1. Add 10-second timeout to WriteAsync calls
   - DoWithStreamingCall now uses Task.WhenAny with timeout
   - SendUpdateStreamRequestAsync uses CancellationTokenSource with timeout
   - Prevents indefinite blocking on dead connections that exhaust ThreadPool

2. Add ConfigureAwait(false) to all network operations
   - Prevents deadlocks from continuations trying to marshal back to main thread

3. Wrap timer callbacks in try-catch
   - Idle check timer: catches exceptions to prevent silent failures
   - Heartbeat timer: wraps both the Elapsed handler and SendHeartbeat task

4. Fix double PostRequest bug in TryPendingCommands
   - Removed unconditional PostRequest call at line 475 that was outside switch
   - Commands were being posted twice: once in switch case, once after

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 21:19:46 -08:00
224 changed files with 12389 additions and 25045 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
+3 -80
View File
@@ -348,86 +348,15 @@ 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 }}
@@ -453,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
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
@@ -473,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
@@ -500,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",
)
-31
View File
@@ -26,8 +26,6 @@ services:
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
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
@@ -92,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"
@@ -114,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
@@ -143,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?
@@ -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;
@@ -118,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;
@@ -134,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;
@@ -149,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);
}
@@ -205,164 +166,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
errorHandler.gameObject.SetActive(true);
// Initialize OAuth UI
SetupOAuthUI();
// 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 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;
@@ -457,8 +262,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
_persistentClientConnection = null;
eagleConnection?.Dispose();
eagleConnection = null;
_connectedEnvironmentIndex = -1;
}
public void OnResolutionChanged(int dropdownIndex) {
@@ -494,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
@@ -533,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;
}
});
}
@@ -556,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);
@@ -571,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,
@@ -598,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);
}
@@ -655,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);
}
@@ -743,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);
@@ -772,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:
@@ -45,17 +45,11 @@ namespace eagle {
private readonly Logger _remoteEagleClientLogger = Logger.GetLogger("ConnectionLogger");
private readonly Logger _timingsLogger = Logger.GetLogger("ConnectionLogger");
// Counter to diagnose duplicate logging - if logs show same seq# twice, two threads
// are processing same message. If seq# increments but lines doubled, Logger issue.
private int _logFlowSeq = 0;
/// <summary>Timestamped log for connection flow tracing.</summary>
private void LogFlow(string message) {
var seq = Interlocked.Increment(ref _logFlowSeq);
var ts = DateTime.UtcNow.ToString("HH:mm:ss.fff");
_remoteEagleClientLogger.LogLine($"[FLOW {ts}] #{seq} {message}");
_remoteEagleClientLogger.LogLine($"[FLOW {ts}] {message}");
}
private volatile bool _isConnecting = false;
private Timer _idleCheckTimer = null;
private Timer _heartbeatTimer = null;
@@ -89,23 +83,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 +137,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 +156,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,
@@ -226,16 +203,12 @@ namespace eagle {
var shardokCount = shardokStatuses.Count();
LogFlow($"SUBSCRIBE game={gameId} unfilteredCount={subscriber.LastUnfilteredResultCount} shardokGames={shardokCount}");
_remoteEagleClientLogger.LogLine(
$"[SUBSCRIBE] Sending StreamGameRequest for game {gameId}, " +
$"unfilteredCount={subscriber.LastUnfilteredResultCount}");
var sendSuccess = await DoWithStreamingCall(async (sc) => {
await sc.RequestStream.WriteAsync(request);
_remoteEagleClientLogger.LogLine(
$"[SUBSCRIBE] StreamGameRequest sent successfully for game {gameId}");
return true;
});
await sc.RequestStream.WriteAsync(request).ConfigureAwait(false);
LogFlow($"SUBSCRIBE sent for game={gameId}");
return true;
}).ConfigureAwait(false);
if (!sendSuccess) {
// Write failed - clean up and return
@@ -249,7 +222,7 @@ namespace eagle {
try {
var ackTask = ackTcs.Task;
var timeoutTask = Task.Delay(SubscriptionAckTimeoutMs, timeoutCts.Token);
var completedTask = await Task.WhenAny(ackTask, timeoutTask);
var completedTask = await Task.WhenAny(ackTask, timeoutTask).ConfigureAwait(false);
if (completedTask == timeoutTask) {
// Timeout waiting for ack
@@ -263,26 +236,15 @@ namespace eagle {
// Cancel the timeout task since ack was received
timeoutCts.Cancel();
var ack = await ackTask;
var ack = await ackTask.ConfigureAwait(false);
if (ack.Success) {
LogFlow($"SUBSCRIBE_ACK game={gameId} success=true confirmedCount={ack.ConfirmedResultCount}");
_remoteEagleClientLogger.LogLine(
$"[SUBSCRIBE] Subscription confirmed for game {gameId}, " +
$"confirmedResultCount={ack.ConfirmedResultCount}");
// Note: Shardok resync flags are cleared in EagleGameModel.HandleOneGameUpdate
// AFTER updates are actually received, not here. This ensures that if the
// connection drops between acknowledgment and update delivery, the resync
// will be requested again on the next reconnect.
return true;
} else {
LogFlow($"SUBSCRIBE_ACK game={gameId} success=false error={ack.ErrorMessage}");
LogConnectionEvent(
"subscribe_ack_failed",
$"game={gameId}, error={ack.ErrorMessage}");
_remoteEagleClientLogger.LogLine(
$"[SUBSCRIBE] Server rejected subscription for game {gameId}: {ack.ErrorMessage}");
return false;
}
} catch (OperationCanceledException) {
@@ -300,27 +262,29 @@ namespace eagle {
}
public async Task Connect() {
LogFlow($"Connect() called, state={_currentState}, failures={_consecutiveFailures}");
// Prevent concurrent connection attempts
if (_isConnecting) {
LogFlow($"Connect() SKIPPED already_connecting=true");
LogFlow("Connect() skipped - already connecting");
return;
}
// Check circuit breaker
if (!_circuitBreaker.ShouldAttemptConnection()) {
LogFlow($"Connect() BLOCKED circuit={_circuitBreaker.CurrentState}");
_remoteEagleClientLogger.LogLine(
$"[CIRCUIT] Connection attempt blocked - circuit {_circuitBreaker.CurrentState}");
LogConnectionEvent("connect_blocked", $"circuit={_circuitBreaker.CurrentState}");
return;
}
_isConnecting = true;
try {
LogFlow($"Connect() START failures={_consecutiveFailures}");
_lastConnectAttempt = DateTime.UtcNow;
_currentState = _consecutiveFailures > 0 ? ConnectionState.Reconnecting
: ConnectionState.Connecting;
LogFlow($"State -> {_currentState}");
NextReconnectAttempt = null;
LogFlow($"State -> {_currentState}");
LogConnectionEvent("connect_attempt");
// Dispose existing streaming call before creating new one
@@ -353,7 +317,7 @@ namespace eagle {
// Stream subscriptions OUTSIDE lock (can await)
// Set state to SubscriptionPending while waiting for acks
LogFlow($"Streaming {subscribersToStream.Count} subscriptions");
LogFlow($"Stream created, {subscribersToStream.Count} subscribers to stream");
if (subscribersToStream.Any()) {
_currentState = ConnectionState.SubscriptionPending;
LogFlow($"State -> {_currentState}");
@@ -361,13 +325,16 @@ namespace eagle {
bool allSucceeded = true;
foreach (var subscriber in subscribersToStream) {
if (!await StreamOneGameAsync(subscriber)) { allSucceeded = false; }
LogFlow($"Subscribing game {subscriber.GameId}...");
if (!await StreamOneGameAsync(subscriber)) {
LogFlow($"Subscription FAILED for game {subscriber.GameId}");
allSucceeded = false;
}
}
if (!allSucceeded) {
// Subscription failed - this is critical because the game won't receive
// updates. Trigger reconnect rather than proceeding to Connected state.
LogFlow("SUBSCRIBE_FAILED triggering reconnect");
LogConnectionEvent(
"subscribe_failed",
"Subscription failed - triggering reconnect");
@@ -391,7 +358,7 @@ namespace eagle {
_lastSuccessfulConnect = DateTime.UtcNow;
_consecutiveFailures = 0; // Reset backoff on successful connection
_currentState = ConnectionState.Connected;
LogFlow($"State -> {_currentState}");
LogFlow($"State -> {_currentState} (all subscriptions succeeded)");
_circuitBreaker.RecordSuccess();
LogConnectionEvent("connect_success");
@@ -426,18 +393,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 +430,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 +459,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;
}
}
// Note: PostRequest is called inside each case, not here
// (removed duplicate PostRequest call that was causing double-posting)
}
}
@@ -632,16 +576,18 @@ namespace eagle {
public async Task PostError(EagleGameId gameId, string errorMessage, string stackTrace) {
await DoWithStreamingCall(async (streamingCall) => {
await streamingCall.RequestStream.WriteAsync(new UpdateStreamRequest {
ErrorRequest =
new ErrorRequest {
ErrorMessage = errorMessage,
GameId = gameId,
StackTrace = stackTrace
}
});
await streamingCall.RequestStream
.WriteAsync(new UpdateStreamRequest {
ErrorRequest =
new ErrorRequest {
ErrorMessage = errorMessage,
GameId = gameId,
StackTrace = stackTrace
}
})
.ConfigureAwait(false);
return true;
});
}).ConfigureAwait(false);
}
public void Unsubscribe(IClientConnectionSubscriber subscriber) {
@@ -652,25 +598,14 @@ namespace eagle {
try {
lock (this) { _pendingCommands.Add(request); }
var success = await DoWithStreamingCall(async (streamingCall) => {
await streamingCall.RequestStream.WriteAsync(
new UpdateStreamRequest { PostCommandRequest = request });
await DoWithStreamingCall(async (streamingCall) => {
await streamingCall.RequestStream
.WriteAsync(new UpdateStreamRequest { PostCommandRequest = request })
.ConfigureAwait(false);
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
}
}).ConfigureAwait(false);
lock (this) { _pendingCommands.Remove(request); }
} catch (Exception e) { Console.WriteLine("Failed to post command: " + e); }
}
private async Task
@@ -747,54 +682,34 @@ 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 actionTask = action(_streamingCall);
var timeoutTask = Task.Delay(WriteTimeoutMs, _cancellationToken);
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");
"[WRITE] DoWithStreamingCall timed out - connection may be dead");
return false;
}
// Cancel the timeout task since action completed
timeoutCts.Cancel();
return await actionTask.ConfigureAwait(false);
} catch (RpcException e) {
} catch (OperationCanceledException) { return false; } catch (RpcException e) {
if (e.StatusCode == StatusCode.Cancelled) {
// This is expected when the connection is closed.
return false;
} 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>
// Timeout for WriteAsync to prevent ThreadPool exhaustion from blocked writes
private const int WriteTimeoutMs = 10000;
public async Task<bool> SendUpdateStreamRequestAsync(UpdateStreamRequest request) {
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc = null;
lock (this) {
@@ -803,31 +718,18 @@ namespace eagle {
}
try {
using var timeoutCts = new CancellationTokenSource(WriteAsyncTimeoutMs);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
_cancellationToken,
timeoutCts.Token);
// Use timeout to prevent indefinite blocking on dead connections
using var timeoutCts =
CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken);
timeoutCts.CancelAfter(WriteTimeoutMs);
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, timeoutCts.Token).ConfigureAwait(false);
return true;
} catch (OperationCanceledException) {
// Timeout or cancellation - connection is likely dead
_remoteEagleClientLogger.LogLine(
"[WRITE] WriteAsync timed out or cancelled - connection may be dead");
return false;
} catch (RpcException e) {
if (e.StatusCode == StatusCode.Cancelled) {
// This is expected when the connection is closed.
@@ -835,9 +737,6 @@ namespace eagle {
} else {
throw;
}
} catch (OperationCanceledException) {
// Timeout was triggered
return false;
}
}
@@ -850,7 +749,6 @@ namespace eagle {
switch (gameUpdate.GameUpdateDetailsCase) {
case GameUpdate.GameUpdateDetailsOneofCase.ErrorResponse:
LogFlow($"UPDATE ErrorResponse game={gameUpdate.GameId}");
_remoteEagleClientLogger.LogLine("Got an error response!");
MainQueue.Q.Enqueue(() => {
lock (this) { _subscribers.Remove(gameUpdate.GameId); }
});
@@ -858,7 +756,6 @@ namespace eagle {
case GameUpdate.GameUpdateDetailsOneofCase.StreamingTextResponse:
// Handle streaming text directly on gRPC thread - dictionary is thread-safe.
// Listeners are notified later via ProcessPendingUpdates() on main thread.
var str = gameUpdate.StreamingTextResponse;
if (str != null) {
ClientTextProvider.Provider.HandleNewStreamingText(
@@ -867,8 +764,6 @@ namespace eagle {
str.StartingByteCount,
str.Completed);
}
// No MainQueue enqueue needed - ProcessPendingUpdates handles listener
// notification
break;
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
@@ -881,11 +776,10 @@ namespace eagle {
? arResp.AvailableCommands.CommandsByProvince?.Count ?? 0
: 0;
var status = arResp.ServerGameStatus?.Status.ToString() ?? "null";
LogFlow($"UPDATE ActionResult game={gameUpdate.GameId} countAfter={arResp.UnfilteredResultCountAfter} " +
$"actionResults={arCount} startingState={hasStartingState} hasCommands={hasCommands} " +
LogFlow($"UPDATE ActionResult game={gameUpdate.GameId} actionResults={arCount} " +
$"startingState={hasStartingState} hasCommands={hasCommands} " +
$"token={cmdToken} cmdProvinces={cmdCount} status={status}");
// Update result counts IMMEDIATELY on the gRPC thread, before enqueueing.
lock (this) {
if (_subscribers.TryGetValue(gameUpdate.GameId, out var sub)) {
sub.UpdateResultCounts(gameUpdate);
@@ -901,28 +795,15 @@ namespace eagle {
_subscribers.Remove(gameUpdate.GameId);
}
}
await TryPendingCommands();
var processTime = (DateTime.UtcNow - receivedTime).TotalMilliseconds;
if (processTime > 100.0) {
_timingsLogger.LogLine($"PROCESS {processTime} ms");
}
});
break;
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
var shardokResp = gameUpdate.ShardokActionResultResponse;
var shardokGameCount = shardokResp.ShardokGameResponses?.Count ?? 0;
// Log each Shardok game's sequence to detect missing results
foreach (var sgr in shardokResp.ShardokGameResponses) {
var resultCount = sgr.ActionResultViews?.Count ?? 0;
LogFlow($"UPDATE ShardokResult game={gameUpdate.GameId} " +
$"shardok={sgr.ShardokGameId} countAfter={sgr.NewResultViewCount} " +
$"results={resultCount}");
}
var shResp = gameUpdate.ShardokActionResultResponse;
var shGames = shResp.ShardokGameResponses?.Count ?? 0;
LogFlow($"UPDATE ShardokResult game={gameUpdate.GameId} shardokGames={shGames}");
// Update result counts IMMEDIATELY on the gRPC thread, before enqueueing.
lock (this) {
if (_subscribers.TryGetValue(gameUpdate.GameId, out var sub2)) {
sub2.UpdateResultCounts(gameUpdate);
@@ -938,13 +819,7 @@ namespace eagle {
_subscribers.Remove(gameUpdate.GameId);
}
}
await TryPendingCommands();
var processTime = (DateTime.UtcNow - receivedTime).TotalMilliseconds;
if (processTime > 100.0) {
_timingsLogger.LogLine($"PROCESS {processTime} ms");
}
});
break;
}
@@ -1071,8 +946,6 @@ namespace eagle {
var scNull = sc == null;
var tokenCancelled = _currentThreadToken.IsCancellationRequested;
LogFlow($"HandleStreamingCall ENDED normally: sc_null={scNull} token_cancelled={tokenCancelled}");
_remoteEagleClientLogger.LogLine(
"How did we get here? This is not my beautiful wife!");
} catch (RpcException e) {
lock (this) {
_lastDisconnect = DateTime.UtcNow;
@@ -1137,19 +1010,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;
@@ -1185,13 +1045,9 @@ namespace eagle {
};
_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}");
LogFlow($"IDLE_TIMER_ERROR {e.GetType().Name}: {e.Message}");
}
};
_idleCheckTimer.Enabled = true;
@@ -1238,7 +1094,7 @@ namespace eagle {
if (toCancel != null) {
toCancel.Dispose();
lock (this) { _streamingCall = null; }
RunConnectAsync();
Task.Run(() => Connect());
}
}
}
@@ -1249,22 +1105,15 @@ namespace eagle {
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();
await SendHeartbeat().ConfigureAwait(false);
} 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}");
LogFlow($"HEARTBEAT_ERROR {e.GetType().Name}: {e.Message}");
}
});
} catch (Exception e) {
// Timer callbacks must not throw
Console.WriteLine($"[HEARTBEAT_TIMER] Exception in callback: {e}");
_remoteEagleClientLogger.LogLine($"Exception in heartbeat timer: {e}");
LogFlow($"HEARTBEAT_TIMER_ERROR {e.GetType().Name}: {e.Message}");
}
};
_heartbeatTimer.Enabled = true;
@@ -1334,32 +1183,18 @@ namespace eagle {
private void HandleHeartbeatResponse(HeartbeatResponse response) {
LogFlow($"HEARTBEAT_RESPONSE server_ts={response.ServerTimestamp}");
_remoteEagleClientLogger.LogLine(
$"[HEARTBEAT] Got response, server_timestamp={response.ServerTimestamp}");
// Check for sync mismatches reported by server
bool hasMismatch = false;
foreach (var syncResult in response.GameSyncResults) {
if (!syncResult.EagleInSync) {
LogFlow($"SYNC_MISMATCH game={syncResult.GameId} server_count={syncResult.ServerUnfilteredResultCount}");
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Game {syncResult.GameId}: Eagle out of sync, " +
$"server has {syncResult.ServerUnfilteredResultCount} results");
LogConnectionEvent(
"sync_mismatch_eagle",
$"game={syncResult.GameId}, server_count={syncResult.ServerUnfilteredResultCount}");
hasMismatch = true;
}
foreach (var shardokResult in syncResult.ShardokSyncResults) {
if (!shardokResult.InSync) {
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Game {syncResult.GameId}, Shardok {shardokResult.ShardokGameId}: " +
$"out of sync, server has {shardokResult.ServerFilteredResultCount} results");
LogConnectionEvent(
"sync_mismatch_shardok",
$"game={syncResult.GameId}, shardok={shardokResult.ShardokGameId}, " +
$"server_count={shardokResult.ServerFilteredResultCount}");
LogFlow($"SYNC_MISMATCH game={syncResult.GameId} shardok={shardokResult.ShardokGameId} server_count={shardokResult.ServerFilteredResultCount}");
hasMismatch = true;
}
}
@@ -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
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;
@@ -46,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) {
@@ -60,17 +55,9 @@ 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
@@ -116,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.
@@ -207,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]);
}
@@ -317,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(
@@ -151,7 +151,7 @@ case class AIClient(
(actingFactionId, gameState, availableCommands, functionalRandom) =>
AttackDecisionCommandChooser.attackDecisionSelectedCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands,
functionalRandom
),
@@ -1,53 +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)
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
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
@@ -69,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(
@@ -88,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] = {
@@ -132,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)
}
}
+2 -11
View File
@@ -51,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",
],
)
@@ -148,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",
],
)
@@ -205,7 +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",
],
)
@@ -224,8 +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/game_state",
],
)
@@ -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,
@@ -34,7 +34,6 @@ 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.views.battalion_view.BattalionView
import net.eagle0.eagle.views.province_view.FullProvinceInfo
@@ -153,7 +152,7 @@ object MidGameAIClient {
Math.min(actingProvince.food - foodToHoldBack, foodAvailable)
val maxGoldToSend = Math.min(
ProvinceGoldSurplusCalculator
.provinceGoldSurplus(ProvinceConverter.fromProto(actingProvince)),
.provinceGoldSurplus(actingProvinceId, gameState),
goldAvailable
)
@@ -941,7 +940,7 @@ object MidGameAIClient {
fid = actingFactionId,
opmc = opmc,
destinationProvinceId = destinationProvinceId,
gs = GameStateConverter.fromProto(gs),
gs = gs,
favorLeaders = false
)
}
@@ -7,7 +7,6 @@ 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
object MoveLeaderToBetterProvinceCommandChooser {
private case class MCFOPWithDestinationAndDistance(
@@ -96,7 +95,7 @@ object MoveLeaderToBetterProvinceCommandChooser {
fid = actingFactionId,
opmc = mcfopwd.mcfop,
destinationProvinceId = mcfopwd.provinceAndDistance.provinceId,
gs = GameStateConverter.fromProto(gs),
gs = gs,
favorLeaders = true
)
}
@@ -28,9 +28,6 @@ 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",
@@ -24,30 +24,22 @@ case class ProviderUserInfo(
username: String
)
/** Result of OAuth flow */
sealed trait OAuthResult
case class OAuthSuccess(providerInfo: ProviderUserInfo, provider: OAuthProvider) extends OAuthResult
case class OAuthFailure(error: String) extends OAuthResult
case object OAuthPending extends OAuthResult
case object OAuthExpired extends OAuthResult
/** Service for OAuth code exchange */
trait OAuthService {
/** Generate OAuth authorization URL. Server handles callback internally. */
def getAuthUrl(provider: OAuthProvider): (String, String) // (url, state)
/** Generate OAuth authorization URL */
def getAuthUrl(provider: OAuthProvider, redirectUri: String): (String, String) // (url, state)
/** Check status of OAuth flow for given state */
def checkStatus(state: String): OAuthResult
/** Handle OAuth callback - exchange code for user info and store result */
def handleCallback(code: String, state: String): Either[String, ProviderUserInfo]
/** Get the server's OAuth callback URL */
def callbackUrl: String
/** Exchange authorization code for user info */
def exchangeCode(
provider: OAuthProvider,
code: String,
state: String,
redirectUri: String
): Either[String, ProviderUserInfo]
}
class OAuthServiceImpl(serverBaseUrl: String) extends OAuthService {
class OAuthServiceImpl extends OAuthService {
private implicit val formats: DefaultFormats.type = DefaultFormats
private val json = new Json(formats)
@@ -57,29 +49,25 @@ class OAuthServiceImpl(serverBaseUrl: String) extends OAuthService {
.connectTimeout(Duration.ofSeconds(10))
.build()
// Pending OAuth sessions: state -> (provider, timestamp)
private val pendingOAuth = TrieMap[String, (OAuthProvider, Long)]()
// Completed OAuth results: state -> result
private val completedOAuth = TrieMap[String, OAuthResult]()
// State parameter storage with timestamp (for CSRF protection)
private val stateStore = TrieMap[String, Long]()
// State expiration (10 minutes)
private val stateExpirationMs = 600000L
override def callbackUrl: String = s"$serverBaseUrl/oauth/callback"
override def getAuthUrl(provider: OAuthProvider): (String, String) = {
override def getAuthUrl(provider: OAuthProvider, redirectUri: String): (String, String) = {
val config = getConfig(provider)
val state = UUID.randomUUID().toString
pendingOAuth.put(state, (provider, System.currentTimeMillis()))
stateStore.put(state, System.currentTimeMillis())
// Clean old states
cleanupExpiredStates()
val cutoff = System.currentTimeMillis() - stateExpirationMs
stateStore.filterInPlace((_, timestamp) => timestamp > cutoff)
val params = Map(
"client_id" -> config.clientId,
"redirect_uri" -> callbackUrl,
"redirect_uri" -> redirectUri,
"response_type" -> "code",
"scope" -> config.scopes.mkString(" "),
"state" -> state
@@ -95,67 +83,30 @@ class OAuthServiceImpl(serverBaseUrl: String) extends OAuthService {
(url, state)
}
override def checkStatus(state: String): OAuthResult =
// First check completed results
completedOAuth.get(state) match {
case Some(result) => result
case None =>
// Check if still pending
pendingOAuth.get(state) match {
case Some((_, timestamp)) if System.currentTimeMillis() - timestamp < stateExpirationMs =>
OAuthPending
case Some(_) =>
// Expired
pendingOAuth.remove(state)
OAuthExpired
case None =>
// Unknown state - might be expired and cleaned up
OAuthExpired
}
override def exchangeCode(
provider: OAuthProvider,
code: String,
state: String,
redirectUri: String
): Either[String, ProviderUserInfo] = {
// Verify state
stateStore.remove(state) match {
case Some(timestamp) if System.currentTimeMillis() - timestamp < stateExpirationMs =>
// Valid state
case _ =>
return Left("Invalid or expired state parameter")
}
override def handleCallback(code: String, state: String): Either[String, ProviderUserInfo] =
// Get pending OAuth info
pendingOAuth.remove(state) match {
case Some((provider, timestamp)) if System.currentTimeMillis() - timestamp < stateExpirationMs =>
val config = getConfig(provider)
val config = getConfig(provider)
// Exchange code for access token
val tokenResult = exchangeCodeForToken(config, code)
tokenResult match {
case Left(error) =>
completedOAuth.put(state, OAuthFailure(error))
Left(error)
case Right(accessToken) =>
// Fetch user info
fetchUserInfo(provider, config, accessToken) match {
case Left(error) =>
completedOAuth.put(state, OAuthFailure(error))
Left(error)
case Right(userInfo) =>
completedOAuth.put(state, OAuthSuccess(userInfo, provider))
Right(userInfo)
}
}
case Some(_) =>
completedOAuth.put(state, OAuthExpired)
Left("OAuth session expired")
case None =>
Left("Invalid or expired state parameter")
// Exchange code for access token
val tokenResult = exchangeCodeForToken(config, code, redirectUri)
tokenResult match {
case Left(error) => Left(error)
case Right(accessToken) =>
// Fetch user info
fetchUserInfo(provider, config, accessToken)
}
private def cleanupExpiredStates(): Unit = {
val cutoff = System.currentTimeMillis() - stateExpirationMs
pendingOAuth.filterInPlace((_, v) => v._2 > cutoff)
completedOAuth.filterInPlace((state, _) =>
pendingOAuth.contains(state) ||
completedOAuth.get(state).exists {
case OAuthSuccess(_, _) => true // Keep successes for a bit
case _ => false
}
)
}
private def getConfig(provider: OAuthProvider): OAuthProviderConfig =
@@ -167,7 +118,8 @@ class OAuthServiceImpl(serverBaseUrl: String) extends OAuthService {
private def exchangeCodeForToken(
config: OAuthProviderConfig,
code: String
code: String,
redirectUri: String
): Either[String, String] =
Try {
val formData = Map(
@@ -175,7 +127,7 @@ class OAuthServiceImpl(serverBaseUrl: String) extends OAuthService {
"client_secret" -> config.clientSecret,
"grant_type" -> "authorization_code",
"code" -> code,
"redirect_uri" -> callbackUrl
"redirect_uri" -> redirectUri
)
val formBody = formData.map {
@@ -51,13 +51,4 @@ trait Engine {
selectedProvinceId: ProvinceId,
selectedCommand: SelectedCommand
): EngineAndResults
/**
* Rewinds the game to a previous state by truncating history.
* @param targetActionCount
* The number of actions to keep (rewinds to state after this many actions)
* @return
* A new Engine at the target state
*/
def rewindTo(targetActionCount: Int): Engine
}
@@ -273,19 +273,6 @@ case class EngineImpl(
)
}
override def rewindTo(targetActionCount: Int): EngineImpl = {
internalRequire(
targetActionCount >= 0 && targetActionCount <= history.count,
s"Target action count $targetActionCount must be between 0 and ${history.count}"
)
val truncatedHistory = history.truncateTo(targetActionCount)
copy(
currentState = GameStateConverter.fromProto(truncatedHistory.last.gameState).copy(gameId = gameId),
history = truncatedHistory
)
}
def postCommand(
factionId: FactionId,
selectedProvinceId: ProvinceId,
@@ -40,14 +40,11 @@ trait GameHistory {
def withNewResults(newResults: Vector[ActionWithResultingState]): GameHistory
def truncateTo(targetActionCount: Int): GameHistory
def withNewResultsScala(newResults: Vector[ActionResultWithResultingState]): GameHistory =
withNewResults(newResults.map { awrs =>
ActionWithResultingState(
actionResult = ActionResultProtoConverter.toProto(awrs.actionResult),
gameState = GameStateConverter.toProto(awrs.resultingState),
precomputedScalaState = Some(awrs.resultingState) // Preserve Scala state to avoid re-conversion
gameState = GameStateConverter.toProto(awrs.resultingState)
)
})
@@ -851,7 +851,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library:game_history",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators:chronicle_update_prompt_generator",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/settings:empty_province_monthly_devastation_delta",
"//src/main/scala/net/eagle0/eagle/library/settings:faction_bias_minimum_adjustment_per_round",
@@ -75,14 +75,9 @@ case class CheckForFactionChangesAction(
val newLeaders =
faction.leaderIds.diff(killedHeroIds)
// Only change faction head if the current head was killed
val newFactionHeadId =
if newLeaders.contains(faction.factionHeadId) then faction.factionHeadId
else newLeaders.headOption.getOrElse(0)
faction.copy(
leaderIds = newLeaders,
factionHeadId = newFactionHeadId
factionHeadId = newLeaders.headOption.getOrElse(0)
)
}
}
@@ -216,65 +211,46 @@ case class CheckForFactionChangesAction(
case Nil => None
case fs =>
val changedFactionData = fs.map { revisedFaction =>
val originalFaction = factions.find(_.id == revisedFaction.id).get
val factionHeadChanged = originalFaction.factionHeadId != revisedFaction.factionHeadId
// Only include new head info if the head actually changed
val (newFactionHeadHeroId, newFactionName, llmRequest, notification) =
if factionHeadChanged then {
val newHeadHero = heroes.find(_.id == revisedFaction.factionHeadId)
val newName = newHeadHero.flatMap(_.ledFactionName)
val llmRequestId = s"new_faction_head_${revisedFaction.id}_${revisedFaction.factionHeadId}"
(
Some(revisedFaction.factionHeadId),
newName,
Some(
NewFactionHeadMessage(
requestId = llmRequestId,
eagleGameId = gameId,
recipientFactionIds = Vector.empty,
alwaysGenerate = false,
newHeadHeroId = revisedFaction.factionHeadId,
factionId = revisedFaction.id,
previousFactionName = originalFaction.name,
newFactionName = newName,
previousHeadHeroId = originalFaction.factionHeadId
)
),
Some(
NotificationC(
details = NotificationDetails.NewFactionHead(
newHeadHeroId = revisedFaction.factionHeadId,
factionId = revisedFaction.id,
previousHeadHeroId = originalFaction.factionHeadId
),
affectedHeroIds = Vector(revisedFaction.factionHeadId, originalFaction.factionHeadId),
llm = NotificationT.Llm.Id(llmRequestId),
deferred = true
)
)
)
} else {
(None, None, None, None)
}
val originalFaction = factions.find(_.id == revisedFaction.id).get
val newHeadHero = heroes.find(_.id == revisedFaction.factionHeadId)
val newFactionName = newHeadHero.flatMap(_.ledFactionName)
val llmRequestId = s"new_faction_head_${revisedFaction.id}_${revisedFaction.factionHeadId}"
(
ChangedFactionC(
factionId = revisedFaction.id,
removedLeaderHeroIds = originalFaction.leaderIds.filter(killedHeroIds.contains),
newFactionHeadHeroId = newFactionHeadHeroId,
newFactionHeadHeroId = Some(revisedFaction.factionHeadId),
newName = newFactionName
),
llmRequest,
notification
NewFactionHeadMessage(
requestId = llmRequestId,
eagleGameId = gameId,
recipientFactionIds = Vector.empty,
alwaysGenerate = false,
newHeadHeroId = revisedFaction.factionHeadId,
factionId = revisedFaction.id,
previousFactionName = originalFaction.name,
newFactionName = newFactionName,
previousHeadHeroId = originalFaction.factionHeadId
),
NotificationC(
details = NotificationDetails.NewFactionHead(
newHeadHeroId = revisedFaction.factionHeadId,
factionId = revisedFaction.id,
previousHeadHeroId = originalFaction.factionHeadId
),
affectedHeroIds = Vector(revisedFaction.factionHeadId, originalFaction.factionHeadId),
llm = NotificationT.Llm.Id(llmRequestId),
deferred = true
)
)
}.toVector
Some(
ActionResultC(
actionResultType = FactionLeaderRemovedResultType,
changedFactions = changedFactionData.map(_._1),
newGeneratedTextRequests = changedFactionData.flatMap(_._2),
newNotifications = changedFactionData.flatMap(_._3)
newGeneratedTextRequests = changedFactionData.map(_._2),
newNotifications = changedFactionData.map(_._3)
)
)
}
@@ -4,7 +4,6 @@ import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.{HeroId, ProvinceId, RoundId}
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialResultsAction
import net.eagle0.eagle.library.actions.llm_prompt_generators.ChronicleOptions
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.library.settings.{
EmptyProvinceMonthlyDevastationDelta,
@@ -86,18 +85,12 @@ case class NewRoundAction(
initialSequencer.withActionResult(_ => NewYearAction(gameState).immediateExecute)
else initialSequencer
// Select chronicler style using a hash that doesn't cycle predictably
val chroniclerStyle = ChronicleOptions.styles(
(gameState.gameId, newDate.year, newDate.month.value).hashCode.abs % ChronicleOptions.styles.size
)
// Add the main new round result
val afterNewRoundSequencer = afterNewYearSequencer.withActionResult { currentState =>
newRoundResult(
gs = currentState,
newRoundId = newRoundId,
newDate = newDate,
chroniclerStyle = chroniclerStyle
newDate = newDate
)
}
@@ -109,8 +102,7 @@ case class NewRoundAction(
private def chronicleLlmRequests(
newDate: Date,
gs: GameState,
chroniclerStyle: String
gs: GameState
): Vector[LlmRequestT] =
if isChronicleMonth(newDate) then
Vector(
@@ -121,8 +113,7 @@ case class NewRoundAction(
previous_entries = gs.chronicleEntries.map { entry =>
ChronicleUpdatePreviousEntry(
date = entry.date,
generatedTextId = entry.generatedTextId,
chroniclerStyle = entry.chroniclerStyle
generatedTextId = entry.generatedTextId
)
},
new_entries = ChronicleEventGenerator.eventTextEntries(
@@ -130,8 +121,7 @@ case class NewRoundAction(
since = gs.chronicleEntries.lastOption
.map(_.date)
.getOrElse(Date(year = 0, month = Date.Month.January))
),
chronicler_style = chroniclerStyle
)
)
)
else Vector()
@@ -142,8 +132,7 @@ case class NewRoundAction(
private def newRoundResult(
gs: GameState,
newRoundId: RoundId,
newDate: Date,
chroniclerStyle: String
newDate: Date
): ActionResultT = {
case class Changes(
changedProvince: ChangedProvinceC,
@@ -265,7 +254,7 @@ case class NewRoundAction(
)
}.toVector
val llmRequests = chronicleLlmRequests(newDate, gs, chroniclerStyle)
val llmRequests = chronicleLlmRequests(newDate, gs)
ActionResultC(
actionResultType = NewRoundActionResultType,
@@ -279,8 +268,7 @@ case class NewRoundAction(
newChronicleEntry = llmRequests.headOption.map { request =>
ChronicleEntry(
date = newDate,
generatedTextId = request.requestId,
chroniclerStyle = Some(chroniclerStyle)
generatedTextId = request.requestId
)
}
)
@@ -1,22 +1,9 @@
package net.eagle0.eagle.library.actions.impl.common
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.internal.game_state.GameState
case class ActionWithResultingState(
actionResult: ActionResult,
gameState: GameStateProto,
private val precomputedScalaState: Option[GameState] = None
) {
/**
* Scala GameState, either precomputed (when we already had it) or lazily converted from proto.
*
* When results come from Scala-based actions via withNewResultsScala, the Scala state is preserved to avoid
* unnecessary proto->Scala conversion. When loaded from disk/S3, conversion happens lazily on first access.
*/
lazy val scalaGameState: GameState =
precomputedScalaState.getOrElse(GameStateConverter.fromProto(gameState))
}
gameState: GameState
)
@@ -25,14 +25,11 @@ scala_library(
],
exports = [
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -1,8 +1,8 @@
package net.eagle0.eagle.library.actions.llm_prompt_generators
import net.eagle0.eagle.client_text.{ClientTextStore, TextGenerationResult}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.generated_text_request.AllianceOfferMessage
import net.eagle0.eagle.model.state.game_state.GameState
case class AllianceOfferMessagePromptGenerator(
allianceOfferMessage: AllianceOfferMessage,
@@ -10,8 +10,8 @@ import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus.{
DIPLOMACY_OFFER_STATUS_UNKNOWN,
DIPLOMACY_OFFER_STATUS_UNRESOLVED
}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.generated_text_request.AllianceOfferResolutionMessage
import net.eagle0.eagle.model.state.game_state.GameState
case class AllianceResolutionMessagePromptGenerator(
allianceOfferResolutionMessage: AllianceOfferResolutionMessage,
@@ -9,11 +9,11 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -26,11 +26,11 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -38,17 +38,11 @@ scala_library(
name = "battalion_descriptions",
srcs = ["BattalionDescriptions.scala"],
visibility = ["//visibility:public"],
exports = [
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
],
)
@@ -61,11 +55,11 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -78,12 +72,12 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -97,11 +91,11 @@ scala_library(
":hero_description_generator",
":hero_info_utilities",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -113,12 +107,12 @@ scala_library(
":generator_utilities",
":hero_description_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:event_for_chronicle_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -132,14 +126,14 @@ scala_library(
":llm_prompt_generator",
":map_description",
"//src/main/protobuf/net/eagle0/eagle/internal:event_for_chronicle_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/library/settings:chronicle_word_count",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero/backstory_version",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
],
)
@@ -152,12 +146,10 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
],
)
@@ -170,10 +162,10 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -181,8 +173,8 @@ scala_library(
name = "faction_info_utilities",
srcs = ["FactionInfoUtilities.scala"],
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/model/state/game_state",
],
)
@@ -191,11 +183,11 @@ scala_library(
srcs = ["GeneratorUtilities.scala"],
visibility = ["//visibility:public"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -208,10 +200,10 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -219,19 +211,15 @@ scala_library(
name = "hero_description_generator",
srcs = ["HeroDescriptionGenerator.scala"],
visibility = ["//visibility:public"],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/hero/backstory_version",
],
deps = [
":faction_info_utilities",
":generator_utilities",
":hero_info_utilities",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero/backstory_version",
],
)
@@ -245,11 +233,11 @@ scala_library(
":hero_description_generator",
":hero_info_utilities",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -258,8 +246,9 @@ scala_library(
srcs = ["HeroInfoUtilities.scala"],
visibility = ["//visibility:public"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -274,13 +263,13 @@ scala_library(
":hero_description_generator",
":llm_prompt_generator",
":quest_ended_generator_utilities",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero:profession",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -292,11 +281,11 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -309,11 +298,11 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -326,11 +315,11 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -368,11 +357,11 @@ scala_library(
":hero_description_generator",
":hero_info_utilities",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -385,11 +374,11 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -403,11 +392,11 @@ scala_library(
":hero_description_generator",
":hero_info_utilities",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -421,11 +410,11 @@ scala_library(
":hero_description_generator",
":hero_info_utilities",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -439,11 +428,11 @@ scala_library(
":hero_description_generator",
":hero_info_utilities",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -457,11 +446,11 @@ scala_library(
":hero_description_generator",
":hero_info_utilities",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -473,12 +462,12 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero:profession",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -490,13 +479,12 @@ scala_library(
":faction_info_utilities",
":hero_description_generator",
":hero_info_utilities",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_id_converter",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -510,11 +498,11 @@ scala_library(
":hero_description_generator",
":llm_prompt_generator",
":quest_ended_generator_utilities",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -528,11 +516,11 @@ scala_library(
":hero_description_generator",
":llm_prompt_generator",
":quest_ended_generator_utilities",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -545,11 +533,11 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -562,11 +550,11 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -578,11 +566,11 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -597,12 +585,12 @@ scala_library(
":hero_description_generator",
":hero_info_utilities",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/library/util:beast_utils",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -615,11 +603,11 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -632,11 +620,11 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -649,10 +637,10 @@ scala_library(
":generator_utilities",
":hero_description_generator",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -1,31 +1,20 @@
package net.eagle0.eagle.library.actions.llm_prompt_generators
import net.eagle0.eagle.common.battalion_type.BattalionTypeId as ProtoBattalionTypeId
import net.eagle0.eagle.common.battalion_type.BattalionTypeId
import net.eagle0.eagle.internal.battalion.Battalion
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.BattalionTypeId
import net.eagle0.eagle.views.battalion_view.BattalionView
object BattalionDescriptions {
def battalionTypeName(ofType: ProtoBattalionTypeId): String = ofType match {
case ProtoBattalionTypeId.LIGHT_INFANTRY => "light infantry"
case ProtoBattalionTypeId.HEAVY_INFANTRY => "heavy infantry"
case ProtoBattalionTypeId.LIGHT_CAVALRY => "dragoons"
case ProtoBattalionTypeId.HEAVY_CAVALRY => "knights"
case ProtoBattalionTypeId.LONGBOWMEN => "longbowmen"
case ProtoBattalionTypeId.UNDEAD => "zombies"
case ProtoBattalionTypeId.Unrecognized(_) =>
throw new EagleInternalException("Unknown battalion type")
}
def battalionTypeName(ofType: BattalionTypeId): String = ofType match {
case BattalionTypeId.LightInfantry => "light infantry"
case BattalionTypeId.HeavyInfantry => "heavy infantry"
case BattalionTypeId.LightCavalry => "dragoons"
case BattalionTypeId.HeavyCavalry => "knights"
case BattalionTypeId.Longbowmen => "longbowmen"
case BattalionTypeId.Undead => "zombies"
case BattalionTypeId.LIGHT_INFANTRY => "light infantry"
case BattalionTypeId.HEAVY_INFANTRY => "heavy infantry"
case BattalionTypeId.LIGHT_CAVALRY => "dragoons"
case BattalionTypeId.HEAVY_CAVALRY => "knights"
case BattalionTypeId.LONGBOWMEN => "longbowmen"
case BattalionTypeId.UNDEAD => "zombies"
case BattalionTypeId.Unrecognized(_) =>
throw new EagleInternalException("Unknown battalion type")
}
def descriptionForBattalion(bv: BattalionView) =
@@ -33,7 +22,4 @@ object BattalionDescriptions {
def descriptionForBattalion(batt: Battalion) =
s"a battalion of ${batt.size} ${battalionTypeName(batt.`type`)} known as \"${batt.name}\""
def descriptionForBattalion(batt: BattalionT): String =
s"a battalion of ${batt.size} ${battalionTypeName(batt.typeId)} known as \"${batt.name}\""
}
@@ -1,8 +1,8 @@
package net.eagle0.eagle.library.actions.llm_prompt_generators
import net.eagle0.eagle.client_text.{ClientTextStore, TextGenerationResult}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.generated_text_request.BreakAllianceMessage
import net.eagle0.eagle.model.state.game_state.GameState
case class BreakAllianceMessagePromptGenerator(
breakAllianceMessage: BreakAllianceMessage,

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