mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 04:05:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62a70d7042 |
@@ -36,7 +36,3 @@ common --java_language_version=17
|
||||
common --java_runtime_version=remotejdk_17
|
||||
common --tool_java_language_version=17
|
||||
common --tool_java_runtime_version=remotejdk_17
|
||||
|
||||
# Workspace status for build stamping (git commit, timestamp)
|
||||
common --workspace_status_command=tools/workspace_status.sh
|
||||
common --stamp
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
name: Deploy OAuth Relay Worker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'cloudflare/oauth-relay/**'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy to Cloudflare Workers
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy Worker
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
workingDirectory: cloudflare/oauth-relay
|
||||
@@ -11,8 +11,6 @@ on:
|
||||
- 'src/main/resources/**'
|
||||
- 'ci/BUILD.bazel'
|
||||
- 'MODULE.bazel'
|
||||
- 'docker-compose.prod.yml'
|
||||
- 'nginx/**'
|
||||
- '.github/workflows/docker_build.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -350,94 +348,20 @@ jobs:
|
||||
echo "Copying to :latest tag"
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
|
||||
|
||||
build-jfr-sidecar:
|
||||
runs-on: self-hosted
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-jfr-sidecar.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build JFR Sidecar Docker image
|
||||
id: build-jfr-sidecar
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Build JFR sidecar image (Go binary has explicit goos/goarch in BUILD.bazel)
|
||||
bazel build //ci:jfr_sidecar_image
|
||||
|
||||
# Save the resolved path before any other bazel command changes bazel-bin symlink
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/jfr_sidecar_image)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
mkdir -p .docker
|
||||
cp ~/.docker/config.json .docker/
|
||||
|
||||
- name: Push JFR Sidecar image to DO registry
|
||||
id: push-jfr-sidecar
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DOCKER_CONFIG: ${{ github.workspace }}/.docker
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
JFR_IMAGE="${{ steps.build-jfr-sidecar.outputs.image_path }}"
|
||||
echo "Using JFR Sidecar image: $JFR_IMAGE"
|
||||
|
||||
if [ -z "$JFR_IMAGE" ] || [ ! -d "$JFR_IMAGE" ]; then
|
||||
echo "ERROR: JFR Sidecar image not found at: $JFR_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the push target to get crane in runfiles
|
||||
bazel build //ci:jfr_sidecar_push
|
||||
|
||||
# Use crane directly for push
|
||||
CRANE="bazel-bin/ci/push_jfr_sidecar_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
|
||||
echo "Using crane: $CRANE"
|
||||
|
||||
# Push with SHA tag
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
IMAGE_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
|
||||
echo "Pushing JFR sidecar image: $IMAGE_TAG"
|
||||
$CRANE push "$JFR_IMAGE" "$IMAGE_TAG"
|
||||
|
||||
# Output the full image tag for deploy step
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also update :latest for convenience
|
||||
echo "Copying to :latest tag"
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-eagle, build-shardok, build-admin, build-jfr-sidecar]
|
||||
needs: [build-eagle, build-shardok, build-admin]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
env:
|
||||
EAGLE_IMAGE: ${{ needs.build-eagle.outputs.image_tag }}
|
||||
SHARDOK_IMAGE: ${{ needs.build-shardok.outputs.image_tag }}
|
||||
ADMIN_IMAGE: ${{ needs.build-admin.outputs.image_tag }}
|
||||
JFR_SIDECAR_IMAGE: ${{ needs.build-jfr-sidecar.outputs.image_tag }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
|
||||
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
|
||||
DO_SPACES_ACCESS_KEY: ${{ secrets.DO_SPACES_ACCESS_KEY }}
|
||||
DO_SPACES_SECRET_KEY: ${{ secrets.DO_SPACES_SECRET_KEY }}
|
||||
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
|
||||
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
|
||||
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -458,7 +382,7 @@ jobs:
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
script_stop: true
|
||||
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,JFR_SIDECAR_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY,JWT_PRIVATE_KEY,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET
|
||||
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY
|
||||
script: |
|
||||
set -x
|
||||
cd /opt/eagle0
|
||||
@@ -466,18 +390,11 @@ jobs:
|
||||
# Write env vars to .env file for docker-compose
|
||||
rm -f .env 2>/dev/null || true
|
||||
cat > .env << EOF
|
||||
EAGLE_IMAGE=${EAGLE_IMAGE}
|
||||
SHARDOK_IMAGE=${SHARDOK_IMAGE}
|
||||
ADMIN_IMAGE=${ADMIN_IMAGE}
|
||||
JFR_SIDECAR_IMAGE=${JFR_SIDECAR_IMAGE}
|
||||
OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
GPT_MODEL_NAME=${GPT_MODEL_NAME:-gpt-4o}
|
||||
EAGLE_ENABLE_S3=${EAGLE_ENABLE_S3:-false}
|
||||
DO_SPACES_ACCESS_KEY=${DO_SPACES_ACCESS_KEY:-}
|
||||
DO_SPACES_SECRET_KEY=${DO_SPACES_SECRET_KEY:-}
|
||||
JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}
|
||||
DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}
|
||||
DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}
|
||||
EOF
|
||||
chmod 600 .env
|
||||
|
||||
@@ -485,7 +402,7 @@ jobs:
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
|
||||
# Use exact image tags passed from build jobs (no :latest fallback)
|
||||
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE, $ADMIN_IMAGE, $JFR_SIDECAR_IMAGE"
|
||||
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE, $ADMIN_IMAGE"
|
||||
|
||||
# Use crane to pull images (handles OCI format correctly) then load into Docker
|
||||
# This avoids digest mismatch from DO registry's OCI->Docker format conversion
|
||||
@@ -512,12 +429,6 @@ jobs:
|
||||
docker load -i admin.tar
|
||||
rm admin.tar
|
||||
|
||||
echo "Pulling JFR Sidecar image with crane..."
|
||||
./crane pull "${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar || { echo "ERROR: Failed to pull jfr-sidecar image"; exit 1; }
|
||||
echo "Loading JFR Sidecar image into Docker..."
|
||||
docker load -i jfr-sidecar.tar
|
||||
rm jfr-sidecar.tar
|
||||
|
||||
rm ./crane
|
||||
|
||||
# Also pull other compose images
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Generated
+3
-3
@@ -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
@@ -51,17 +51,13 @@ oci_image(
|
||||
base = "@eclipse_temurin_17_linux_amd64",
|
||||
entrypoint = [
|
||||
"java",
|
||||
"-Xmx2g",
|
||||
"-Xmx4g",
|
||||
"-XX:+UseG1GC",
|
||||
# JFR profiling support
|
||||
"-XX:+UnlockDiagnosticVMOptions",
|
||||
"-XX:+DebugNonSafepoints", # Required for JFR to see through inlined methods
|
||||
"-XX:FlightRecorderOptions=stackdepth=256",
|
||||
"-jar",
|
||||
"/app/eagle_server_deploy.jar",
|
||||
],
|
||||
env = {
|
||||
"JAVA_OPTS": "-Xmx2g -XX:+UseG1GC",
|
||||
"JAVA_OPTS": "-Xmx4g -XX:+UseG1GC",
|
||||
},
|
||||
exposed_ports = ["40032/tcp"],
|
||||
tars = [
|
||||
@@ -195,45 +191,3 @@ oci_push(
|
||||
image = ":admin_server_image",
|
||||
repository = "registry.digitalocean.com/eagle0/admin-server",
|
||||
)
|
||||
|
||||
#
|
||||
# JFR Sidecar Docker Image (Go + JDK for jcmd)
|
||||
#
|
||||
# This sidecar runs with shared PID namespace to access the Eagle JVM.
|
||||
# Build: bazel build //ci:jfr_sidecar_image
|
||||
# Load: bazel run //ci:jfr_sidecar_load
|
||||
# Push: bazel run //ci:jfr_sidecar_push
|
||||
#
|
||||
|
||||
# Package the Go JFR server binary
|
||||
pkg_tar(
|
||||
name = "jfr_sidecar_binary_layer",
|
||||
srcs = ["//src/main/go/net/eagle0/jfr_server:jfr_server_linux_amd64"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "jfr_sidecar_image",
|
||||
# Use JDK base image - we need jcmd to dump JFR recordings
|
||||
base = "@eclipse_temurin_17_linux_amd64",
|
||||
entrypoint = ["/app/jfr_server_linux_amd64"],
|
||||
exposed_ports = ["8081/tcp"],
|
||||
tars = [
|
||||
":jfr_sidecar_binary_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally: bazel run //ci:jfr_sidecar_load
|
||||
oci_load(
|
||||
name = "jfr_sidecar_load",
|
||||
image = ":jfr_sidecar_image",
|
||||
repo_tags = ["eagle0/jfr-sidecar:latest"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry
|
||||
oci_push(
|
||||
name = "jfr_sidecar_push",
|
||||
image = ":jfr_sidecar_image",
|
||||
repository = "registry.digitalocean.com/eagle0/jfr-sidecar",
|
||||
)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
node_modules/
|
||||
@@ -1,46 +0,0 @@
|
||||
# OAuth Relay Worker
|
||||
|
||||
Cloudflare Worker that relays OAuth callbacks to the eagle0:// custom URL scheme.
|
||||
|
||||
## Why?
|
||||
|
||||
Discord (and some other OAuth providers) don't support custom URL schemes as redirect URIs. This worker acts as a relay:
|
||||
|
||||
1. Discord redirects to `https://eagle0-oauth-relay.<account>.workers.dev/oauth/callback?code=xxx&state=yyy`
|
||||
2. Worker responds with 302 redirect to `eagle0://auth/callback?code=xxx&state=yyy`
|
||||
3. OS opens the Eagle0 app via deep link
|
||||
|
||||
## Deploy
|
||||
|
||||
1. Install wrangler: `npm install -g wrangler`
|
||||
2. Login: `wrangler login`
|
||||
3. Deploy: `wrangler deploy`
|
||||
|
||||
The worker will be available at `https://eagle0-oauth-relay.<your-account>.workers.dev`
|
||||
|
||||
## Test Locally
|
||||
|
||||
```bash
|
||||
wrangler dev
|
||||
# Then in another terminal:
|
||||
curl -I "http://localhost:8787/oauth/callback?code=test&state=abc"
|
||||
```
|
||||
|
||||
## Test Production
|
||||
|
||||
```bash
|
||||
curl -I "https://eagle0-oauth-relay.<your-account>.workers.dev/oauth/callback?code=test&state=abc"
|
||||
```
|
||||
|
||||
Should return:
|
||||
```
|
||||
HTTP/2 302
|
||||
location: eagle0://auth/callback?code=test&state=abc
|
||||
```
|
||||
|
||||
## OAuth Provider Configuration
|
||||
|
||||
In Discord Developer Portal / Google Cloud Console, set the redirect URI to:
|
||||
```
|
||||
https://eagle0-oauth-relay.<your-account>.workers.dev/oauth/callback
|
||||
```
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* OAuth Relay Worker
|
||||
*
|
||||
* Receives OAuth callbacks from providers (Discord, Google) and redirects
|
||||
* to the eagle0:// custom URL scheme for the native app to handle.
|
||||
*
|
||||
* Input: GET /oauth/callback?code=xxx&state=yyy
|
||||
* Output: 302 Redirect to eagle0://auth/callback?code=xxx&state=yyy
|
||||
*/
|
||||
|
||||
export default {
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Only handle /oauth/callback path
|
||||
if (url.pathname !== '/oauth/callback') {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
// Build the deep link URL
|
||||
const deepLink = new URL('eagle0://auth/callback');
|
||||
|
||||
// Forward all query parameters
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
const error = url.searchParams.get('error');
|
||||
const errorDescription = url.searchParams.get('error_description');
|
||||
|
||||
if (code) deepLink.searchParams.set('code', code);
|
||||
if (state) deepLink.searchParams.set('state', state);
|
||||
if (error) deepLink.searchParams.set('error', error);
|
||||
if (errorDescription) deepLink.searchParams.set('error_description', errorDescription);
|
||||
|
||||
console.log(`OAuth relay: redirecting to ${deepLink.toString()}`);
|
||||
|
||||
return Response.redirect(deepLink.toString(), 302);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
name = "eagle0-oauth-relay"
|
||||
main = "worker.js"
|
||||
compatibility_date = "2024-01-01"
|
||||
workers_dev = true
|
||||
@@ -24,13 +24,8 @@ services:
|
||||
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
|
||||
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
|
||||
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
|
||||
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
|
||||
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
|
||||
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
|
||||
volumes:
|
||||
- ./saves:/app/saves
|
||||
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
|
||||
- jvm-tmp:/tmp # Shared with jfr-sidecar for JVM attach socket files
|
||||
depends_on:
|
||||
- shardok
|
||||
restart: unless-stopped
|
||||
@@ -49,8 +44,6 @@ services:
|
||||
shardok:
|
||||
image: ${SHARDOK_IMAGE:-registry.digitalocean.com/eagle0/shardok-server:latest}
|
||||
container_name: shardok-server
|
||||
mem_limit: 1g
|
||||
memswap_limit: 1g # Prevent swap, OOM-kill cleanly instead
|
||||
ports:
|
||||
- "40042:40042"
|
||||
- "40052:40052"
|
||||
@@ -97,15 +90,12 @@ services:
|
||||
command:
|
||||
- "--eagle-addr"
|
||||
- "eagle:40032"
|
||||
- "--jfr-sidecar-addr"
|
||||
- "jfr-sidecar:8081"
|
||||
- "--http-port"
|
||||
- "8080"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- eagle
|
||||
- jfr-sidecar
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
@@ -119,28 +109,6 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
jfr-sidecar:
|
||||
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
|
||||
container_name: jfr-sidecar
|
||||
# Share PID namespace with Eagle to access its JVM via jcmd
|
||||
pid: "service:eagle"
|
||||
volumes:
|
||||
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
|
||||
depends_on:
|
||||
- eagle
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "2"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://localhost:8081/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
container_name: certbot
|
||||
@@ -148,7 +116,3 @@ services:
|
||||
- ./certbot/conf:/etc/letsencrypt
|
||||
- ./certbot/www:/var/www/certbot
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
|
||||
volumes:
|
||||
jvm-tmp:
|
||||
# Shared /tmp for JVM attach socket files between Eagle and jfr-sidecar
|
||||
|
||||
@@ -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?
|
||||
@@ -1,350 +0,0 @@
|
||||
# OAuth Implementation: Next Steps and Design
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The OAuth implementation is functional but has several gaps that need addressing before it's production-ready. This document outlines the known issues, proposes a comprehensive user identity model, and provides a prioritized implementation plan.
|
||||
|
||||
## Current State (Updated January 2026)
|
||||
|
||||
### What Works ✅
|
||||
- Discord OAuth flow (server-mediated polling)
|
||||
- Google OAuth flow
|
||||
- JWT token generation and validation
|
||||
- User creation and display name setting
|
||||
- Auto-login with stored tokens
|
||||
- Basic game creation and play with OAuth users
|
||||
- Headshot fetching via public CDN (no auth required)
|
||||
- Logout button in lobby (preserves tokens for quick reconnect)
|
||||
- Environment (prod/qa) and user display in lobby
|
||||
- Game identity with userName = displayName (PR #4964 merged)
|
||||
|
||||
### Known Issues
|
||||
|
||||
#### 1. Game Identity Model Fragility (Deferred)
|
||||
**Status**: Accepted for now. PR #4964 merged with `userName = displayName`.
|
||||
|
||||
**Current behavior**:
|
||||
- Games store `userNameToFactionId: Map[String, Int]`
|
||||
- For JWT users, this maps displayName → factionId
|
||||
- displayName is technically mutable (users could change it)
|
||||
- No migration path when displayName changes
|
||||
|
||||
**Why this is acceptable**:
|
||||
1. We don't currently have a "change display name" feature
|
||||
2. The alternative (using userId) requires more extensive changes
|
||||
3. Can migrate to userId-based identity later if needed
|
||||
|
||||
#### 2. In-Game Headshot Fetching ✅ FIXED
|
||||
**Solution**: Made the `eagle0-headshots` S3 bucket public and enabled CDN.
|
||||
- Client now fetches directly from `https://eagle0-headshots.sfo3.cdn.digitaloceanspaces.com/`
|
||||
- No authentication required
|
||||
- Works for both OAuth and Basic Auth users
|
||||
- Simpler architecture, no dependency on home Mac server
|
||||
|
||||
#### 3. Logout from Lobby ✅ FIXED
|
||||
**Solution**: Added logout button to lobby UI (PR #4967).
|
||||
- Button disconnects from server and returns to connection screen
|
||||
- Intentionally does NOT clear OAuth tokens
|
||||
- Allows quick reconnect with same account without full OAuth flow
|
||||
|
||||
#### 4. Display Name Uniqueness Not Enforced (Medium) - OPEN
|
||||
**Problem**: User was able to set displayName "nolen" when that name was already taken.
|
||||
|
||||
**Root cause**: Unknown - needs investigation. Either:
|
||||
- The uniqueness check is buggy
|
||||
- The displayNameIndex wasn't populated correctly during user creation
|
||||
- Race condition during concurrent registrations
|
||||
|
||||
#### 5. Admin Server Crashes ✅ FIXED
|
||||
**Solution**: PR #4964 sets `userName = displayName` for JWT users.
|
||||
|
||||
#### 6. Intermittent "Expired" Errors During Login (Medium) - INVESTIGATING
|
||||
**Problem**: Users occasionally get "OAuth session expired" errors even when server logs show the callback succeeded.
|
||||
|
||||
**Status**: Added diagnostic logging in PR #4974 to trace:
|
||||
- State creation in `getAuthUrl`
|
||||
- State lookup in `handleCallback`
|
||||
- Result lookup in `checkStatus`
|
||||
|
||||
**Possible causes**:
|
||||
- State mismatch between client and server
|
||||
- Race condition in polling
|
||||
- Cleanup running at wrong time
|
||||
|
||||
#### 7. Token Expiry Field Bug ✅ FIXED
|
||||
**Problem**: `CheckOAuthStatusResponse.expiresAt` was returning refresh token expiry (30 days) instead of access token expiry (7 days).
|
||||
|
||||
**Solution**: Fixed in PR #4974 to calculate correct access token expiry.
|
||||
|
||||
---
|
||||
|
||||
## Proposed User Identity Model
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Stable Internal Identity**: `userId` (UUID) is the only key used for persistent associations
|
||||
2. **Display Name is Cosmetic**: Can change without breaking game associations
|
||||
3. **Backwards Compatibility**: Basic Auth continues to work for local development
|
||||
4. **Multi-Provider Support**: Users can link Discord, Google, and future providers
|
||||
5. **Avatar Flexibility**: Use OAuth avatar by default, support custom uploads later
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
User {
|
||||
userId: String (UUID) // Primary key, immutable, used for all internal references
|
||||
displayName: String // Unique, user-visible, mutable with migration
|
||||
displayNameLower: String // Case-insensitive uniqueness
|
||||
email: String // Primary email for account recovery/linking
|
||||
avatarUrl: String // Current avatar URL
|
||||
avatarData: bytes // Cached avatar for offline/fast access (future)
|
||||
oauthIdentities: [OAuthIdentity]
|
||||
createdAt: Timestamp
|
||||
lastLoginAt: Timestamp
|
||||
isAdmin: Boolean
|
||||
}
|
||||
|
||||
OAuthIdentity {
|
||||
provider: String // "discord", "google", etc.
|
||||
providerUserId: String // Provider's user ID
|
||||
providerEmail: String // Email from this provider
|
||||
avatarUrl: String // Avatar from this provider
|
||||
linkedAt: Timestamp
|
||||
}
|
||||
```
|
||||
|
||||
### Identity Resolution Strategy
|
||||
|
||||
The key question: **What should `AuthorizationUtils.userName` return?**
|
||||
|
||||
#### Option A: userName = displayName (Current PR #4964)
|
||||
- **Pro**: Human-readable in logs, game saves, debugging
|
||||
- **Con**: Breaks if displayName changes
|
||||
- **Migration**: None needed now, complex later
|
||||
|
||||
#### Option B: userName = userId (Recommended)
|
||||
- **Pro**: Stable identity, displayName changes are safe
|
||||
- **Con**: UUIDs in logs are ugly, need display name lookup for UI
|
||||
- **Migration**: Cleaner long-term, but breaking change for any existing OAuth games
|
||||
|
||||
#### Option C: Hybrid with Migration Support
|
||||
- **userName** = userId for new games
|
||||
- **Legacy lookup** for old games by displayName
|
||||
- **Display layer** resolves userId → displayName for UI
|
||||
|
||||
**Recommendation**: Option B with a display name resolution layer. The ugliness in logs is acceptable for the stability it provides. Implement a `UserService.resolveDisplayName(identifier: String): String` that returns displayName for UUIDs or the identifier itself for legacy usernames.
|
||||
|
||||
### Account Linking Strategy
|
||||
|
||||
#### Automatic Linking (Future)
|
||||
When a user logs in with a new OAuth provider:
|
||||
1. Check if the provider email matches an existing user's email
|
||||
2. If match found, prompt: "An account exists with this email. Link accounts?"
|
||||
3. If confirmed, add new OAuthIdentity to existing user
|
||||
4. If declined, create separate account (different email required)
|
||||
|
||||
#### Manual Linking (MVP)
|
||||
1. User logs in with primary account
|
||||
2. User goes to Settings → Linked Accounts
|
||||
3. User clicks "Link Discord" or "Link Google"
|
||||
4. OAuth flow adds new identity to current user
|
||||
|
||||
### Avatar/Headshot Strategy
|
||||
|
||||
#### Phase 1: OAuth Avatars (MVP)
|
||||
- Store `avatarUrl` from OAuth provider during login
|
||||
- Server proxies avatar requests to avoid CORS issues
|
||||
- Cache avatars locally with TTL
|
||||
|
||||
#### Phase 2: Avatar Caching
|
||||
- Download avatar to local storage on login
|
||||
- Serve from local storage for reliability
|
||||
- Refresh periodically or on login
|
||||
|
||||
#### Phase 3: Custom Avatars (Future)
|
||||
- Allow users to upload custom avatar
|
||||
- Store in S3/DO Spaces
|
||||
- Custom avatar overrides OAuth avatar
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Stabilization ✅ COMPLETE
|
||||
|
||||
#### 1.1 Fix Display Name Uniqueness Bug - OPEN
|
||||
- [ ] Investigate why "nolen" was allowed when it existed
|
||||
- [ ] Add logging to `setDisplayName` to trace the issue
|
||||
- [ ] Ensure `displayNameIndex` is correctly maintained
|
||||
- [ ] Add unit tests for uniqueness enforcement
|
||||
|
||||
#### 1.2 Add Logout Button to Lobby ✅ DONE
|
||||
- [x] Add "Logout" button to lobby UI
|
||||
- [x] Disconnect from server
|
||||
- [x] Navigate to connection screen
|
||||
- [x] Preserve OAuth tokens for quick reconnect (intentional change from original plan)
|
||||
|
||||
#### 1.3 Merge PR #4964 (userName = displayName) ✅ DONE
|
||||
- [x] Merged - games work with OAuth users
|
||||
- [x] Documented limitation (games break if displayName changes)
|
||||
|
||||
#### 1.4 Fix Headshot Fetching ✅ DONE
|
||||
- [x] Made eagle0-headshots bucket public
|
||||
- [x] Client fetches directly from CDN
|
||||
- [x] No authentication required
|
||||
|
||||
#### 1.5 Add Lobby Status Display ✅ DONE
|
||||
- [x] Show environment (prod/qa) in lobby
|
||||
- [x] Show current user in lobby (OAuth displayName or classic username)
|
||||
|
||||
### Phase 2: Remaining Work (Priority Order)
|
||||
|
||||
#### 2.1 Diagnose Intermittent "Expired" Errors - IN PROGRESS
|
||||
- [x] Add diagnostic logging (PR #4974)
|
||||
- [ ] Deploy and reproduce the issue
|
||||
- [ ] Analyze logs to identify root cause
|
||||
- [ ] Implement fix based on findings
|
||||
|
||||
#### 2.2 Fix Display Name Uniqueness
|
||||
- [ ] Investigate UserService.setDisplayName logic
|
||||
- [ ] Check displayNameIndex population
|
||||
- [ ] Add logging to trace the issue
|
||||
- [ ] Fix the bug and add tests
|
||||
|
||||
#### 2.3 Wire Up Lobby UI in Unity
|
||||
- [ ] Connect `lobbyEnvironmentText` to TextMeshProUGUI in scene
|
||||
- [ ] Connect `lobbyUserText` to TextMeshProUGUI in scene
|
||||
|
||||
#### 2.4 Implement Token Refresh During Gameplay
|
||||
- [ ] Implement `RefreshToken` RPC on server (currently throws UNIMPLEMENTED)
|
||||
- [ ] Store refresh tokens server-side for validation
|
||||
- [ ] Add proactive refresh in client before token expires
|
||||
- [ ] Handle refresh during reconnection attempts
|
||||
|
||||
### Phase 3: Nice-to-Haves (Future)
|
||||
|
||||
#### 3.1 Proactive Token Refresh
|
||||
- [ ] Monitor token expiry in client
|
||||
- [ ] Refresh automatically when < 5 minutes remaining
|
||||
- [ ] Update TokenStorage with new access token
|
||||
|
||||
#### 3.2 Better Error Messages
|
||||
- [ ] Distinguish between network errors and auth errors
|
||||
- [ ] Show user-friendly messages for OAuth failures
|
||||
- [ ] Add retry suggestions
|
||||
|
||||
#### 3.3 Session Persistence Across Server Restarts
|
||||
- [ ] Move pendingOAuth from in-memory TrieMap to Redis/database
|
||||
- [ ] Move completedOAuth to Redis with TTL
|
||||
- [ ] Server can restart without breaking in-flight OAuth flows
|
||||
|
||||
#### 3.4 Migrate to userId-based Game Identity (Deferred)
|
||||
- [ ] Change `AuthorizationUtils.userName` to return `userId` for JWT users
|
||||
- [ ] Add `UserService.resolveDisplayName(id: String): String` for UI display
|
||||
- [ ] Update game UI to resolve userIds to displayNames
|
||||
- [ ] Existing Basic Auth games continue to work (userName is literal)
|
||||
|
||||
#### 3.5 Display Name Change Support (Requires 3.4)
|
||||
- [ ] Add `ChangeDisplayName` RPC
|
||||
- [ ] Validate new name is unique
|
||||
- [ ] Update user record
|
||||
- [ ] No game migration needed (games use userId)
|
||||
|
||||
### Phase 3: Multi-Provider Support (Future)
|
||||
|
||||
#### 3.1 Account Linking UI
|
||||
- [ ] Add Settings page with "Linked Accounts" section
|
||||
- [ ] Show currently linked providers
|
||||
- [ ] "Link Another Account" button triggers OAuth flow
|
||||
- [ ] `LinkOAuthProvider` RPC adds identity to current user
|
||||
|
||||
#### 3.2 Login Provider Selection
|
||||
- [ ] If user has multiple providers, any can be used to login
|
||||
- [ ] All resolve to same userId
|
||||
- [ ] Session shows which provider was used
|
||||
|
||||
#### 3.3 Account Merging (Complex)
|
||||
- [ ] Handle case where user created separate accounts
|
||||
- [ ] Merge game history, stats, etc.
|
||||
- [ ] Delete duplicate user record
|
||||
- [ ] This is complex - may defer or not implement
|
||||
|
||||
### Phase 4: Enhanced Avatars (Future)
|
||||
|
||||
#### 4.1 Avatar Caching
|
||||
- [ ] Download avatars to S3/DO Spaces on login
|
||||
- [ ] Serve from our CDN
|
||||
- [ ] Refresh on login if changed
|
||||
|
||||
#### 4.2 Custom Avatar Upload
|
||||
- [ ] Upload endpoint with size/format validation
|
||||
- [ ] Store in S3/DO Spaces
|
||||
- [ ] Custom avatar overrides OAuth avatar
|
||||
|
||||
---
|
||||
|
||||
## Technical Debt to Address
|
||||
|
||||
1. **Context Propagation in Futures**: PR #4960 fixed `setDisplayName` and `getCurrentUser`, but audit all `Future` blocks that access `AuthorizationUtils`
|
||||
|
||||
2. **Dual Auth Support**: The system supports both Basic Auth and JWT. Consider:
|
||||
- Should Basic Auth be deprecated for production?
|
||||
- Should it remain for local development only?
|
||||
- How do Basic Auth users interact with OAuth users in the same game?
|
||||
|
||||
3. **Token Refresh**: `RefreshToken` RPC throws UNIMPLEMENTED. Need to:
|
||||
- Implement refresh token storage and validation
|
||||
- Handle token refresh in client
|
||||
- Consider refresh token rotation for security
|
||||
|
||||
4. **Session Management**: No server-side session tracking. Consider:
|
||||
- Track active sessions per user
|
||||
- Allow "logout all devices"
|
||||
- Detect concurrent logins
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **What happens when a Basic Auth user and OAuth user have the same name?**
|
||||
- Currently possible - Basic Auth doesn't check UserService
|
||||
- Could cause confusion in games
|
||||
- Solution: Require OAuth for multiplayer? Or namespace Basic Auth names?
|
||||
|
||||
2. **Should displayName changes be allowed?**
|
||||
- With userId-based identity, it's safe
|
||||
- But could cause confusion ("who is this new player?")
|
||||
- Consider: rate limit changes, show "formerly known as" temporarily
|
||||
|
||||
3. **How to handle OAuth provider account deletion?**
|
||||
- User deletes their Discord account
|
||||
- Their Eagle0 account still exists
|
||||
- They can't login unless they linked another provider
|
||||
- Solution: Encourage linking multiple providers, or add email/password fallback
|
||||
|
||||
4. **Admin impersonation with OAuth**
|
||||
- Currently works via X-Impersonate-User header
|
||||
- Should this use userId or displayName?
|
||||
- Probably userId for stability
|
||||
|
||||
---
|
||||
|
||||
## Appendix: File Locations
|
||||
|
||||
### Server (Scala)
|
||||
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD
|
||||
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - Token generation/validation
|
||||
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala` - Auth middleware
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala` - Context accessors
|
||||
|
||||
### Client (C#)
|
||||
- `Assets/Auth/AuthClient.cs` - gRPC client for Auth service
|
||||
- `Assets/Auth/OAuthManager.cs` - OAuth flow orchestration
|
||||
- `Assets/Auth/TokenStorage.cs` - Persistent token storage
|
||||
- `Assets/Auth/JwtAuthInterceptor.cs` - Attaches JWT to requests
|
||||
|
||||
### Protos
|
||||
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth service definition
|
||||
- `src/main/protobuf/net/eagle0/eagle/internal/user/user.proto` - User data model
|
||||
@@ -81,29 +81,6 @@ http {
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# gRPC proxy for Auth service
|
||||
location /net.eagle0.eagle.api.auth.Auth {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# gRPC proxy
|
||||
grpc_pass grpc://eagle_grpc;
|
||||
|
||||
# Timeouts
|
||||
grpc_read_timeout 30s;
|
||||
grpc_send_timeout 30s;
|
||||
|
||||
# Error handling
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# OAuth callback endpoint (proxied to Eagle's HTTP handler)
|
||||
location /oauth/callback {
|
||||
proxy_pass http://eagle:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
|
||||
@@ -25,8 +25,7 @@ auto CalculateTimeBudget(
|
||||
const PlayerId playerId,
|
||||
const GameSettingsSPtr &settings,
|
||||
const GameStateW &state,
|
||||
const size_t numCommands,
|
||||
const bool isAllAiBattle) -> AITimeBudget {
|
||||
const size_t numCommands) -> AITimeBudget {
|
||||
const auto settingsGetter = settings->GetGetter();
|
||||
const auto castleCoords = AllCastleCoords(state->hex_map());
|
||||
|
||||
@@ -35,10 +34,8 @@ auto CalculateTimeBudget(
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP;
|
||||
|
||||
// Get maximum budget cap from settings (in seconds)
|
||||
// For all-AI battles, use the faster budget limit
|
||||
const double maxBudgetSeconds =
|
||||
isAllAiBattle ? settingsGetter.Backing().all_ai_battle_time_budget_maximum()
|
||||
: settingsGetter.Backing().lookahead_time_budget_maximum_seconds();
|
||||
settingsGetter.Backing().lookahead_time_budget_maximum_seconds();
|
||||
const double maxBudgetMs = maxBudgetSeconds * 1000.0;
|
||||
|
||||
// During setup, use the setup-specific time budget
|
||||
|
||||
@@ -38,13 +38,11 @@ struct AITimeBudget {
|
||||
// Calculate time budget based on proximity to enemies and castles
|
||||
// Time budget is calculated dynamically based on number of available commands:
|
||||
// budget = msPerCommand × numCommands (clamped to 200-5000ms)
|
||||
// If isAllAiBattle is true, uses allAiBattleTimeBudgetMaximum instead of the normal maximum.
|
||||
auto CalculateTimeBudget(
|
||||
PlayerId playerId,
|
||||
const GameSettingsSPtr &settings,
|
||||
const GameStateW &state,
|
||||
size_t numCommands,
|
||||
bool isAllAiBattle) -> AITimeBudget;
|
||||
size_t numCommands) -> AITimeBudget;
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ auto RoundsRemaining(const GameSettingsSPtr &settings, const GameStateView &gsv)
|
||||
ShardokAIClient::ShardokAIClient(
|
||||
const PlayerId playerId,
|
||||
const bool isDefender,
|
||||
const bool isAllAiBattle,
|
||||
const HexMap *hexMap,
|
||||
const SettingsGetter &settings,
|
||||
const AIAlgorithmType aiAlgorithmType,
|
||||
@@ -61,7 +60,6 @@ ShardokAIClient::ShardokAIClient(
|
||||
const mcts::MCTSConfig &mctsConfig)
|
||||
: playerId(playerId),
|
||||
isDefender(isDefender),
|
||||
isAllAiBattle(isAllAiBattle),
|
||||
aiAlgorithmType(aiAlgorithmType),
|
||||
scoringCalculatorType(scoringCalculatorType),
|
||||
alCache(std::make_unique<AttackLocationsCache>(hexMap, settings)),
|
||||
@@ -137,8 +135,7 @@ auto ShardokAIClient::StandardChooseCommandIndex(
|
||||
const auto commandCount = guessedCommands->size();
|
||||
|
||||
// Calculate time budget based on game situation using new dynamic per-command settings
|
||||
const auto timeBudget =
|
||||
CalculateTimeBudget(playerId, settings, guessedState, commandCount, isAllAiBattle);
|
||||
const auto timeBudget = CalculateTimeBudget(playerId, settings, guessedState, commandCount);
|
||||
|
||||
// Configure MCTS based on proximity to enemy
|
||||
// When far from enemy: use AVERAGING with maxPlayerFlips=0 (single-player lookahead)
|
||||
|
||||
@@ -41,7 +41,6 @@ class ShardokAIClient {
|
||||
private:
|
||||
const PlayerId playerId;
|
||||
const bool isDefender;
|
||||
const bool isAllAiBattle; // Whether this battle has only AI players (for faster time budgets)
|
||||
const AIAlgorithmType aiAlgorithmType;
|
||||
const ScoringCalculatorType scoringCalculatorType;
|
||||
|
||||
@@ -70,7 +69,6 @@ public:
|
||||
explicit ShardokAIClient(
|
||||
PlayerId playerId,
|
||||
bool isDefender,
|
||||
bool isAllAiBattle,
|
||||
const HexMap* hexMap,
|
||||
const SettingsGetter& settings,
|
||||
AIAlgorithmType aiAlgorithmType,
|
||||
|
||||
@@ -24,6 +24,9 @@ using std::scoped_lock;
|
||||
using std::string;
|
||||
using std::unique_lock;
|
||||
using std::weak_ptr;
|
||||
using std::chrono::duration;
|
||||
|
||||
static constexpr duration kWaitForUpdatesDuration = std::chrono::milliseconds(5000);
|
||||
|
||||
using net::eagle0::shardok::common::GameStatus;
|
||||
|
||||
@@ -71,17 +74,11 @@ vector<shared_ptr<ShardokAIClient>> ShardokGameController::MakeAIClients(
|
||||
mctsConfig.maxSimulationFlips = 1;
|
||||
mctsConfig.simulationPolicy = mcts::MCTSSimulationPolicy::WEIGHTED_HEURISTIC;
|
||||
|
||||
// Check if all players are AI - if so, use faster time budgets
|
||||
const auto &playerInfos = e->GetPlayerInfos();
|
||||
const bool isAllAiBattle =
|
||||
std::ranges::all_of(playerInfos, [](const auto &pi) { return pi.is_ai(); });
|
||||
|
||||
for (const auto &pi : playerInfos) {
|
||||
for (const auto &pi : e->GetPlayerInfos()) {
|
||||
if (pi.is_ai()) {
|
||||
auto newClient = std::make_shared<ShardokAIClient>(
|
||||
pi.player_id(),
|
||||
pi.is_defender(),
|
||||
isAllAiBattle,
|
||||
e->GetCurrentGameState()->hex_map(),
|
||||
e->GetGameSettings()->GetGetter(),
|
||||
AIAlgorithmType::ITERATIVE_DEEPENING,
|
||||
@@ -281,9 +278,15 @@ auto ShardokGameController::GetUpdates(const int64_t startingActionId) -> AllUpd
|
||||
awrs = engine->GetGameHistory(startingActionId);
|
||||
incomingRegistrations--;
|
||||
|
||||
// Note: Previously this had a 5-second wait for AI players to support long-polling.
|
||||
// With streaming (WaitForUpdatesAndPush), the caller already waits for updates,
|
||||
// so this wait is no longer needed. GetGameStatus is deprecated in favor of streaming.
|
||||
// If the current player is an AI, wait for some results to post. Otherwise go ahead and
|
||||
// return, we might be telling the caller about available commands.
|
||||
if (awrs.empty() && !engine->GameIsOver() &&
|
||||
engine->GetPlayerInfos()[engine->GetCurrentPlayerId()].is_ai()) {
|
||||
updateCondition.wait_for(guard, kWaitForUpdatesDuration);
|
||||
incomingRegistrations++;
|
||||
awrs = engine->GetGameHistory(startingActionId);
|
||||
incomingRegistrations--;
|
||||
}
|
||||
|
||||
updates.mainResults.reserve(awrs.size());
|
||||
std::ranges::transform(
|
||||
@@ -403,10 +406,12 @@ auto ShardokGameController::WaitForUpdatesAndPush(
|
||||
}
|
||||
// Lock released - GetUpdates will acquire its own lock
|
||||
|
||||
// IMPORTANT: Send any pending updates FIRST, including the final actions
|
||||
// that caused the game to end. Previously, we returned early on gameOver
|
||||
// without sending these final updates, causing the client to never see
|
||||
// the last few battle actions.
|
||||
if (gameOver) {
|
||||
subscriber->OnGameOver(gameOverInfo);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get updates outside the lock (GetUpdates acquires masterLock internally)
|
||||
AllUpdates updates = GetUpdates(lastPushedActionId);
|
||||
lastPushedActionId = updates.newUnfilteredCount;
|
||||
|
||||
@@ -417,12 +422,6 @@ auto ShardokGameController::WaitForUpdatesAndPush(
|
||||
updates.newUnfilteredCount,
|
||||
updates.currentGameState);
|
||||
}
|
||||
|
||||
// Now send gameOver notification after all updates have been sent
|
||||
if (gameOver) {
|
||||
subscriber->OnGameOver(gameOverInfo);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false; // Subscriber disconnected
|
||||
|
||||
@@ -322,6 +322,23 @@ void EagleInterfaceImpl::PopulateGameStatusResponse(
|
||||
}
|
||||
}
|
||||
|
||||
auto EagleInterfaceImpl::GetGameStatus(
|
||||
ServerContext * /*context*/,
|
||||
const GameStatusRequest *request,
|
||||
GameStatusResponse *response) -> Status {
|
||||
shared_ptr<ShardokGameController> controller;
|
||||
try {
|
||||
controller = ControllerForGame(request->game_id(), request->game_setup_info());
|
||||
} catch (NewGameException &e) { return e.GetStatus(); }
|
||||
|
||||
PopulateGameStatusResponse(
|
||||
controller,
|
||||
request->game_setup_info().known_result_count(),
|
||||
response);
|
||||
|
||||
return Status::OK;
|
||||
}
|
||||
|
||||
auto EagleInterfaceImpl::GetHexMap(
|
||||
ServerContext * /*context*/,
|
||||
const HexMapRequest *request,
|
||||
|
||||
@@ -31,6 +31,7 @@ namespace shardok {
|
||||
using grpc::ServerContext;
|
||||
using grpc::Status;
|
||||
using net::eagle0::common::GameSetupInfo;
|
||||
using net::eagle0::common::GameStatusRequest;
|
||||
using net::eagle0::common::GameStatusResponse;
|
||||
using net::eagle0::common::GameSubscriptionRequest;
|
||||
using net::eagle0::common::HexMapNamesRequest;
|
||||
@@ -67,6 +68,10 @@ public:
|
||||
ServerContext* context,
|
||||
const PlacementCommandsRequest* request,
|
||||
GameStatusResponse* response) -> Status override;
|
||||
auto GetGameStatus(
|
||||
ServerContext* context,
|
||||
const GameStatusRequest* request,
|
||||
GameStatusResponse* response) -> Status override;
|
||||
|
||||
auto GetHexMap(ServerContext* context, const HexMapRequest* request, HexMapResponse* response)
|
||||
-> Status override;
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/ResolveInvitationCommandSelector.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Slider/SliderManager.cs" />
|
||||
<Compile Include="Assets/common/GUIUtils/TableRowHoverDetector.cs" />
|
||||
<Compile Include="Assets/ConnectionHandler/DropGameConfirmationPanel.cs" />
|
||||
<Compile Include="Assets/Eagle/Table Rows/DynamicHeroTextUpdater.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/util/HeroDropdownController.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/PrisonerReturnedNotificationGenerator.cs" />
|
||||
@@ -126,7 +125,6 @@
|
||||
<Compile Include="Assets/Eagle/Notifications/Notification.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/QuestFailedDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/QuestFulfilledDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Auth/OAuthManager.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ProfessionGainedDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Eagle/TextureList.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Modal Window/ModalWindowManager.cs" />
|
||||
@@ -148,7 +146,6 @@
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ProvinceHeldDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Bluetooth/DieInfo.cs" />
|
||||
<Compile Include="Assets/common/ResourceFetcher.cs" />
|
||||
<Compile Include="Assets/Auth/AuthClient.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/TruceRejectedDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/RiotSuppressedNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Modal Window/ModalWindowManagerEditor.cs" />
|
||||
@@ -156,7 +153,6 @@
|
||||
<Compile Include="Assets/Bluetooth/NativeDiceInterfaceImports.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ProvinceConqueredNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Eagle/Table Rows/ExtraTroopsRowController.cs" />
|
||||
<Compile Include="Assets/Auth/JwtAuthInterceptor.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandPanelController.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ShatteredArmyDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Shardok/TurnHistoryPanelController.cs" />
|
||||
@@ -262,7 +258,6 @@
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/ResolveTruceCommandSelector.cs" />
|
||||
<Compile Include="Assets/Eagle/ClientPregeneratedText.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/SwearBrotherhoodCommandSelector.cs" />
|
||||
<Compile Include="Assets/Auth/TokenStorage.cs" />
|
||||
<Compile Include="Assets/Eagle/Table Rows/TableRowController.cs" />
|
||||
<Compile Include="Assets/Eagle/DisplayNames.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerSliderEditor.cs" />
|
||||
@@ -368,7 +363,6 @@
|
||||
<Compile Include="Assets/Eagle/Table Rows/AvailableHeroTableRow.cs" />
|
||||
<Compile Include="Assets/Eagle/Table Rows/HeroRowController.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerTooltip.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/NewFactionHeadDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Shardok/HexGrid.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Tooltip/TooltipManager.cs" />
|
||||
<None Include="Assets/TextMesh Pro/Resources/Shaders/TMPro.cginc" />
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 683e069fbc3074e35ac6fc3881a03f4a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,161 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Cysharp.Net.Http;
|
||||
using Grpc.Core;
|
||||
using Grpc.Core.Interceptors;
|
||||
using Grpc.Net.Client;
|
||||
using Net.Eagle0.Eagle.Api.Auth;
|
||||
using UnityEngine;
|
||||
using GrpcAuthClient = Net.Eagle0.Eagle.Api.Auth.Auth.AuthClient;
|
||||
|
||||
namespace Auth {
|
||||
/// <summary>
|
||||
/// gRPC client for the Auth service.
|
||||
/// Handles OAuth URL generation, status polling, and token refresh.
|
||||
/// </summary>
|
||||
public class AuthClient : IDisposable {
|
||||
private const int PollIntervalMs = 2000; // Poll every 2 seconds
|
||||
private const int PollTimeoutMs = 300000; // 5 minute timeout
|
||||
|
||||
private readonly GrpcAuthClient _client;
|
||||
private readonly GrpcChannel _channel;
|
||||
|
||||
/// <summary>
|
||||
/// Create auth client for the given server URL.
|
||||
/// </summary>
|
||||
/// <param name="serverUrl">Full URL with scheme, e.g. "http://localhost:40032" or
|
||||
/// "https://prod.eagle0.net"</param>
|
||||
public AuthClient(string serverUrl) {
|
||||
_channel = GrpcChannel.ForAddress(
|
||||
serverUrl,
|
||||
new GrpcChannelOptions {
|
||||
HttpHandler = new YetAnotherHttpHandler { Http2Only = true },
|
||||
DisposeHttpClient = true
|
||||
});
|
||||
|
||||
// Create invoker with JWT interceptor for authenticated requests
|
||||
CallInvoker invoker = _channel.Intercept(new JwtAuthInterceptor());
|
||||
_client = new GrpcAuthClient(invoker);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get OAuth URL to open in system browser.
|
||||
/// Returns both the URL and the state token for polling.
|
||||
/// </summary>
|
||||
/// <param name="provider">Discord or Google</param>
|
||||
/// <returns>Tuple of (URL to open in browser, state token for polling)</returns>
|
||||
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(OAuthProvider provider) {
|
||||
var request = new GetOAuthUrlRequest { Provider = provider };
|
||||
|
||||
var response = await _client.GetOAuthUrlAsync(request);
|
||||
|
||||
Debug.Log($"[AuthClient] Got OAuth URL for {provider}, state={response.State}");
|
||||
return (response.AuthUrl, response.State);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Poll for OAuth completion. Blocks until success, failure, or timeout.
|
||||
/// The server handles the OAuth callback and token exchange.
|
||||
/// </summary>
|
||||
/// <param name="state">State token from GetOAuthUrlAsync</param>
|
||||
/// <returns>Response with tokens and user info on success</returns>
|
||||
public async Task<CheckOAuthStatusResponse> PollForOAuthCompletionAsync(string state) {
|
||||
var startTime = DateTime.UtcNow;
|
||||
|
||||
while (true) {
|
||||
var request = new CheckOAuthStatusRequest { State = state };
|
||||
var response = await _client.CheckOAuthStatusAsync(request);
|
||||
|
||||
switch (response.Status) {
|
||||
case OAuthStatus.Success:
|
||||
Debug.Log(
|
||||
$"[AuthClient] OAuth successful, user={response.User?.DisplayName}, isNew={response.IsNewUser}");
|
||||
return response;
|
||||
|
||||
case OAuthStatus.Failed:
|
||||
throw new Exception($"OAuth failed: {response.ErrorMessage}");
|
||||
|
||||
case OAuthStatus.Expired:
|
||||
throw new Exception("OAuth session expired. Please try again.");
|
||||
|
||||
case OAuthStatus.Pending:
|
||||
// Check timeout
|
||||
if ((DateTime.UtcNow - startTime).TotalMilliseconds > PollTimeoutMs) {
|
||||
throw new TimeoutException("OAuth login timed out. Please try again.");
|
||||
}
|
||||
// Wait before polling again
|
||||
await Task.Delay(PollIntervalMs);
|
||||
break;
|
||||
|
||||
default: throw new Exception($"Unknown OAuth status: {response.Status}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh access token using stored refresh token.
|
||||
/// </summary>
|
||||
/// <returns>New access token and expiry, or null if refresh failed</returns>
|
||||
public async Task<RefreshTokenResponse> RefreshTokenAsync() {
|
||||
var refreshToken = TokenStorage.RefreshToken;
|
||||
if (string.IsNullOrEmpty(refreshToken)) {
|
||||
throw new InvalidOperationException("No refresh token available");
|
||||
}
|
||||
|
||||
var request = new RefreshTokenRequest { RefreshToken = refreshToken };
|
||||
|
||||
var response = await _client.RefreshTokenAsync(request);
|
||||
|
||||
// Update stored access token
|
||||
TokenStorage.UpdateAccessToken(response.AccessToken, response.ExpiresAt);
|
||||
|
||||
Debug.Log($"[AuthClient] Token refreshed, expires at {response.ExpiresAt}");
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set display name for new users.
|
||||
/// Requires valid JWT in interceptor.
|
||||
/// </summary>
|
||||
public async Task<SetDisplayNameResponse> SetDisplayNameAsync(string displayName) {
|
||||
var request = new SetDisplayNameRequest { DisplayName = displayName };
|
||||
var response = await _client.SetDisplayNameAsync(request);
|
||||
|
||||
if (response.Success) {
|
||||
TokenStorage.UpdateDisplayName(displayName);
|
||||
Debug.Log($"[AuthClient] Display name set to: {displayName}");
|
||||
} else {
|
||||
Debug.LogWarning(
|
||||
$"[AuthClient] Failed to set display name: {response.ErrorMessage}");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current user info. Validates the stored JWT.
|
||||
/// </summary>
|
||||
public async Task<GetCurrentUserResponse> GetCurrentUserAsync() {
|
||||
var response = await _client.GetCurrentUserAsync(new GetCurrentUserRequest());
|
||||
Debug.Log($"[AuthClient] Current user: {response.User?.DisplayName}");
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logout - invalidates refresh token on server.
|
||||
/// </summary>
|
||||
public async Task LogoutAsync() {
|
||||
try {
|
||||
await _client.LogoutAsync(new LogoutRequest());
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[AuthClient] Logout RPC failed (may be expected): {ex.Message}");
|
||||
}
|
||||
|
||||
// Clear local tokens regardless of server response
|
||||
TokenStorage.Clear();
|
||||
Debug.Log("[AuthClient] Logged out, tokens cleared");
|
||||
}
|
||||
|
||||
public void Dispose() { _channel?.Dispose(); }
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4ba94126c2d2145b8875612eaf5bc371
|
||||
@@ -1,75 +0,0 @@
|
||||
using System;
|
||||
using Grpc.Core;
|
||||
using Grpc.Core.Interceptors;
|
||||
|
||||
namespace Auth {
|
||||
/// <summary>
|
||||
/// gRPC interceptor that attaches JWT Bearer token to requests.
|
||||
/// Reads token from TokenStorage on each request.
|
||||
/// </summary>
|
||||
public class JwtAuthInterceptor : Interceptor {
|
||||
private const string HeaderName = "Authorization";
|
||||
|
||||
private string GetBearerHeader() {
|
||||
var token = TokenStorage.AccessToken;
|
||||
return string.IsNullOrEmpty(token) ? null : $"Bearer {token}";
|
||||
}
|
||||
|
||||
public override TResponse BlockingUnaryCall<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
ClientInterceptorContext<TRequest, TResponse> context,
|
||||
BlockingUnaryCallContinuation<TRequest, TResponse> continuation) {
|
||||
return continuation(request, ContextWithHeaders(context));
|
||||
}
|
||||
|
||||
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
ClientInterceptorContext<TRequest, TResponse> context,
|
||||
AsyncUnaryCallContinuation<TRequest, TResponse> continuation) {
|
||||
return continuation(request, ContextWithHeaders(context));
|
||||
}
|
||||
|
||||
public override AsyncServerStreamingCall<TResponse>
|
||||
AsyncServerStreamingCall<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
ClientInterceptorContext<TRequest, TResponse> context,
|
||||
AsyncServerStreamingCallContinuation<TRequest, TResponse> continuation) {
|
||||
return continuation(request, ContextWithHeaders(context));
|
||||
}
|
||||
|
||||
public override AsyncClientStreamingCall<TRequest, TResponse>
|
||||
AsyncClientStreamingCall<TRequest, TResponse>(
|
||||
ClientInterceptorContext<TRequest, TResponse> context,
|
||||
AsyncClientStreamingCallContinuation<TRequest, TResponse> continuation) {
|
||||
return continuation(ContextWithHeaders(context));
|
||||
}
|
||||
|
||||
public override AsyncDuplexStreamingCall<TRequest, TResponse>
|
||||
AsyncDuplexStreamingCall<TRequest, TResponse>(
|
||||
ClientInterceptorContext<TRequest, TResponse> context,
|
||||
AsyncDuplexStreamingCallContinuation<TRequest, TResponse> continuation) {
|
||||
return continuation(ContextWithHeaders(context));
|
||||
}
|
||||
|
||||
private ClientInterceptorContext<TRequest, TResponse>
|
||||
ContextWithHeaders<TRequest, TResponse>(
|
||||
ClientInterceptorContext<TRequest, TResponse> original)
|
||||
where TRequest : class
|
||||
where TResponse : class {
|
||||
var bearerHeader = GetBearerHeader();
|
||||
if (string.IsNullOrEmpty(bearerHeader)) {
|
||||
// No token available - return original context
|
||||
// Auth service endpoints don't require token
|
||||
return original;
|
||||
}
|
||||
|
||||
var headers = new Metadata();
|
||||
headers.Add(HeaderName, bearerHeader);
|
||||
|
||||
return new ClientInterceptorContext<TRequest, TResponse>(
|
||||
original.Method,
|
||||
original.Host,
|
||||
original.Options.WithHeaders(headers));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8528d8ee967e042ecb2a5e8285c425e0
|
||||
@@ -1,183 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Net.Eagle0.Eagle.Api.Auth;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Auth {
|
||||
/// <summary>
|
||||
/// Manages OAuth authentication flow using server-mediated polling:
|
||||
/// - Opens system browser for OAuth consent
|
||||
/// - Polls server for OAuth completion (no deep links needed)
|
||||
/// - Token storage and refresh
|
||||
/// </summary>
|
||||
public class OAuthManager : MonoBehaviour {
|
||||
public static OAuthManager Instance { get; private set; }
|
||||
|
||||
private AuthClient _authClient;
|
||||
private string _currentServerUrl;
|
||||
|
||||
// Events for UI updates
|
||||
public event Action<UserInfo> OnLoginSuccess;
|
||||
public event Action<string> OnLoginFailed;
|
||||
public event Action OnLogout;
|
||||
public event Action<bool> OnNewUserNeedsDisplayName; // true if new user
|
||||
|
||||
public bool IsAuthenticated => TokenStorage.HasValidToken;
|
||||
public string DisplayName => TokenStorage.DisplayName;
|
||||
public string UserId => TokenStorage.UserId;
|
||||
|
||||
private void Awake() {
|
||||
if (Instance != null && Instance != this) {
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
// AuthClient is created lazily when SetServerUrl is called
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the server URL for OAuth requests. Call this before any OAuth operations.
|
||||
/// </summary>
|
||||
/// <param name="serverUrl">Full URL with scheme, e.g. "https://prod.eagle0.net"</param>
|
||||
public void SetServerUrl(string serverUrl) {
|
||||
if (_currentServerUrl == serverUrl && _authClient != null) {
|
||||
return; // Already configured for this URL
|
||||
}
|
||||
|
||||
_authClient?.Dispose();
|
||||
_currentServerUrl = serverUrl;
|
||||
_authClient = new AuthClient(serverUrl);
|
||||
Debug.Log($"[OAuthManager] Configured for server: {serverUrl}");
|
||||
}
|
||||
|
||||
private void OnDestroy() {
|
||||
_authClient?.Dispose();
|
||||
if (Instance == this) { Instance = null; }
|
||||
}
|
||||
|
||||
private void EnsureConfigured() {
|
||||
if (_authClient == null) {
|
||||
throw new InvalidOperationException(
|
||||
"OAuthManager not configured. Call SetServerUrl() first.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start OAuth login with specified provider.
|
||||
/// Opens system browser for user consent, then polls for completion.
|
||||
/// </summary>
|
||||
public async Task<CheckOAuthStatusResponse> LoginAsync(OAuthProvider provider) {
|
||||
EnsureConfigured();
|
||||
try {
|
||||
// Get OAuth URL and state from server
|
||||
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider);
|
||||
|
||||
// Open system browser
|
||||
Debug.Log($"[OAuthManager] Opening browser: {authUrl}");
|
||||
Application.OpenURL(authUrl);
|
||||
|
||||
// Poll for OAuth completion (server handles the callback)
|
||||
var response = await _authClient.PollForOAuthCompletionAsync(state);
|
||||
|
||||
// Store tokens
|
||||
TokenStorage.StoreTokens(
|
||||
response.AccessToken,
|
||||
response.RefreshToken,
|
||||
response.ExpiresAt,
|
||||
response.User.UserId,
|
||||
response.User.DisplayName ?? "");
|
||||
|
||||
// Notify listeners
|
||||
if (response.IsNewUser) {
|
||||
OnNewUserNeedsDisplayName?.Invoke(true);
|
||||
} else {
|
||||
OnLoginSuccess?.Invoke(response.User);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (Exception ex) {
|
||||
Debug.LogError($"[OAuthManager] Login failed: {ex.Message}");
|
||||
OnLoginFailed?.Invoke(ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set display name for new user after OAuth.
|
||||
/// </summary>
|
||||
public async Task<bool> SetDisplayNameAsync(string displayName) {
|
||||
EnsureConfigured();
|
||||
var response = await _authClient.SetDisplayNameAsync(displayName);
|
||||
|
||||
if (response.Success) { OnLoginSuccess?.Invoke(response.User); }
|
||||
|
||||
return response.Success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to restore session from stored tokens.
|
||||
/// Returns true if valid session exists.
|
||||
/// Must call SetServerUrl() before this method.
|
||||
/// </summary>
|
||||
public async Task<bool> TryRestoreSessionAsync() {
|
||||
if (_authClient == null) {
|
||||
Debug.Log("[OAuthManager] Not configured yet, skipping session restore");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TokenStorage.HasValidToken && !TokenStorage.HasRefreshToken) {
|
||||
Debug.Log("[OAuthManager] No stored session");
|
||||
return false;
|
||||
}
|
||||
|
||||
// If token is expired but we have refresh token, try to refresh
|
||||
if (!TokenStorage.HasValidToken && TokenStorage.HasRefreshToken) {
|
||||
try {
|
||||
await _authClient.RefreshTokenAsync();
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[OAuthManager] Token refresh failed: {ex.Message}");
|
||||
TokenStorage.Clear();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate session with server
|
||||
try {
|
||||
var response = await _authClient.GetCurrentUserAsync();
|
||||
Debug.Log($"[OAuthManager] Session restored for {response.User.DisplayName}");
|
||||
OnLoginSuccess?.Invoke(response.User);
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[OAuthManager] Session validation failed: {ex.Message}");
|
||||
TokenStorage.Clear();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh access token if needed.
|
||||
/// Call this before making API requests.
|
||||
/// </summary>
|
||||
public async Task EnsureValidTokenAsync() {
|
||||
EnsureConfigured();
|
||||
if (TokenStorage.NeedsRefresh && TokenStorage.HasRefreshToken) {
|
||||
await _authClient.RefreshTokenAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logout and clear stored tokens.
|
||||
/// </summary>
|
||||
public async Task LogoutAsync() {
|
||||
// LogoutAsync can work even without server connection - just clear local tokens
|
||||
if (_authClient != null) {
|
||||
await _authClient.LogoutAsync();
|
||||
} else {
|
||||
TokenStorage.Clear();
|
||||
}
|
||||
OnLogout?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0e9d5b0cb9f26477eaf26e8a09c41707
|
||||
@@ -1,122 +0,0 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Auth {
|
||||
/// <summary>
|
||||
/// Secure storage for OAuth tokens using Unity's PlayerPrefs.
|
||||
/// In production, consider using platform-specific secure storage
|
||||
/// (Keychain on iOS, Keystore on Android).
|
||||
/// </summary>
|
||||
public static class TokenStorage {
|
||||
private const string AccessTokenKey = "eagle0_access_token";
|
||||
private const string RefreshTokenKey = "eagle0_refresh_token";
|
||||
private const string ExpiresAtKey = "eagle0_expires_at";
|
||||
private const string UserIdKey = "eagle0_user_id";
|
||||
private const string DisplayNameKey = "eagle0_display_name";
|
||||
|
||||
public static bool HasValidToken {
|
||||
get {
|
||||
var token = AccessToken;
|
||||
var expiresAt = ExpiresAt;
|
||||
return !string.IsNullOrEmpty(token) &&
|
||||
expiresAt > DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasRefreshToken => !string.IsNullOrEmpty(RefreshToken);
|
||||
|
||||
public static string AccessToken {
|
||||
get => PlayerPrefs.GetString(AccessTokenKey, null);
|
||||
private
|
||||
set => PlayerPrefs.SetString(AccessTokenKey, value ?? "");
|
||||
}
|
||||
|
||||
public static string RefreshToken {
|
||||
get => PlayerPrefs.GetString(RefreshTokenKey, null);
|
||||
private
|
||||
set => PlayerPrefs.SetString(RefreshTokenKey, value ?? "");
|
||||
}
|
||||
|
||||
public static long ExpiresAt {
|
||||
get => long.TryParse(PlayerPrefs.GetString(ExpiresAtKey, "0"), out var val) ? val : 0;
|
||||
private
|
||||
set => PlayerPrefs.SetString(ExpiresAtKey, value.ToString());
|
||||
}
|
||||
|
||||
public static string UserId {
|
||||
get => PlayerPrefs.GetString(UserIdKey, null);
|
||||
private
|
||||
set => PlayerPrefs.SetString(UserIdKey, value ?? "");
|
||||
}
|
||||
|
||||
public static string DisplayName {
|
||||
get => PlayerPrefs.GetString(DisplayNameKey, null);
|
||||
private
|
||||
set => PlayerPrefs.SetString(DisplayNameKey, value ?? "");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store tokens received from OAuth exchange.
|
||||
/// </summary>
|
||||
public static void StoreTokens(
|
||||
string accessToken,
|
||||
string refreshToken,
|
||||
long expiresAt,
|
||||
string userId,
|
||||
string displayName) {
|
||||
AccessToken = accessToken;
|
||||
RefreshToken = refreshToken;
|
||||
ExpiresAt = expiresAt;
|
||||
UserId = userId;
|
||||
DisplayName = displayName;
|
||||
PlayerPrefs.Save();
|
||||
|
||||
Debug.Log(
|
||||
$"[TokenStorage] Stored tokens for user {displayName} (expires at {expiresAt})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update access token after refresh.
|
||||
/// </summary>
|
||||
public static void UpdateAccessToken(string accessToken, long expiresAt) {
|
||||
AccessToken = accessToken;
|
||||
ExpiresAt = expiresAt;
|
||||
PlayerPrefs.Save();
|
||||
|
||||
Debug.Log($"[TokenStorage] Updated access token (expires at {expiresAt})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update display name after user sets it.
|
||||
/// </summary>
|
||||
public static void UpdateDisplayName(string displayName) {
|
||||
DisplayName = displayName;
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all stored tokens (logout).
|
||||
/// </summary>
|
||||
public static void Clear() {
|
||||
PlayerPrefs.DeleteKey(AccessTokenKey);
|
||||
PlayerPrefs.DeleteKey(RefreshTokenKey);
|
||||
PlayerPrefs.DeleteKey(ExpiresAtKey);
|
||||
PlayerPrefs.DeleteKey(UserIdKey);
|
||||
PlayerPrefs.DeleteKey(DisplayNameKey);
|
||||
PlayerPrefs.Save();
|
||||
|
||||
Debug.Log("[TokenStorage] Cleared all tokens");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if token needs refresh (expires within 5 minutes).
|
||||
/// </summary>
|
||||
public static bool NeedsRefresh {
|
||||
get {
|
||||
var expiresAt = ExpiresAt;
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
return expiresAt > 0 && expiresAt - now < 300; // 5 minutes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4b1f130cec656466893fa04ba789d34f
|
||||
+13
-321
@@ -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
|
||||
|
||||
-4
@@ -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;
|
||||
|
||||
+13
-311
@@ -7,18 +7,15 @@ using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Auth;
|
||||
using common;
|
||||
using common.GUIUtils;
|
||||
using eagle;
|
||||
using Grpc.Core;
|
||||
using Grpc.Core.Interceptors;
|
||||
using Net.Eagle0.Eagle.Api;
|
||||
using Net.Eagle0.Eagle.Api.Auth;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.UI;
|
||||
using Object = System.Object;
|
||||
|
||||
public class AuthInterceptor : Interceptor {
|
||||
@@ -85,23 +82,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
public TMP_InputField passwordField;
|
||||
public TMP_Dropdown resolutionDropdown;
|
||||
|
||||
[Header("OAuth UI")]
|
||||
public GameObject oauthPanel;
|
||||
public Button discordLoginButton;
|
||||
public Button googleLoginButton;
|
||||
public TextMeshProUGUI oauthStatusText;
|
||||
|
||||
[Header("Display Name Setup")]
|
||||
public GameObject displayNamePanel;
|
||||
public TMP_InputField displayNameField;
|
||||
public Button setDisplayNameButton;
|
||||
public TextMeshProUGUI displayNameErrorText;
|
||||
|
||||
[Header("Legacy Auth (for testing)")]
|
||||
public GameObject legacyAuthPanel;
|
||||
public Button useLegacyAuthButton;
|
||||
public TextMeshProUGUI useLegacyAuthButtonText;
|
||||
|
||||
[Header("Status Display")]
|
||||
public TextMeshProUGUI connectionStatusText;
|
||||
|
||||
@@ -110,11 +90,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
public GameObject customBattlePanel;
|
||||
public ErrorHandler errorHandler;
|
||||
|
||||
[Header("Lobby Controls")]
|
||||
public Button logoutButton;
|
||||
public TextMeshProUGUI lobbyEnvironmentText;
|
||||
public TextMeshProUGUI lobbyUserText;
|
||||
|
||||
public GameObject runningGamesListArea;
|
||||
public GameObject runningGamesListItemPrefab;
|
||||
|
||||
@@ -123,8 +98,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
public GameObject waitingGamesListItemPrefab;
|
||||
public GameObject createGameListItemPrefab;
|
||||
|
||||
public DropGameConfirmationPanel dropGameConfirmationPanel;
|
||||
|
||||
public Canvas connectionCanvas;
|
||||
public Canvas eagleCanvas;
|
||||
public Canvas shardokCanvas;
|
||||
@@ -139,8 +112,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
private bool listen = false;
|
||||
private CancellationTokenSource _cancellationTokenSource;
|
||||
private readonly Object pendingReplyLock = new Object();
|
||||
private int _connectedEnvironmentIndex = -1; // -1 means not connected
|
||||
private bool _useOAuth = false; // Default to legacy until server-side OAuth is ready
|
||||
|
||||
public ClientPregeneratedText clientPregeneratedText;
|
||||
|
||||
@@ -154,27 +125,12 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
private static readonly List<string> EnvironmentDisplayNames =
|
||||
new() { "prod.", "qa.", "(none)" };
|
||||
|
||||
/// <summary>
|
||||
/// Returns the connected environment name ("prod." or "qa.") or null if not connected.
|
||||
/// </summary>
|
||||
public string ConnectedEnvironmentName =>
|
||||
_connectedEnvironmentIndex >= 0 &&
|
||||
_connectedEnvironmentIndex < EnvironmentDisplayNames.Count
|
||||
? EnvironmentDisplayNames[_connectedEnvironmentIndex]
|
||||
: null;
|
||||
|
||||
private string GetUrlFromEnvironment() {
|
||||
int envIndex = environmentDropdown.value;
|
||||
string prefix = EnvironmentOptions[envIndex];
|
||||
return prefix + BaseDomain;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get full URL with scheme for the selected environment.
|
||||
/// Remote servers use HTTPS, could be extended for local HTTP.
|
||||
/// </summary>
|
||||
private string GetFullUrlFromEnvironment() { return "https://" + GetUrlFromEnvironment(); }
|
||||
|
||||
public void ReceiveLobbyUpdate(LobbyResponse lobbyResponse) {
|
||||
_handleLobbyResponse(lobbyResponse);
|
||||
}
|
||||
@@ -210,214 +166,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
|
||||
errorHandler.gameObject.SetActive(true);
|
||||
|
||||
// Initialize OAuth UI
|
||||
SetupOAuthUI();
|
||||
|
||||
// Initialize Lobby UI (logout button, etc.)
|
||||
SetupLobbyUI();
|
||||
|
||||
// Initialize status text
|
||||
UpdateConnectionStatus();
|
||||
|
||||
// Try to restore existing OAuth session
|
||||
TryRestoreSession();
|
||||
}
|
||||
|
||||
private void SetupOAuthUI() {
|
||||
// Set up OAuth button click handlers
|
||||
if (discordLoginButton != null) {
|
||||
discordLoginButton.onClick.AddListener(
|
||||
() => OnOAuthLoginClicked(OAuthProvider.Discord));
|
||||
}
|
||||
if (googleLoginButton != null) {
|
||||
googleLoginButton.onClick.AddListener(() => OnOAuthLoginClicked(OAuthProvider.Google));
|
||||
}
|
||||
if (setDisplayNameButton != null) {
|
||||
setDisplayNameButton.onClick.AddListener(OnSetDisplayNameClicked);
|
||||
}
|
||||
if (useLegacyAuthButton != null) {
|
||||
useLegacyAuthButton.onClick.AddListener(OnUseLegacyAuthClicked);
|
||||
}
|
||||
|
||||
// Subscribe to OAuthManager events
|
||||
if (OAuthManager.Instance != null) {
|
||||
OAuthManager.Instance.OnLoginSuccess += OnOAuthLoginSuccess;
|
||||
OAuthManager.Instance.OnLoginFailed += OnOAuthLoginFailed;
|
||||
OAuthManager.Instance.OnNewUserNeedsDisplayName += OnNewUserNeedsDisplayName;
|
||||
OAuthManager.Instance.OnLogout += OnOAuthLogout;
|
||||
}
|
||||
|
||||
// Show appropriate panel based on auth state
|
||||
ShowAuthPanel();
|
||||
}
|
||||
|
||||
private void ShowAuthPanel() {
|
||||
// Hide all auth panels first
|
||||
if (oauthPanel != null) oauthPanel.SetActive(false);
|
||||
if (displayNamePanel != null) displayNamePanel.SetActive(false);
|
||||
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(false);
|
||||
|
||||
// Show appropriate panel based on _useOAuth
|
||||
if (_useOAuth) {
|
||||
if (oauthPanel != null) oauthPanel.SetActive(true);
|
||||
if (useLegacyAuthButtonText != null)
|
||||
useLegacyAuthButtonText.text = "Use Classic Sign-in";
|
||||
} else {
|
||||
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(true);
|
||||
if (useLegacyAuthButtonText != null) useLegacyAuthButtonText.text = "Use OAuth Sign-in";
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupLobbyUI() {
|
||||
if (logoutButton != null) { logoutButton.onClick.AddListener(OnLogoutClicked); }
|
||||
}
|
||||
|
||||
private void UpdateLobbyStatusDisplays() {
|
||||
// Show environment (prod/qa)
|
||||
if (lobbyEnvironmentText != null) {
|
||||
lobbyEnvironmentText.text = ConnectedEnvironmentName ?? "";
|
||||
}
|
||||
|
||||
// Show current user - prefer OAuth display name, fall back to classic login name
|
||||
if (lobbyUserText != null) {
|
||||
if (_useOAuth && !string.IsNullOrEmpty(TokenStorage.DisplayName)) {
|
||||
lobbyUserText.text = TokenStorage.DisplayName;
|
||||
} else {
|
||||
lobbyUserText.text = nameField.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnLogoutClicked() {
|
||||
Debug.Log("[ConnectionHandler] Logout button clicked");
|
||||
|
||||
// Clear OAuth tokens
|
||||
if (OAuthManager.Instance != null) { await OAuthManager.Instance.LogoutAsync(); }
|
||||
|
||||
// Disconnect from server
|
||||
_persistentClientConnection?.Dispose();
|
||||
eagleConnection?.Dispose();
|
||||
_httpClient?.Dispose();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
|
||||
_persistentClientConnection = null;
|
||||
eagleConnection = null;
|
||||
_httpClient = null;
|
||||
_cancellationTokenSource = null;
|
||||
_connectedEnvironmentIndex = -1;
|
||||
|
||||
// Return to connection screen
|
||||
gameSelectionPanel.SetActive(false);
|
||||
connectionPanel.SetActive(true);
|
||||
|
||||
// Re-initialize the auth UI
|
||||
ShowAuthPanel();
|
||||
UpdateConnectionStatus();
|
||||
}
|
||||
|
||||
private async void TryRestoreSession() {
|
||||
if (OAuthManager.Instance == null) return;
|
||||
|
||||
// Configure OAuthManager with current environment URL
|
||||
OAuthManager.Instance.SetServerUrl(GetFullUrlFromEnvironment());
|
||||
|
||||
if (oauthStatusText != null) { oauthStatusText.text = "Checking for existing session..."; }
|
||||
|
||||
var restored = await OAuthManager.Instance.TryRestoreSessionAsync();
|
||||
if (restored) {
|
||||
// Session restored, connect to lobby
|
||||
ConnectWithOAuth();
|
||||
} else if (oauthStatusText != null) {
|
||||
oauthStatusText.text = "";
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnOAuthLoginClicked(OAuthProvider provider) {
|
||||
// Configure OAuthManager with current environment URL (user may have changed dropdown)
|
||||
OAuthManager.Instance.SetServerUrl(GetFullUrlFromEnvironment());
|
||||
|
||||
if (oauthStatusText != null) {
|
||||
oauthStatusText.text = $"Opening browser for {provider} login...";
|
||||
}
|
||||
|
||||
try {
|
||||
await OAuthManager.Instance.LoginAsync(provider);
|
||||
// OnLoginSuccess or OnNewUserNeedsDisplayName will be called
|
||||
} catch (Exception ex) {
|
||||
Debug.LogError($"OAuth login failed: {ex.Message}");
|
||||
if (oauthStatusText != null) { oauthStatusText.text = $"Login failed: {ex.Message}"; }
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOAuthLoginSuccess(UserInfo user) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
if (oauthStatusText != null) { oauthStatusText.text = $"Welcome, {user.DisplayName}!"; }
|
||||
ConnectWithOAuth();
|
||||
});
|
||||
}
|
||||
|
||||
private void OnOAuthLoginFailed(string error) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
if (oauthStatusText != null) { oauthStatusText.text = $"Login failed: {error}"; }
|
||||
});
|
||||
}
|
||||
|
||||
private void OnNewUserNeedsDisplayName(bool isNewUser) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
// Show display name panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(false);
|
||||
if (displayNamePanel != null) displayNamePanel.SetActive(true);
|
||||
if (displayNameErrorText != null) displayNameErrorText.text = "";
|
||||
});
|
||||
}
|
||||
|
||||
private async void OnSetDisplayNameClicked() {
|
||||
if (displayNameField == null || OAuthManager.Instance == null) return;
|
||||
|
||||
var displayName = displayNameField.text.Trim();
|
||||
if (string.IsNullOrEmpty(displayName)) {
|
||||
if (displayNameErrorText != null) {
|
||||
displayNameErrorText.text = "Please enter a display name";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (displayNameErrorText != null) { displayNameErrorText.text = "Setting display name..."; }
|
||||
|
||||
var success = await OAuthManager.Instance.SetDisplayNameAsync(displayName);
|
||||
if (!success && displayNameErrorText != null) {
|
||||
displayNameErrorText.text = "Name already taken or invalid. Try another.";
|
||||
}
|
||||
// OnLoginSuccess will be called if successful
|
||||
}
|
||||
|
||||
private void OnOAuthLogout() {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
ShowAuthPanel();
|
||||
if (oauthStatusText != null) { oauthStatusText.text = ""; }
|
||||
});
|
||||
}
|
||||
|
||||
private void OnUseLegacyAuthClicked() {
|
||||
_useOAuth = !_useOAuth; // Toggle instead of just setting false
|
||||
|
||||
if (_useOAuth) {
|
||||
// Show OAuth panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(true);
|
||||
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(false);
|
||||
if (useLegacyAuthButtonText != null)
|
||||
useLegacyAuthButtonText.text = "Use Classic Sign-in";
|
||||
} else {
|
||||
// Show legacy panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(false);
|
||||
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(true);
|
||||
if (useLegacyAuthButtonText != null) useLegacyAuthButtonText.text = "Use OAuth Sign-in";
|
||||
}
|
||||
}
|
||||
|
||||
private void ConnectWithOAuth() {
|
||||
_useOAuth = true;
|
||||
_internalConnectEagle();
|
||||
}
|
||||
|
||||
private float _statusUpdateTimer = 0f;
|
||||
@@ -512,8 +262,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
_persistentClientConnection = null;
|
||||
eagleConnection?.Dispose();
|
||||
eagleConnection = null;
|
||||
|
||||
_connectedEnvironmentIndex = -1;
|
||||
}
|
||||
|
||||
public void OnResolutionChanged(int dropdownIndex) {
|
||||
@@ -541,9 +289,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
connectionPanel.gameObject.SetActive(false);
|
||||
gameSelectionPanel.gameObject.SetActive(true);
|
||||
|
||||
// Update lobby status displays
|
||||
UpdateLobbyStatusDisplays();
|
||||
|
||||
// Set up running games table
|
||||
foreach (Transform row in runningGamesListArea.transform) { Destroy(row.gameObject); }
|
||||
foreach (var runningGame in lobbyResponse.RunningGames) {
|
||||
@@ -552,10 +297,10 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
runningGamesListArea.transform,
|
||||
true);
|
||||
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
|
||||
var runningGameItem = listItem.GetComponent<RunningGameItem>();
|
||||
runningGameItem.SetRunningGame(runningGame.GameId, runningGame.Leader);
|
||||
runningGameItem.GoCallback = this.SelectEagleGame;
|
||||
runningGameItem.DropCallback = this.DropGame;
|
||||
listItem.GetComponent<RunningGameItem>().SetRunningGame(
|
||||
runningGame.GameId,
|
||||
runningGame.Leader);
|
||||
listItem.GetComponent<RunningGameItem>().GoCallback = this.SelectEagleGame;
|
||||
}
|
||||
|
||||
// Set up available/waiting games table
|
||||
@@ -591,16 +336,14 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
availableGamesListArea.transform,
|
||||
true);
|
||||
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
|
||||
var availableGameItem = listItem.GetComponent<AvailableGameItem>();
|
||||
availableGameItem.SetAvailableNewGame(
|
||||
listItem.GetComponent<AvailableGameItem>().SetAvailableNewGame(
|
||||
availableGame.GameId,
|
||||
string.Format(
|
||||
"{0} / {1}",
|
||||
availableGame.CurrentHumanPlayerCount,
|
||||
availableGame.MaxHumanPlayerCount),
|
||||
availableGame.AvailableLeaders.ToList());
|
||||
availableGameItem.JoinCallback = this.JoinGame;
|
||||
availableGameItem.DropCallback = this.DropGame;
|
||||
listItem.GetComponent<AvailableGameItem>().JoinCallback = this.JoinGame;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -614,8 +357,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
}
|
||||
|
||||
private void _createConnection() {
|
||||
_connectedEnvironmentIndex = environmentDropdown.value;
|
||||
PlayerPrefs.SetInt(EnvironmentKey, _connectedEnvironmentIndex);
|
||||
PlayerPrefs.SetInt(EnvironmentKey, environmentDropdown.value);
|
||||
PlayerPrefs.SetString(NameKey, nameField.text);
|
||||
PlayerPrefs.SetString(PasswordKey, passwordField.text);
|
||||
|
||||
@@ -629,26 +371,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
string url = GetUrlFromEnvironment();
|
||||
|
||||
if (_useOAuth && TokenStorage.HasValidToken) {
|
||||
// Use JWT-based connection
|
||||
eagleConnection = EagleConnection.CreateWithJwt(url);
|
||||
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", TokenStorage.AccessToken);
|
||||
} else {
|
||||
// Legacy Basic Auth connection
|
||||
PlayerPrefs.SetString(NameKey, nameField.text);
|
||||
PlayerPrefs.SetString(PasswordKey, passwordField.text);
|
||||
|
||||
eagleConnection = new EagleConnection(nameField.text, passwordField.text, url);
|
||||
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Basic", eagleConnection.authHeader);
|
||||
}
|
||||
|
||||
eagleConnection = new EagleConnection(nameField.text, passwordField.text, url);
|
||||
_persistentClientConnection = new PersistentClientConnection(
|
||||
eagleConnection.EagleGrpcClient,
|
||||
eagleConnection.credentials,
|
||||
@@ -656,6 +379,10 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
_persistentClientConnection.Connect();
|
||||
|
||||
errorHandler.PersistentClientConnection = _persistentClientConnection;
|
||||
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Basic", eagleConnection.authHeader);
|
||||
ResourceFetcher.SetUpConnection(_httpClient);
|
||||
}
|
||||
|
||||
@@ -713,18 +440,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
var game = _internalJoinEagleGame(gameId, leaderName);
|
||||
}
|
||||
|
||||
private void DropGame(long gameId) {
|
||||
if (dropGameConfirmationPanel != null) {
|
||||
dropGameConfirmationPanel.OnConfirm = ConfirmDropGame;
|
||||
dropGameConfirmationPanel.Show(gameId, "Are you sure you want to leave this game?");
|
||||
} else {
|
||||
// Fallback if no confirmation panel is configured
|
||||
ConfirmDropGame(gameId);
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfirmDropGame(long gameId) { _internalDropGame(gameId); }
|
||||
|
||||
private void CreateGame(string leaderNameTextId, int humanPlayerCount, int totalPlayerCount) {
|
||||
var game = _internalCreateGame(leaderNameTextId, humanPlayerCount, totalPlayerCount);
|
||||
}
|
||||
@@ -801,18 +516,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> _internalDropGame(long gameId) {
|
||||
try {
|
||||
return await _persistentClientConnection.SendUpdateStreamRequestAsync(
|
||||
new UpdateStreamRequest {
|
||||
DropGameRequest = new DropGameRequest { GameId = gameId }
|
||||
});
|
||||
} catch (WebException e) {
|
||||
Debug.Log(String.Format("Caught exception in DropGame {0}", e.Message));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetLobbyActive(bool active) {
|
||||
runningGamesListArea.SetActive(active);
|
||||
availableGamesListArea.SetActive(active);
|
||||
@@ -830,8 +533,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
eagleCanvas.GetComponent<EagleGameController>().SetUpGame(
|
||||
gameInfo.GameId,
|
||||
gameInfo.FactionId,
|
||||
_persistentClientConnection,
|
||||
ConnectedEnvironmentName);
|
||||
_persistentClientConnection);
|
||||
connectionCanvas.enabled = false;
|
||||
connectionCanvas.gameObject.SetActive(false);
|
||||
eagleCanvas.gameObject.SetActive(true);
|
||||
|
||||
-24
@@ -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); }
|
||||
}
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 558d4c7321daa4fe18dc4759a130d919
|
||||
+9
-304
@@ -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
|
||||
|
||||
-4
@@ -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;
|
||||
|
||||
|
||||
+49
-142
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
-117
@@ -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)) {
|
||||
|
||||
+2
-2
@@ -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:
|
||||
LFS
BIN
Binary file not shown.
-117
@@ -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:
|
||||
LFS
BIN
Binary file not shown.
-117
@@ -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:
|
||||
src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/OAuth Buttons/web_neutral_sq_ctn@4x.png
LFS
BIN
Binary file not shown.
-117
@@ -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:
|
||||
+31
-183
@@ -46,7 +46,8 @@ namespace eagle {
|
||||
private readonly Logger _timingsLogger = Logger.GetLogger("ConnectionLogger");
|
||||
|
||||
// Counter to diagnose duplicate logging - if logs show same seq# twice, two threads
|
||||
// are processing same message. If seq# increments but lines doubled, Logger issue.
|
||||
// are processing the same message. If seq# increments but lines appear doubled,
|
||||
// it's a StreamWriter/Logger issue.
|
||||
private int _logFlowSeq = 0;
|
||||
|
||||
/// <summary>Timestamped log for connection flow tracing.</summary>
|
||||
@@ -89,23 +90,7 @@ namespace eagle {
|
||||
_retryTimer?.Dispose();
|
||||
NextReconnectAttempt = null;
|
||||
LogConnectionEvent("force_reconnect", "User requested immediate reconnect");
|
||||
RunConnectAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fire-and-forget wrapper for Connect() that ensures exceptions are logged
|
||||
/// rather than silently swallowed by Task.Run().
|
||||
/// </summary>
|
||||
private void RunConnectAsync() {
|
||||
Task.Run(async () => {
|
||||
try {
|
||||
await Connect();
|
||||
} catch (Exception e) {
|
||||
// Log but don't rethrow - this is fire-and-forget
|
||||
Console.WriteLine($"[CONNECT] Exception in Connect: {e}");
|
||||
_remoteEagleClientLogger.LogLine($"Exception in Connect: {e}");
|
||||
}
|
||||
});
|
||||
Task.Run(() => Connect());
|
||||
}
|
||||
|
||||
private DateTime? GetDeadlineFromNow() {
|
||||
@@ -159,7 +144,7 @@ namespace eagle {
|
||||
_retryTimer = new Timer();
|
||||
_retryTimer.AutoReset = false;
|
||||
_retryTimer.Interval = backoffSeconds * 1000;
|
||||
_retryTimer.Elapsed += (sender, args) => RunConnectAsync();
|
||||
_retryTimer.Elapsed += (sender, args) => Task.Run(() => Connect());
|
||||
_retryTimer.Enabled = true;
|
||||
}
|
||||
|
||||
@@ -178,7 +163,6 @@ namespace eagle {
|
||||
private readonly Dictionary<EagleGameId, TaskCompletionSource<SubscriptionAck>>
|
||||
_pendingSubscriptionAcks = new();
|
||||
private const int SubscriptionAckTimeoutMs = 10000; // 10 seconds
|
||||
private const int WriteAsyncTimeoutMs = 10000; // 10 seconds for WriteAsync operations
|
||||
|
||||
public PersistentClientConnection(
|
||||
Eagle.EagleClient grpcClient,
|
||||
@@ -426,18 +410,6 @@ namespace eagle {
|
||||
_pendingCommands.Add(pendingCommands.Dequeue());
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up resources before reconnecting
|
||||
lock (this) {
|
||||
_streamingCall?.Dispose();
|
||||
_streamingCall = null;
|
||||
_threadCancellationTokenSource?.Cancel();
|
||||
}
|
||||
MarkAllShardokGamesForResync();
|
||||
CancelAllPendingSubscriptionAcks();
|
||||
|
||||
// Schedule reconnect - without this, the connection dies silently
|
||||
ScheduleReconnect($"ConnectFailed-{e.GetType().Name}");
|
||||
}
|
||||
} finally { _isConnecting = false; }
|
||||
}
|
||||
@@ -475,35 +447,26 @@ namespace eagle {
|
||||
lock (this) { _pendingCommands.Add(nextCommand); }
|
||||
}
|
||||
} else {
|
||||
// CurrentEagleToken is null - server hasn't sent us
|
||||
// commands yet, or we skipped them. Retry the pending
|
||||
// command anyway; if the server already processed it, it
|
||||
// will be ignored as duplicate.
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"No current token, retrying pending command with token {providedToken}");
|
||||
await PostRequest(nextCommand);
|
||||
"No matching command for token " + providedToken);
|
||||
}
|
||||
|
||||
break;
|
||||
case UniversalCommand.SealedValueOneofCase.ShardokCommand: {
|
||||
Int64 providedShardokToken =
|
||||
nextCommand.Command.ShardokCommand.ShardokToken;
|
||||
var currentShardokToken = subscriber.CurrentShardokToken(
|
||||
nextCommand.Command.ShardokCommand.GameId);
|
||||
if (currentShardokToken is Int64 shardokToken &&
|
||||
if (subscriber.CurrentShardokToken(
|
||||
nextCommand.Command.ShardokCommand.GameId)
|
||||
is Int64 shardokToken &&
|
||||
shardokToken == providedShardokToken) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
"Found a matching pending command with token " +
|
||||
shardokToken);
|
||||
await PostRequest(nextCommand);
|
||||
} else if (currentShardokToken == null) {
|
||||
// No current token - retry anyway
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"No current Shardok token, retrying pending command with token {providedShardokToken}");
|
||||
await PostRequest(nextCommand);
|
||||
} else {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"Shardok token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
|
||||
"No matching command for token " +
|
||||
providedShardokToken);
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -513,27 +476,25 @@ namespace eagle {
|
||||
Int64 providedShardokToken =
|
||||
nextCommand.Command.ShardokPlacementCommands
|
||||
.ShardokToken;
|
||||
var currentShardokToken = subscriber.CurrentShardokToken(
|
||||
nextCommand.Command.ShardokPlacementCommands.GameId);
|
||||
if (currentShardokToken is Int64 shardokToken &&
|
||||
if (subscriber.CurrentShardokToken(
|
||||
nextCommand.Command.ShardokCommand.GameId)
|
||||
is Int64 shardokToken &&
|
||||
shardokToken == providedShardokToken) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
"Found a matching pending placement command with token " +
|
||||
"Found a matching pending command with token " +
|
||||
shardokToken);
|
||||
await PostRequest(nextCommand);
|
||||
} else if (currentShardokToken == null) {
|
||||
// No current token - retry anyway
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"No current Shardok token, retrying pending placement command with token {providedShardokToken}");
|
||||
await PostRequest(nextCommand);
|
||||
} else {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"Shardok placement token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
|
||||
"No matching command for token " +
|
||||
providedShardokToken);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await PostRequest(nextCommand);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,25 +613,13 @@ namespace eagle {
|
||||
try {
|
||||
lock (this) { _pendingCommands.Add(request); }
|
||||
|
||||
var success = await DoWithStreamingCall(async (streamingCall) => {
|
||||
await DoWithStreamingCall(async (streamingCall) => {
|
||||
await streamingCall.RequestStream.WriteAsync(
|
||||
new UpdateStreamRequest { PostCommandRequest = request });
|
||||
return true;
|
||||
});
|
||||
|
||||
// Only remove from pending if successfully sent.
|
||||
// If connection was dead, leave in queue for retry after reconnect.
|
||||
if (success) {
|
||||
lock (this) { _pendingCommands.Remove(request); }
|
||||
} else {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[POST] Command not sent (connection dead), keeping in pending queue for retry");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Console.WriteLine("Failed to post command: " + e);
|
||||
_remoteEagleClientLogger.LogLine($"[POST] Exception posting command: {e.Message}");
|
||||
// Leave in _pendingCommands for retry after reconnect
|
||||
}
|
||||
lock (this) { _pendingCommands.Remove(request); }
|
||||
} catch (Exception e) { Console.WriteLine("Failed to post command: " + e); }
|
||||
}
|
||||
|
||||
private async Task
|
||||
@@ -747,38 +696,9 @@ namespace eagle {
|
||||
|
||||
private delegate Task<bool> WithStreamingCallDelegate(
|
||||
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> call);
|
||||
|
||||
/// <summary>
|
||||
/// Execute an action with the streaming call, with a timeout to prevent indefinite
|
||||
/// blocking. If the action takes longer than WriteAsyncTimeoutMs, we consider the
|
||||
/// connection dead and return false. This prevents thread pool exhaustion on dead
|
||||
/// connections.
|
||||
/// </summary>
|
||||
private async Task<bool> DoWithStreamingCall(WithStreamingCallDelegate action) {
|
||||
var sc = _streamingCall;
|
||||
if (sc == null) return false;
|
||||
|
||||
try {
|
||||
using var timeoutCts = new CancellationTokenSource(WriteAsyncTimeoutMs);
|
||||
var actionTask = action(sc);
|
||||
var timeoutTask = Task.Delay(WriteAsyncTimeoutMs, timeoutCts.Token);
|
||||
|
||||
var completedTask =
|
||||
await Task.WhenAny(actionTask, timeoutTask).ConfigureAwait(false);
|
||||
|
||||
if (completedTask == timeoutTask) {
|
||||
// WriteAsync timed out - connection is likely dead
|
||||
LogConnectionEvent(
|
||||
"write_timeout",
|
||||
$"WriteAsync timed out after {WriteAsyncTimeoutMs}ms");
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[WRITE_TIMEOUT] WriteAsync timed out after {WriteAsyncTimeoutMs}ms, connection may be dead");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cancel the timeout task since action completed
|
||||
timeoutCts.Cancel();
|
||||
return await actionTask.ConfigureAwait(false);
|
||||
return await action(_streamingCall);
|
||||
} catch (RpcException e) {
|
||||
if (e.StatusCode == StatusCode.Cancelled) {
|
||||
// This is expected when the connection is closed.
|
||||
@@ -786,15 +706,9 @@ namespace eagle {
|
||||
} else {
|
||||
throw;
|
||||
}
|
||||
} catch (OperationCanceledException) {
|
||||
// Timeout was triggered
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send a request on the streaming call with a timeout to prevent indefinite blocking.
|
||||
/// </summary>
|
||||
public async Task<bool> SendUpdateStreamRequestAsync(UpdateStreamRequest request) {
|
||||
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc = null;
|
||||
lock (this) {
|
||||
@@ -803,30 +717,7 @@ namespace eagle {
|
||||
}
|
||||
|
||||
try {
|
||||
using var timeoutCts = new CancellationTokenSource(WriteAsyncTimeoutMs);
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
_cancellationToken,
|
||||
timeoutCts.Token);
|
||||
|
||||
var writeTask = sc.RequestStream.WriteAsync(request, linkedCts.Token);
|
||||
var timeoutTask = Task.Delay(WriteAsyncTimeoutMs, timeoutCts.Token);
|
||||
|
||||
var completedTask =
|
||||
await Task.WhenAny(writeTask, timeoutTask).ConfigureAwait(false);
|
||||
|
||||
if (completedTask == timeoutTask) {
|
||||
// WriteAsync timed out - connection is likely dead
|
||||
LogConnectionEvent(
|
||||
"write_timeout",
|
||||
$"SendUpdateStreamRequestAsync timed out after {WriteAsyncTimeoutMs}ms");
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[WRITE_TIMEOUT] SendUpdateStreamRequestAsync timed out after {WriteAsyncTimeoutMs}ms");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cancel the timeout task since write completed
|
||||
timeoutCts.Cancel();
|
||||
await writeTask.ConfigureAwait(false); // Observe any exception
|
||||
await sc.RequestStream.WriteAsync(request, _cancellationToken);
|
||||
return true;
|
||||
} catch (RpcException e) {
|
||||
if (e.StatusCode == StatusCode.Cancelled) {
|
||||
@@ -835,9 +726,6 @@ namespace eagle {
|
||||
} else {
|
||||
throw;
|
||||
}
|
||||
} catch (OperationCanceledException) {
|
||||
// Timeout was triggered
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -914,13 +802,7 @@ namespace eagle {
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
|
||||
var shardokResp = gameUpdate.ShardokActionResultResponse;
|
||||
var shardokGameCount = shardokResp.ShardokGameResponses?.Count ?? 0;
|
||||
// Log each Shardok game's sequence to detect missing results
|
||||
foreach (var sgr in shardokResp.ShardokGameResponses) {
|
||||
var resultCount = sgr.ActionResultViews?.Count ?? 0;
|
||||
LogFlow($"UPDATE ShardokResult game={gameUpdate.GameId} " +
|
||||
$"shardok={sgr.ShardokGameId} countAfter={sgr.NewResultViewCount} " +
|
||||
$"results={resultCount}");
|
||||
}
|
||||
LogFlow($"UPDATE ShardokResult game={gameUpdate.GameId} shardokGames={shardokGameCount}");
|
||||
|
||||
// Update result counts IMMEDIATELY on the gRPC thread, before enqueueing.
|
||||
lock (this) {
|
||||
@@ -1137,19 +1019,6 @@ namespace eagle {
|
||||
CancelAllPendingSubscriptionAcks();
|
||||
|
||||
ScheduleReconnect("ObjectDisposed");
|
||||
} catch (Exception e) {
|
||||
// Catch-all for any unexpected exceptions. Without this, exceptions in an
|
||||
// async void method can crash the process or be silently lost, leaving the
|
||||
// connection dead with no recovery.
|
||||
_lastDisconnect = DateTime.UtcNow;
|
||||
LogConnectionEvent("disconnect", $"UnexpectedException: {e.GetType().Name}");
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"UNEXPECTED exception in HandleStreamingCall: {e}");
|
||||
|
||||
MarkAllShardokGamesForResync();
|
||||
CancelAllPendingSubscriptionAcks();
|
||||
|
||||
ScheduleReconnect($"UnexpectedException-{e.GetType().Name}");
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -1184,15 +1053,9 @@ namespace eagle {
|
||||
Interval = 5000 // Check every 5 seconds
|
||||
};
|
||||
_idleCheckTimer.Elapsed += (sender, args) => {
|
||||
try {
|
||||
// Log BEFORE callback to confirm timer is firing
|
||||
Console.WriteLine($"[IDLE_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
|
||||
CheckForIdleTimeout();
|
||||
} catch (Exception e) {
|
||||
// Timer callbacks must not throw - it can stop the timer from firing again
|
||||
Console.WriteLine($"[IDLE_TIMER] Exception in callback: {e}");
|
||||
_remoteEagleClientLogger.LogLine($"Exception in idle check timer: {e}");
|
||||
}
|
||||
// Log BEFORE callback to confirm timer is firing
|
||||
Console.WriteLine($"[IDLE_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
|
||||
CheckForIdleTimeout();
|
||||
};
|
||||
_idleCheckTimer.Enabled = true;
|
||||
}
|
||||
@@ -1238,7 +1101,7 @@ namespace eagle {
|
||||
if (toCancel != null) {
|
||||
toCancel.Dispose();
|
||||
lock (this) { _streamingCall = null; }
|
||||
RunConnectAsync();
|
||||
Task.Run(() => Connect());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1248,24 +1111,9 @@ namespace eagle {
|
||||
_heartbeatTimer =
|
||||
new Timer { AutoReset = true, Interval = HeartbeatIntervalSeconds * 1000 };
|
||||
_heartbeatTimer.Elapsed += (sender, args) => {
|
||||
try {
|
||||
// Log BEFORE Task.Run to confirm timer is firing
|
||||
Console.WriteLine(
|
||||
$"[HEARTBEAT_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
|
||||
Task.Run(async () => {
|
||||
try {
|
||||
await SendHeartbeat();
|
||||
} catch (Exception e) {
|
||||
// Log but don't rethrow - exceptions in Task.Run are silently lost
|
||||
Console.WriteLine($"[HEARTBEAT] Exception in SendHeartbeat: {e}");
|
||||
_remoteEagleClientLogger.LogLine($"Exception in SendHeartbeat: {e}");
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
// Timer callbacks must not throw
|
||||
Console.WriteLine($"[HEARTBEAT_TIMER] Exception in callback: {e}");
|
||||
_remoteEagleClientLogger.LogLine($"Exception in heartbeat timer: {e}");
|
||||
}
|
||||
// Log BEFORE Task.Run to confirm timer is firing
|
||||
Console.WriteLine($"[HEARTBEAT_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
|
||||
Task.Run(() => SendHeartbeat());
|
||||
};
|
||||
_heartbeatTimer.Enabled = true;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Auth;
|
||||
using Cysharp.Net.Http;
|
||||
using eagle;
|
||||
using Grpc.Core;
|
||||
@@ -58,25 +57,6 @@ public class EagleConnection : IDisposable {
|
||||
};
|
||||
}
|
||||
|
||||
private CallInvoker MakeJwtInvoker(string url) {
|
||||
var jwtInterceptor = new JwtAuthInterceptor();
|
||||
|
||||
_channel = GrpcChannel.ForAddress(
|
||||
"https://" + url,
|
||||
new GrpcChannelOptions {
|
||||
HttpHandler = CreateHttpHandler(),
|
||||
DisposeHttpClient = true,
|
||||
LoggerFactory = _loggerFactory,
|
||||
MaxReceiveMessageSize = null
|
||||
});
|
||||
|
||||
var invoker = _channel.Intercept(jwtInterceptor);
|
||||
return invoker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create connection using Basic Auth (legacy).
|
||||
/// </summary>
|
||||
public EagleConnection(string playerName, string password, string url) {
|
||||
this.playerName = playerName;
|
||||
credentials = new Metadata { { "user", playerName } };
|
||||
@@ -88,26 +68,6 @@ public class EagleConnection : IDisposable {
|
||||
EagleGrpcClient = new Eagle.EagleClient(MakeInvoker(playerName, password, url));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create connection using JWT Bearer token auth.
|
||||
/// Tokens are read from TokenStorage on each request.
|
||||
/// </summary>
|
||||
public static EagleConnection CreateWithJwt(string url) {
|
||||
return new EagleConnection(url, useJwt: true);
|
||||
}
|
||||
|
||||
private EagleConnection(string url, bool useJwt) {
|
||||
// For JWT auth, get display name from TokenStorage
|
||||
playerName = TokenStorage.DisplayName ?? "Unknown";
|
||||
credentials = new Metadata { { "user", TokenStorage.UserId ?? "" } };
|
||||
authHeader = null; // Not used for JWT
|
||||
|
||||
_loggerFactory = LoggerFactory.Create(
|
||||
builder => { builder.AddProvider(new CustomFileLoggerProvider()); });
|
||||
|
||||
EagleGrpcClient = new Eagle.EagleClient(MakeJwtInvoker(url));
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
try {
|
||||
_channel?.Dispose();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -413,7 +413,6 @@ public class HexGrid : MonoBehaviour {
|
||||
}
|
||||
|
||||
public void SetUnitInfoLabels(int cellIndex, string mainValue, string secondaryValue = null) {
|
||||
if (cells == null) return;
|
||||
cells[cellIndex].UpperUnitInfoLabel.text = mainValue;
|
||||
cells[cellIndex].LowerUnitInfoLabel.text = secondaryValue;
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,22 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using UnityEngine;
|
||||
|
||||
namespace common {
|
||||
/// <summary>
|
||||
/// Non-blocking logger that queues messages and writes them on a dedicated background thread.
|
||||
/// This ensures that file I/O issues (antivirus, cloud sync, slow disk) never block the caller.
|
||||
/// </summary>
|
||||
public sealed class Logger : IDisposable {
|
||||
private readonly StreamWriter _sw;
|
||||
private readonly ConcurrentQueue<string> _messageQueue = new ConcurrentQueue<string>();
|
||||
private readonly Thread _writerThread;
|
||||
private readonly AutoResetEvent _messageAvailable = new AutoResetEvent(false);
|
||||
private volatile bool _disposed;
|
||||
private readonly string _name;
|
||||
private readonly object _lock = new object();
|
||||
|
||||
private static Dictionary<String, Logger> _loggers = new Dictionary<String, Logger>();
|
||||
private static readonly object _loggersLock = new object();
|
||||
@@ -28,12 +18,11 @@ namespace common {
|
||||
}
|
||||
}
|
||||
|
||||
private static string CurrentTimeString() {
|
||||
private string CurrentTimeString() {
|
||||
return DateTime.UtcNow.ToString("yyyyMMdd_HHmmss.fff");
|
||||
}
|
||||
|
||||
private Logger(string name) {
|
||||
_name = name;
|
||||
var directoryPath =
|
||||
Path.Combine(Application.persistentDataPath, "eagle0", "Resources", name);
|
||||
|
||||
@@ -41,94 +30,19 @@ namespace common {
|
||||
|
||||
var logFilePath = Path.Combine(directoryPath, $"{name}_{CurrentTimeString()}.txt");
|
||||
_sw = new StreamWriter(logFilePath, append: true);
|
||||
|
||||
// Start dedicated writer thread
|
||||
_writerThread = new Thread(WriterLoop) {
|
||||
Name = $"Logger-{name}",
|
||||
IsBackground = true // Won't prevent app exit
|
||||
};
|
||||
_writerThread.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queue a log message. This method never blocks on file I/O.
|
||||
/// </summary>
|
||||
public void LogLine(string line) {
|
||||
if (_disposed) return;
|
||||
|
||||
// Format the message with timestamp immediately (captures accurate time)
|
||||
var formattedMessage = CurrentTimeString() + " " + line;
|
||||
_messageQueue.Enqueue(formattedMessage);
|
||||
_messageAvailable.Set(); // Signal the writer thread
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background thread that processes the message queue and writes to file.
|
||||
/// File I/O blocking only affects this thread, not the callers of LogLine().
|
||||
/// </summary>
|
||||
private void WriterLoop() {
|
||||
while (!_disposed) {
|
||||
try {
|
||||
// Wait for messages with timeout so we can check _disposed periodically
|
||||
_messageAvailable.WaitOne(1000);
|
||||
|
||||
// Process all queued messages
|
||||
while (_messageQueue.TryDequeue(out var message)) {
|
||||
try {
|
||||
_sw.WriteLine(message);
|
||||
} catch (Exception ex) {
|
||||
// File write failed - log to Console as fallback
|
||||
Console.WriteLine($"[Logger-{_name}] Write failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Flush after processing batch
|
||||
try {
|
||||
_sw.Flush();
|
||||
} catch (Exception ex) {
|
||||
Console.WriteLine($"[Logger-{_name}] Flush failed: {ex.Message}");
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
// Don't let any exception kill the writer thread
|
||||
Console.WriteLine($"[Logger-{_name}] WriterLoop error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Drain remaining messages on shutdown
|
||||
while (_messageQueue.TryDequeue(out var message)) {
|
||||
try {
|
||||
_sw.WriteLine(message);
|
||||
} catch { /* ignore on shutdown */
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
lock (_lock) {
|
||||
_sw.WriteLine(CurrentTimeString() + " " + line);
|
||||
_sw.Flush();
|
||||
} catch { /* ignore on shutdown */
|
||||
}
|
||||
}
|
||||
|
||||
public void Close() { Dispose(); }
|
||||
public void Close() { _sw.Close(); }
|
||||
|
||||
public void Dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
// Signal writer thread to exit and wait for it
|
||||
_messageAvailable.Set();
|
||||
if (_writerThread != null && _writerThread.IsAlive) {
|
||||
_writerThread.Join(5000); // Wait up to 5 seconds for clean shutdown
|
||||
}
|
||||
|
||||
try {
|
||||
_sw?.Dispose();
|
||||
} catch { /* ignore */
|
||||
}
|
||||
|
||||
try {
|
||||
_messageAvailable?.Dispose();
|
||||
} catch { /* ignore */
|
||||
}
|
||||
if (_sw != null) { _sw.Dispose(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Cysharp.Net.Http;
|
||||
using eagle;
|
||||
using Google.Protobuf;
|
||||
using Net.Eagle0.Eagle.Api;
|
||||
@@ -25,9 +24,10 @@ namespace common {
|
||||
// Client that doesn't follow redirects, so we can retry each hop independently
|
||||
private readonly HttpClient _noRedirectClient;
|
||||
|
||||
// Public CDN URL for headshots (no auth required)
|
||||
private static readonly Uri BaseUri =
|
||||
new("https://eagle0-headshots.sfo3.cdn.digitaloceanspaces.com/");
|
||||
// Auth header to send only to eagle0.net (not to S3 signed URLs)
|
||||
private readonly System.Net.Http.Headers.AuthenticationHeaderValue _authHeader;
|
||||
|
||||
private static readonly Uri BaseUri = new("https://eagle0.net/assets/headshots/");
|
||||
|
||||
private const int MaxRetryAttempts = 5;
|
||||
private const int MaxRedirectHops = 5;
|
||||
@@ -45,10 +45,6 @@ namespace common {
|
||||
|
||||
private readonly HashSet<string> _failedPaths = new();
|
||||
private readonly object _failedPathsLock = new();
|
||||
|
||||
// Track paths currently being fetched to avoid duplicate requests
|
||||
private readonly HashSet<string> _inFlightPaths = new();
|
||||
private readonly object _inFlightLock = new();
|
||||
private Timer _periodicRetryTimer;
|
||||
|
||||
ResourceFetcher(string relativeLocalPath, HttpClient httpClient) {
|
||||
@@ -59,19 +55,14 @@ namespace common {
|
||||
relativeLocalPath);
|
||||
Directory.CreateDirectory(_localPath);
|
||||
|
||||
// Use HTTP/2 handler for better connection reuse and multiplexing.
|
||||
// YetAnotherHttpHandler doesn't follow redirects automatically,
|
||||
// which is what we want so we can retry each hop independently.
|
||||
var handler = new YetAnotherHttpHandler {
|
||||
Http2Only = true,
|
||||
// Keep connections alive to improve reliability on poor networks
|
||||
Http2KeepAliveInterval = TimeSpan.FromSeconds(15),
|
||||
Http2KeepAliveTimeout = TimeSpan.FromSeconds(5),
|
||||
Http2KeepAliveWhileIdle = true,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(RequestTimeoutSeconds)
|
||||
};
|
||||
// Create a client that doesn't follow redirects automatically,
|
||||
// so we can retry each hop independently
|
||||
var handler = new HttpClientHandler { AllowAutoRedirect = false };
|
||||
_noRedirectClient = new HttpClient(
|
||||
handler) { Timeout = TimeSpan.FromSeconds(RequestTimeoutSeconds) };
|
||||
// Store auth header to add per-request only for eagle0.net
|
||||
// (sending it to S3 signed URLs causes HTTP 400)
|
||||
_authHeader = httpClient.DefaultRequestHeaders.Authorization;
|
||||
|
||||
// Start periodic retry timer
|
||||
_periodicRetryTimer = new Timer(
|
||||
@@ -112,45 +103,32 @@ namespace common {
|
||||
}
|
||||
|
||||
private async Task<bool> FetchRemote(string path) {
|
||||
// Check if this path is already being fetched
|
||||
lock (_inFlightLock) {
|
||||
if (_inFlightPaths.Contains(path)) {
|
||||
// Already being fetched; the existing fetch will handle _imageLoadQueue
|
||||
return false;
|
||||
}
|
||||
_inFlightPaths.Add(path);
|
||||
}
|
||||
Uri currentUri = new Uri(BaseUri, path);
|
||||
|
||||
try {
|
||||
Uri currentUri = new Uri(BaseUri, path);
|
||||
for (int hop = 0; hop < MaxRedirectHops; hop++) {
|
||||
var result = await FetchOneHopWithRetry(currentUri, path, hop);
|
||||
|
||||
for (int hop = 0; hop < MaxRedirectHops; hop++) {
|
||||
var result = await FetchOneHopWithRetry(currentUri, path, hop);
|
||||
|
||||
if (result.GotContent) {
|
||||
// Successfully received and saved content
|
||||
ClearFailed(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (result.RedirectUri != null) {
|
||||
// Follow the redirect
|
||||
currentUri = result.RedirectUri;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Failed and not a redirect
|
||||
MarkFailed(path);
|
||||
return false;
|
||||
if (result.GotContent) {
|
||||
// Successfully received and saved content
|
||||
ClearFailed(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Too many redirects
|
||||
Debug.Log($"ResourceFetcher: Too many redirects for {path}");
|
||||
if (result.RedirectUri != null) {
|
||||
// Follow the redirect
|
||||
currentUri = result.RedirectUri;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Failed and not a redirect
|
||||
MarkFailed(path);
|
||||
return false;
|
||||
} finally {
|
||||
lock (_inFlightLock) { _inFlightPaths.Remove(path); }
|
||||
}
|
||||
|
||||
// Too many redirects
|
||||
Debug.Log($"ResourceFetcher: Too many redirects for {path}");
|
||||
MarkFailed(path);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fetch a single hop with retries. Returns content, redirect, or failure.
|
||||
@@ -159,6 +137,10 @@ namespace common {
|
||||
try {
|
||||
var request =
|
||||
new HttpRequestMessage { Method = HttpMethod.Get, RequestUri = uri };
|
||||
// Only send auth header to eagle0.net, not to S3 signed URLs
|
||||
if (_authHeader != null && uri.Host.EndsWith("eagle0.net")) {
|
||||
request.Headers.Authorization = _authHeader;
|
||||
}
|
||||
var response = await _noRedirectClient.SendAsync(request);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
@@ -199,9 +181,9 @@ namespace common {
|
||||
if (attempt < MaxRetryAttempts - 1) {
|
||||
await Task.Delay(RetryDelaysMs[attempt]);
|
||||
}
|
||||
} catch (OperationCanceledException e) {
|
||||
} catch (TaskCanceledException e) {
|
||||
Debug.Log(
|
||||
$"ResourceFetcher: hop {hopIndex} canceled for {path} (attempt {attempt + 1}/{MaxRetryAttempts}): {e.Message}");
|
||||
$"ResourceFetcher: hop {hopIndex} timeout for {path} (attempt {attempt + 1}/{MaxRetryAttempts}): {e.Message}");
|
||||
if (attempt < MaxRetryAttempts - 1) {
|
||||
await Task.Delay(RetryDelaysMs[attempt]);
|
||||
}
|
||||
@@ -309,13 +291,10 @@ namespace common {
|
||||
public void Prefetch(IEnumerable<string> paths) {
|
||||
List<string> toFetch;
|
||||
lock (_failedPathsLock) {
|
||||
lock (_inFlightLock) {
|
||||
toFetch = paths.Where(path => !string.IsNullOrEmpty(path) &&
|
||||
!File.Exists(FullLocalPath(path)) &&
|
||||
!_failedPaths.Contains(path) &&
|
||||
!_inFlightPaths.Contains(path))
|
||||
.ToList();
|
||||
}
|
||||
toFetch = paths.Where(path => !string.IsNullOrEmpty(path) &&
|
||||
!File.Exists(FullLocalPath(path)) &&
|
||||
!_failedPaths.Contains(path))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
foreach (var path in toFetch) { FetchRemote(path); }
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"depth": 0,
|
||||
"source": "git",
|
||||
"dependencies": {},
|
||||
"hash": "5719afbc6b1d8cfa8729f24f0fe6a0e9dfa70f3a"
|
||||
"hash": ""
|
||||
},
|
||||
"com.unity.2d.sprite": {
|
||||
"version": "1.0.0",
|
||||
|
||||
@@ -253,8 +253,5 @@
|
||||
<Protobuf Include="..\..\..\..\..\..\..\..\..\src\main\protobuf\net\eagle0\eagle\api\command\util\expanded_combat_unit.proto" ProtoRoot="..\..\..\..\..\..\..\..\..\">
|
||||
<Link>src\main\protobuf\net\eagle0\eagle\api\command\util\expanded_combat_unit.proto</Link>
|
||||
</Protobuf>
|
||||
<Protobuf Include="..\..\..\..\..\..\..\..\..\src\main\protobuf\net\eagle0\eagle\api\auth.proto" ProtoRoot="..\..\..\..\..\..\..\..\..\" GrpcServices="Client">
|
||||
<Link>src\main\protobuf\net\eagle0\eagle\api\auth.proto</Link>
|
||||
</Protobuf>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -3,10 +3,6 @@ load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
|
||||
go_library(
|
||||
name = "admin_server_lib",
|
||||
srcs = ["admin_server.go"],
|
||||
embedsrcs = glob([
|
||||
"static/*",
|
||||
"templates/*.html",
|
||||
]),
|
||||
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/admin_server",
|
||||
visibility = ["//visibility:private"],
|
||||
deps = [
|
||||
@@ -21,10 +17,6 @@ go_binary(
|
||||
embed = [":admin_server_lib"],
|
||||
pure = "on",
|
||||
visibility = ["//visibility:public"],
|
||||
x_defs = {
|
||||
"main.buildTime": "{BUILD_TIMESTAMP}",
|
||||
"main.gitCommit": "{STABLE_GIT_COMMIT}",
|
||||
},
|
||||
)
|
||||
|
||||
# Linux x86_64 target for Docker deployment
|
||||
@@ -35,8 +27,4 @@ go_binary(
|
||||
goos = "linux",
|
||||
pure = "on",
|
||||
visibility = ["//visibility:public"],
|
||||
x_defs = {
|
||||
"main.buildTime": "{BUILD_TIMESTAMP}",
|
||||
"main.gitCommit": "{STABLE_GIT_COMMIT}",
|
||||
},
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,565 +0,0 @@
|
||||
/* Admin Server Styles - supplements Pico CSS */
|
||||
|
||||
:root {
|
||||
--pico-font-size: 16px;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
nav {
|
||||
background: var(--pico-card-background-color);
|
||||
padding: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||
}
|
||||
|
||||
nav ul {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
nav .brand {
|
||||
font-weight: bold;
|
||||
font-size: 1.25rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* Game cards */
|
||||
.game-card {
|
||||
background: var(--pico-card-background-color);
|
||||
border: 1px solid var(--pico-muted-border-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.game-card h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.game-card .meta {
|
||||
color: var(--pico-muted-color);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.game-card .players {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.game-card .actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Player badges */
|
||||
.player-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
font-size: 0.875rem;
|
||||
margin-right: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.player-badge.human {
|
||||
background: var(--pico-primary-background);
|
||||
color: var(--pico-primary-inverse);
|
||||
}
|
||||
|
||||
.player-badge.ai {
|
||||
background: var(--pico-secondary-background);
|
||||
color: var(--pico-secondary-inverse);
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge.running {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.finished {
|
||||
background: var(--pico-muted-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* History table */
|
||||
.history-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.history-table th,
|
||||
.history-table td {
|
||||
text-align: left;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.history-table tr:hover {
|
||||
background: var(--pico-card-background-color);
|
||||
}
|
||||
|
||||
.action-type {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Loading indicator */
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.htmx-request.htmx-indicator {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
|
||||
/* Page header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Breadcrumbs */
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
|
||||
.breadcrumb .separator {
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
|
||||
/* Clickable action rows */
|
||||
.action-row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.action-row:hover {
|
||||
background: var(--pico-primary-background) !important;
|
||||
color: var(--pico-primary-inverse);
|
||||
}
|
||||
|
||||
/* Action detail panel */
|
||||
.action-detail-row {
|
||||
background: var(--pico-card-background-color);
|
||||
}
|
||||
|
||||
.action-detail {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--pico-muted-border-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.action-detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||
}
|
||||
|
||||
.action-detail .close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.5rem;
|
||||
color: var(--pico-muted-color);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.action-detail .close-btn:hover {
|
||||
color: var(--pico-del-color);
|
||||
}
|
||||
|
||||
.action-json {
|
||||
background: var(--pico-code-background-color);
|
||||
padding: 1rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
overflow-x: auto;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Settings page */
|
||||
.settings-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-search input {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-count {
|
||||
color: var(--pico-muted-color);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.settings-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.settings-table th,
|
||||
.settings-table td {
|
||||
text-align: left;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.settings-table th {
|
||||
background: var(--pico-card-background-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.setting-name {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.setting-type {
|
||||
font-size: 0.7rem;
|
||||
color: var(--pico-muted-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.setting-value form {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setting-input {
|
||||
width: 100px;
|
||||
padding: 2px 6px !important;
|
||||
margin: 0 !important;
|
||||
font-size: 0.75rem;
|
||||
height: 22px !important;
|
||||
line-height: 1 !important;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.setting-default {
|
||||
font-size: 0.8rem;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
|
||||
.setting-row.modified {
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
}
|
||||
|
||||
.setting-row.modified .setting-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 2px 8px !important;
|
||||
font-size: 0.7rem;
|
||||
margin: 0 !important;
|
||||
height: 22px !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions-cell button {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.settings-note {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
background: var(--pico-card-background-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
font-size: 0.875rem;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--pico-del-color);
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--pico-ins-color);
|
||||
}
|
||||
|
||||
/* Players table */
|
||||
.players-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.players-table th,
|
||||
.players-table td {
|
||||
text-align: left;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||
}
|
||||
|
||||
.players-table th {
|
||||
background: var(--pico-card-background-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.player-type {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.player-type.human {
|
||||
background: var(--pico-primary-background);
|
||||
color: var(--pico-primary-inverse);
|
||||
}
|
||||
|
||||
.player-type.ai {
|
||||
background: var(--pico-secondary-background);
|
||||
color: var(--pico-secondary-inverse);
|
||||
}
|
||||
|
||||
/* Button variants */
|
||||
.btn-primary {
|
||||
background: var(--pico-primary-background);
|
||||
color: var(--pico-primary-inverse);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--pico-secondary-background);
|
||||
color: var(--pico-secondary-inverse);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--pico-del-color);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
/* Modal dialogs */
|
||||
dialog {
|
||||
border: 1px solid var(--pico-muted-border-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
padding: 1.5rem;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
dialog h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
dialog .form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
dialog .form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
dialog .form-group input {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dialog .modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
dialog .modal-actions button {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* Rewind button and cell */
|
||||
.rewind-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-rewind {
|
||||
padding: 2px 8px !important;
|
||||
font-size: 0.7rem;
|
||||
margin: 0 !important;
|
||||
height: 22px !important;
|
||||
line-height: 1 !important;
|
||||
background: var(--pico-del-color);
|
||||
border-color: var(--pico-del-color);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-rewind:hover {
|
||||
background: #c82333;
|
||||
border-color: #c82333;
|
||||
}
|
||||
|
||||
/* Alert messages */
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert-success,
|
||||
.alert.success {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #16a34a;
|
||||
border: 1px solid #22c55e;
|
||||
}
|
||||
|
||||
.alert-error,
|
||||
.alert.error {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #dc2626;
|
||||
border: 1px solid #ef4444;
|
||||
}
|
||||
|
||||
.alert a {
|
||||
color: inherit;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Table action buttons */
|
||||
.players-table .actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* JFR controls in nav */
|
||||
#jfr-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.jfr-status {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.jfr-on {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.jfr-off {
|
||||
background: var(--pico-muted-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.jfr-loading {
|
||||
color: var(--pico-muted-color);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.jfr-error {
|
||||
color: var(--pico-del-color);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
#jfr-controls .btn-small {
|
||||
padding: 0.25rem 0.5rem !important;
|
||||
font-size: 0.75rem;
|
||||
height: auto !important;
|
||||
line-height: 1.2 !important;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#jfr-controls button.btn-small {
|
||||
background: var(--pico-primary-background);
|
||||
color: var(--pico-primary-inverse);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#jfr-controls button.btn-small.secondary {
|
||||
background: var(--pico-secondary-background);
|
||||
color: var(--pico-secondary-inverse);
|
||||
}
|
||||
|
||||
#jfr-controls a.btn-small {
|
||||
background: var(--pico-primary-background);
|
||||
color: var(--pico-primary-inverse);
|
||||
border-radius: var(--pico-border-radius);
|
||||
}
|
||||
|
||||
/* Result div */
|
||||
.rewind-result:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Build info in header */
|
||||
.build-info {
|
||||
font-size: 0.7rem;
|
||||
font-weight: normal;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<tr class="action-detail-row">
|
||||
<td colspan="6">
|
||||
<div class="action-detail">
|
||||
<div class="action-detail-header">
|
||||
<strong>Action #{{.Index}}</strong> - {{.ActionType}} (Round {{.RoundId}})
|
||||
<button class="close-btn" onclick="this.closest('tr').remove()">×</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> • {{.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()">×</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}} • {{.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'">×</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}}
|
||||
+10
-43
@@ -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,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
|
||||
|
@@ -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",
|
||||
|
||||
@@ -8,9 +8,9 @@ import io.grpc.{Server, ServerBuilder}
|
||||
import net.eagle0.common.{FunctionalRandom, SeededRandom, SimpleTimedLogger}
|
||||
import net.eagle0.eagle.api.auth.auth.AuthGrpc
|
||||
import net.eagle0.eagle.api.eagle.EagleGrpc
|
||||
import net.eagle0.eagle.auth.{JwtService, JwtServiceImpl, OAuthService, OAuthServiceImpl, UserServiceImpl}
|
||||
import net.eagle0.eagle.auth.{JwtService, JwtServiceImpl, OAuthServiceImpl, UserServiceImpl}
|
||||
import net.eagle0.eagle.service.*
|
||||
import net.eagle0.eagle.service.persistence.{AsyncS3Persister, LocalFilePersister, SaveDirectory}
|
||||
import net.eagle0.eagle.service.persistence.{LocalFilePersister, SaveDirectory}
|
||||
|
||||
object Main {
|
||||
import ServerSetupHelpers.*
|
||||
@@ -25,12 +25,6 @@ object Main {
|
||||
|
||||
private val gptModelNameKey = Symbol("gptModelName")
|
||||
|
||||
private val serverBaseUrlKey = Symbol("serverBaseUrl")
|
||||
private val defaultServerBaseUrl = "https://prod.eagle0.net"
|
||||
|
||||
private val oauthHttpPortKey = Symbol("oauthHttpPort")
|
||||
private val defaultOauthHttpPort = "8080"
|
||||
|
||||
def parsedOptions(args: Array[String]): Map[Symbol, String] = {
|
||||
def nextOption(
|
||||
map: Map[Symbol, String],
|
||||
@@ -49,16 +43,6 @@ object Main {
|
||||
map ++ Map(gptModelNameKey -> value),
|
||||
tail
|
||||
)
|
||||
case "--server-base-url" +: value +: tail =>
|
||||
nextOption(
|
||||
map ++ Map(serverBaseUrlKey -> value),
|
||||
tail
|
||||
)
|
||||
case "--oauth-http-port" +: value +: tail =>
|
||||
nextOption(
|
||||
map ++ Map(oauthHttpPortKey -> value),
|
||||
tail
|
||||
)
|
||||
case option +: _ =>
|
||||
throw new IllegalArgumentException("Unknown option " + option)
|
||||
|
||||
@@ -70,9 +54,6 @@ object Main {
|
||||
def main(args: Array[String]): Unit = {
|
||||
SimpleTimedLogger.printLogger.logLine("Starting!")
|
||||
|
||||
// Register shutdown hook to flush pending S3 saves on graceful shutdown
|
||||
AsyncS3Persister.registerShutdownHook()
|
||||
|
||||
val options = parsedOptions(args)
|
||||
SimpleTimedLogger.printLogger.logLine(s"Options: $options")
|
||||
|
||||
@@ -93,24 +74,11 @@ object Main {
|
||||
val customBattleManager = newCustomBattleManager(shardokInterfaceAddress)
|
||||
customBattleManager.begin()
|
||||
|
||||
val serverBaseUrl =
|
||||
options.getOrElse(serverBaseUrlKey, defaultServerBaseUrl)
|
||||
|
||||
// Create OAuth service (shared between gRPC and HTTP)
|
||||
val oauthService = new OAuthServiceImpl(serverBaseUrl)
|
||||
|
||||
// Start OAuth HTTP server for callbacks
|
||||
val oauthHttpPort =
|
||||
options.getOrElse(oauthHttpPortKey, defaultOauthHttpPort).toInt
|
||||
val oauthHttpHandler = new OAuthHttpHandler(oauthService, oauthHttpPort)
|
||||
oauthHttpHandler.start()
|
||||
|
||||
val server = buildServer(
|
||||
gamesManager,
|
||||
customBattleManager,
|
||||
options.getOrElse(eagleGrpcPortKey, defaultEagleGrpcPort).toInt,
|
||||
SeededRandom(random.nextLong()),
|
||||
oauthService
|
||||
SeededRandom(random.nextLong())
|
||||
).start
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
s"Eagle server is listening on port ${server.getPort}"
|
||||
@@ -123,8 +91,7 @@ object Main {
|
||||
gamesManager: GamesManager,
|
||||
customBattleManager: CustomBattleManager,
|
||||
grpcPort: Int,
|
||||
functionalRandom: FunctionalRandom,
|
||||
oauthService: OAuthService
|
||||
functionalRandom: FunctionalRandom
|
||||
): Server = {
|
||||
val serverBuilder = ServerBuilder.forPort(grpcPort)
|
||||
|
||||
@@ -132,6 +99,7 @@ object Main {
|
||||
val jwtService: Option[JwtService] = JwtServiceImpl.fromEnvironment()
|
||||
val authPersister = LocalFilePersister(SaveDirectory.saveDirectory)
|
||||
val userService = new UserServiceImpl(authPersister)
|
||||
val oauthService = new OAuthServiceImpl()
|
||||
|
||||
// Add Eagle game service
|
||||
serverBuilder.addService(
|
||||
|
||||
@@ -7,6 +7,7 @@ import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
|
||||
import net.eagle0.eagle.api.available_command.AvailableCommand
|
||||
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
|
||||
import net.eagle0.eagle.api.selected_command.SelectedCommand
|
||||
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
|
||||
import net.eagle0.eagle.library.util.*
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.{
|
||||
AlmsCommandSelector,
|
||||
@@ -15,7 +16,7 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
|
||||
}
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
|
||||
import net.eagle0.eagle.library.Engine
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
|
||||
|
||||
case class AIClientWithSelectedCommand(
|
||||
client: AIClient,
|
||||
@@ -54,13 +55,13 @@ case class AIClient(
|
||||
else
|
||||
chooseFrom(
|
||||
maybeCommandsMap.get.commandsByProvince,
|
||||
engine.currentState,
|
||||
GameStateConverter.toProto(engine.currentState),
|
||||
functionalRandom
|
||||
)
|
||||
}
|
||||
|
||||
private def chooseMidGameCommandFrom(
|
||||
gameState: GameState,
|
||||
gameState: GameStateProto,
|
||||
oneProvinceAvailableCommands: OneProvinceAvailableCommands,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[AIClientWithSelectedCommand] =
|
||||
@@ -81,7 +82,7 @@ case class AIClient(
|
||||
fr
|
||||
) =>
|
||||
logger.logLine(
|
||||
s"${gameState.currentDate.map(_.year).getOrElse("?")} ${gameState.currentDate.map(_.month).getOrElse("?")} ${gameState.currentPhase}"
|
||||
s"${gameState.currentDate.get.year} ${gameState.currentDate.get.month} ${gameState.currentPhase}"
|
||||
)
|
||||
logger.logLine(
|
||||
s"$actingProvinceId (${gameState.provinces(actingProvinceId).name})"
|
||||
@@ -115,7 +116,7 @@ case class AIClient(
|
||||
|
||||
private def chooseFrom(
|
||||
acs: Map[ProvinceId, OneProvinceAvailableCommands],
|
||||
gs: GameState,
|
||||
gs: GameStateProto,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[AIClientWithSelectedCommand] = {
|
||||
val opac = acs.head._2
|
||||
@@ -186,7 +187,7 @@ case class AIClient(
|
||||
),
|
||||
(
|
||||
fid: FactionId,
|
||||
gameState: GameState,
|
||||
gs: GameStateProto,
|
||||
acs: Vector[AvailableCommand],
|
||||
functionalRandom
|
||||
) =>
|
||||
@@ -194,7 +195,7 @@ case class AIClient(
|
||||
AlmsCommandSelector
|
||||
.chosenAlmsForSupportWithMinimumCommandForProvince(
|
||||
actingFactionId = fid,
|
||||
gameState = gameState,
|
||||
gameState = GameStateConverter.fromProto(gs),
|
||||
availableCommands = acs,
|
||||
minimumFood = 0,
|
||||
provinceId = opac.provinceId
|
||||
|
||||
@@ -1,78 +1,27 @@
|
||||
package net.eagle0.eagle.ai
|
||||
|
||||
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
|
||||
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
|
||||
import net.eagle0.eagle.internal.province.Province as ProvinceProto
|
||||
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
|
||||
import net.eagle0.eagle.library.util.hero.{HeroUtils, LegacyHeroUtils}
|
||||
import net.eagle0.eagle.model.state.faction.FactionT
|
||||
import net.eagle0.eagle.model.state.hero.HeroT
|
||||
import net.eagle0.eagle.model.state.province.ProvinceT
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.internal.province.Province
|
||||
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
|
||||
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
|
||||
|
||||
object AIClientUtils {
|
||||
def extraHeroCount(pid: ProvinceId, fid: FactionId, gs: GameStateProto): Int =
|
||||
def extraHeroCount(pid: ProvinceId, fid: FactionId, gs: GameState): Int =
|
||||
(gs.provinces(pid).rulingFactionHeroIds.size - desiredHeroCount(
|
||||
pid,
|
||||
fid,
|
||||
gs
|
||||
)).max(0)
|
||||
|
||||
/** Protoless overload */
|
||||
def extraHeroCount(
|
||||
province: ProvinceT,
|
||||
fid: FactionId,
|
||||
allProvinces: Map[ProvinceId, ProvinceT],
|
||||
factions: Vector[FactionT]
|
||||
): Int =
|
||||
(province.rulingFactionHeroIds.size - desiredHeroCount(
|
||||
province,
|
||||
fid,
|
||||
allProvinces,
|
||||
factions
|
||||
)).max(0)
|
||||
|
||||
def desiredHeroCount(pid: ProvinceId, fid: FactionId, gs: GameStateProto): Int =
|
||||
def desiredHeroCount(pid: ProvinceId, fid: FactionId, gs: GameState): Int =
|
||||
if LegacyFactionUtils.hostileNeighbors(pid, fid, gs).nonEmpty then 3
|
||||
else if gs.provinces(pid).support < 65 then 2
|
||||
else 1
|
||||
|
||||
/** Protoless overload */
|
||||
def desiredHeroCount(
|
||||
province: ProvinceT,
|
||||
fid: FactionId,
|
||||
allProvinces: Map[ProvinceId, ProvinceT],
|
||||
factions: Vector[FactionT]
|
||||
): Int =
|
||||
if FactionUtils.hostileNeighbors(province, fid, allProvinces, factions).nonEmpty then 3
|
||||
else if province.support < 65 then 2
|
||||
else 1
|
||||
|
||||
def desiredCountForMarchTowardFocus(
|
||||
originProvince: ProvinceProto,
|
||||
destinationProvince: ProvinceProto,
|
||||
availableHeroIds: Vector[HeroId],
|
||||
factionLeaders: Vector[HeroId],
|
||||
favorLeaders: Boolean
|
||||
): Int =
|
||||
if favorLeaders &&
|
||||
originProvince.rulingFactionHeroIds.size == 2 &&
|
||||
originProvince.rulingFactionHeroIds
|
||||
.intersect(factionLeaders)
|
||||
.nonEmpty &&
|
||||
destinationProvince.rulingFactionHeroIds
|
||||
.intersect(factionLeaders)
|
||||
.isEmpty
|
||||
then 1
|
||||
else
|
||||
Vector(
|
||||
10,
|
||||
originProvince.rulingFactionHeroIds.size - 2,
|
||||
availableHeroIds.size
|
||||
).min
|
||||
|
||||
def desiredCountForMarchTowardFocus(
|
||||
originProvince: ProvinceT,
|
||||
destinationProvince: ProvinceT,
|
||||
originProvince: Province,
|
||||
destinationProvince: Province,
|
||||
availableHeroIds: Vector[HeroId],
|
||||
factionLeaders: Vector[HeroId],
|
||||
favorLeaders: Boolean
|
||||
@@ -94,11 +43,11 @@ object AIClientUtils {
|
||||
).min
|
||||
|
||||
def takenHeroIdsForMarchTowardFocus(
|
||||
originProvince: ProvinceProto,
|
||||
destinationProvince: ProvinceProto,
|
||||
originProvince: Province,
|
||||
destinationProvince: Province,
|
||||
availableHeroIds: Vector[HeroId],
|
||||
favorLeaders: Boolean,
|
||||
gs: GameStateProto
|
||||
gs: GameState
|
||||
): Vector[HeroId] =
|
||||
mostPowerfulHeroes(
|
||||
desiredCountForMarchTowardFocus(
|
||||
@@ -113,31 +62,9 @@ object AIClientUtils {
|
||||
favorLeaders
|
||||
)
|
||||
|
||||
def takenHeroIdsForMarchTowardFocus(
|
||||
originProvince: ProvinceT,
|
||||
destinationProvince: ProvinceT,
|
||||
availableHeroIds: Vector[HeroId],
|
||||
favorLeaders: Boolean,
|
||||
factions: Vector[FactionT],
|
||||
heroes: Map[HeroId, HeroT]
|
||||
): Vector[HeroId] =
|
||||
mostPowerfulHeroes(
|
||||
desiredCountForMarchTowardFocus(
|
||||
originProvince = originProvince,
|
||||
destinationProvince = destinationProvince,
|
||||
availableHeroIds = availableHeroIds,
|
||||
factionLeaders = factions.find(_.id == originProvince.rulingFactionId.get).get.leaderIds,
|
||||
favorLeaders = favorLeaders
|
||||
),
|
||||
availableHeroIds,
|
||||
favorLeaders,
|
||||
factions,
|
||||
heroes
|
||||
)
|
||||
|
||||
def mostPowerfulHeroes(
|
||||
desiredCount: Int,
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
availableHeroIds: Vector[HeroId],
|
||||
favorLeaders: Boolean
|
||||
): Vector[HeroId] = {
|
||||
@@ -157,28 +84,4 @@ object AIClientUtils {
|
||||
.map(_.id)
|
||||
.take(desiredCount)
|
||||
}
|
||||
|
||||
def mostPowerfulHeroes(
|
||||
desiredCount: Int,
|
||||
availableHeroIds: Vector[HeroId],
|
||||
favorLeaders: Boolean,
|
||||
factions: Vector[FactionT],
|
||||
heroes: Map[HeroId, HeroT]
|
||||
): Vector[HeroId] = {
|
||||
val availableHeroes = availableHeroIds
|
||||
.map(heroes)
|
||||
|
||||
if availableHeroes.size <= desiredCount then availableHeroes.map(_.id)
|
||||
else
|
||||
availableHeroes
|
||||
.sortBy(hero =>
|
||||
(
|
||||
if favorLeaders then !FactionUtils.isFactionLeader(hero.id, factions)
|
||||
else FactionUtils.isFactionLeader(hero.id, factions),
|
||||
-HeroUtils.power(hero)
|
||||
)
|
||||
)
|
||||
.map(_.id)
|
||||
.take(desiredCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:alms_command_selector",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:attack_decision_command_chooser",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
|
||||
],
|
||||
)
|
||||
@@ -50,13 +51,8 @@ scala_library(
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/faction",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/hero",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -68,9 +64,6 @@ scala_library(
|
||||
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle/service:__pkg__",
|
||||
],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
deps = [
|
||||
":resolve_diplomacy_command_selector",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
|
||||
@@ -93,9 +86,6 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle/service:__pkg__",
|
||||
],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
|
||||
@@ -103,11 +93,8 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/province",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -117,20 +104,12 @@ scala_library(
|
||||
visibility = [
|
||||
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
|
||||
],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/province",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -140,10 +119,7 @@ scala_library(
|
||||
visibility = [
|
||||
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
|
||||
],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
exports = ["//src/main/scala/net/eagle0/eagle/library/util:command_selection"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
|
||||
@@ -151,9 +127,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:available_command_selector",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -169,12 +143,11 @@ scala_library(
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:battalion_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:legacy_battalion_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -188,7 +161,6 @@ scala_library(
|
||||
exports = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
deps = [
|
||||
":ai_client_utils",
|
||||
@@ -227,8 +199,6 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -238,9 +208,6 @@ scala_library(
|
||||
visibility = [
|
||||
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
|
||||
],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
deps = [
|
||||
":faction_leader_province_ranker",
|
||||
":march_toward_province_command_chooser",
|
||||
@@ -250,9 +217,6 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/faction",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -262,9 +226,6 @@ scala_library(
|
||||
visibility = [
|
||||
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
|
||||
],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
|
||||
@@ -283,7 +244,6 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/faction",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -2,61 +2,25 @@ package net.eagle0.eagle.ai
|
||||
|
||||
import net.eagle0.common.{FunctionalRandom, RandomState}
|
||||
import net.eagle0.eagle.api.available_command.AvailableCommand
|
||||
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.{CommandChooser, LegacyCommandChooser}
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers.{
|
||||
chosenCommandWhileTraveling,
|
||||
chosenDevelopCommand,
|
||||
chosenUnderAttackCommand
|
||||
}
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.{
|
||||
DeterministicCommandChooser,
|
||||
LegacyDeterministicCommandChooser,
|
||||
LegacyRandomCommandChooser,
|
||||
RandomCommandChooser
|
||||
}
|
||||
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
|
||||
import net.eagle0.eagle.library.util.province.{LegacyProvinceUtils, ProvinceUtils}
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooser
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.DeterministicCommandChooser
|
||||
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
|
||||
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
|
||||
import net.eagle0.eagle.library.util.CommandSelection
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
object EarlyGameAIClient {
|
||||
def isEarlyGame(gs: GameStateProto, factionId: FactionId): Boolean =
|
||||
def isEarlyGame(gs: GameState, factionId: FactionId): Boolean =
|
||||
LegacyFactionUtils.provinceCount(factionId, gs) == 1 && !LegacyFactionUtils
|
||||
.provinces(factionId, gs)
|
||||
.exists(LegacyProvinceUtils.getsTaxes)
|
||||
|
||||
/** Protoless overload */
|
||||
def isEarlyGame(gs: GameState, factionId: FactionId): Boolean =
|
||||
FactionUtils.provinceCount(factionId, gs.provinces.values) == 1 && !FactionUtils
|
||||
.provinces(factionId, gs.provinces.values.toVector)
|
||||
.exists(ProvinceUtils.getsTaxes)
|
||||
|
||||
def chooseEarlyGameCommand(
|
||||
actingFactionId: FactionId,
|
||||
gs: GameStateProto,
|
||||
acs: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[CommandSelection] =
|
||||
CommandChooser
|
||||
.choose(
|
||||
actingFactionId = actingFactionId,
|
||||
gameState = gs,
|
||||
availableCommands = acs,
|
||||
rankedChoosers = Vector[LegacyCommandChooser](
|
||||
(fid: FactionId, gs: GameStateProto, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
|
||||
chosenCommandWhileTraveling(fid, gs, acs, fr),
|
||||
(fid: FactionId, gs: GameStateProto, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
|
||||
RandomState(chosenUnderAttackCommand(fid, gs, acs), fr),
|
||||
(fid: FactionId, gs: GameStateProto, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
|
||||
chosenDevelopCommand(fid, gs, acs, fr)
|
||||
),
|
||||
functionalRandom = functionalRandom
|
||||
)
|
||||
.map(_.get)
|
||||
|
||||
/** Protoless overload */
|
||||
def chooseEarlyGameCommand(
|
||||
actingFactionId: FactionId,
|
||||
gs: GameState,
|
||||
@@ -69,12 +33,9 @@ object EarlyGameAIClient {
|
||||
gameState = gs,
|
||||
availableCommands = acs,
|
||||
rankedChoosers = Vector[CommandChooser](
|
||||
(fid: FactionId, gs: GameState, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
|
||||
chosenCommandWhileTraveling(fid, gs, acs, fr),
|
||||
(fid: FactionId, gs: GameState, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
|
||||
RandomState(chosenUnderAttackCommand(fid, gs, acs), fr),
|
||||
(fid: FactionId, gs: GameState, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
|
||||
chosenDevelopCommand(fid, gs, acs, fr)
|
||||
chosenCommandWhileTraveling _,
|
||||
chosenUnderAttackCommand _,
|
||||
chosenDevelopCommand _
|
||||
),
|
||||
functionalRandom = functionalRandom
|
||||
)
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
package net.eagle0.eagle.ai
|
||||
|
||||
import net.eagle0.eagle.{FactionId, ProvinceId}
|
||||
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
|
||||
import net.eagle0.eagle.internal.province.Province as ProvinceProto
|
||||
import net.eagle0.eagle.library.util.{LegacyProvinceDistances, ProvinceDistances}
|
||||
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
|
||||
import net.eagle0.eagle.library.util.province.{LegacyProvinceUtils, ProvinceUtils}
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.province.ProvinceT
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.internal.province.Province
|
||||
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
|
||||
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
|
||||
import net.eagle0.eagle.library.util.LegacyProvinceDistances
|
||||
|
||||
object FactionLeaderProvinceRanker {
|
||||
// Proto versions delegate to protoless implementations
|
||||
private def factionLeaderProvinceOrdering(
|
||||
factionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
fromProvinceId: ProvinceId
|
||||
): Ordering[ProvinceProto] = factionLeaderProvinceOrdering(factionId, gameState)
|
||||
): Ordering[Province] = factionLeaderProvinceOrdering(factionId, gameState)
|
||||
.orElse(
|
||||
nearestThroughFriendliesOrdering(factionId, gameState, fromProvinceId)
|
||||
)
|
||||
@@ -23,9 +20,9 @@ object FactionLeaderProvinceRanker {
|
||||
// Orders by nearest-through-friendlies first
|
||||
private def nearestThroughFriendliesOrdering(
|
||||
factionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
fromProvinceId: ProvinceId
|
||||
): Ordering[ProvinceProto] = Ordering.by((p: ProvinceProto) =>
|
||||
): Ordering[Province] = Ordering.by((p: Province) =>
|
||||
LegacyProvinceDistances.distanceThroughFriendliesOption(
|
||||
fromProvinceId,
|
||||
p.id,
|
||||
@@ -37,27 +34,27 @@ object FactionLeaderProvinceRanker {
|
||||
// Orders by most-hostile-neighbors-first
|
||||
private def hostileNeighborCountOrdering(
|
||||
factionId: FactionId,
|
||||
gameState: GameStateProto
|
||||
): Ordering[ProvinceProto] = Ordering
|
||||
.by((p: ProvinceProto) => LegacyFactionUtils.hostileNeighbors(p, factionId, gameState).size)
|
||||
gameState: GameState
|
||||
): Ordering[Province] = Ordering
|
||||
.by((p: Province) => LegacyFactionUtils.hostileNeighbors(p, factionId, gameState).size)
|
||||
.reverse
|
||||
|
||||
// Orders by most-neutral-neighbors-first
|
||||
private def neutralNeighborCountOrdering(
|
||||
gameState: GameStateProto
|
||||
): Ordering[ProvinceProto] =
|
||||
Ordering.by((p: ProvinceProto) => neutralNeighborCountProto(p, gameState)).reverse
|
||||
gameState: GameState
|
||||
): Ordering[Province] =
|
||||
Ordering.by((p: Province) => neutralNeighborCount(p, gameState)).reverse
|
||||
|
||||
private def factionLeaderProvinceOrdering(
|
||||
factionId: FactionId,
|
||||
gameState: GameStateProto
|
||||
): Ordering[ProvinceProto] =
|
||||
gameState: GameState
|
||||
): Ordering[Province] =
|
||||
hostileNeighborCountOrdering(factionId = factionId, gameState = gameState)
|
||||
.orElse(neutralNeighborCountOrdering(gameState))
|
||||
|
||||
private def neutralNeighborCountProto(
|
||||
province: ProvinceProto,
|
||||
gameState: GameStateProto
|
||||
private def neutralNeighborCount(
|
||||
province: Province,
|
||||
gameState: GameState
|
||||
): Int =
|
||||
province.neighbors
|
||||
.map(_.provinceId)
|
||||
@@ -68,7 +65,7 @@ object FactionLeaderProvinceRanker {
|
||||
// Sorts the owned provinces for this faction, with the most desirable for faction leaders coming first
|
||||
def rankedProvinceIds(
|
||||
factionId: FactionId,
|
||||
gameState: GameStateProto
|
||||
gameState: GameState
|
||||
): Vector[ProvinceId] = {
|
||||
val ordering = factionLeaderProvinceOrdering(
|
||||
factionId = factionId,
|
||||
@@ -82,7 +79,7 @@ object FactionLeaderProvinceRanker {
|
||||
|
||||
def bestProvinceId(
|
||||
factionId: FactionId,
|
||||
gameState: GameStateProto
|
||||
gameState: GameState
|
||||
): Option[ProvinceId] = {
|
||||
val ordering = factionLeaderProvinceOrdering(
|
||||
factionId = factionId,
|
||||
@@ -96,7 +93,7 @@ object FactionLeaderProvinceRanker {
|
||||
|
||||
def bestProvinceId(
|
||||
factionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
fromProvinceId: ProvinceId
|
||||
): Option[ProvinceId] = {
|
||||
val ordering = factionLeaderProvinceOrdering(
|
||||
@@ -112,7 +109,7 @@ object FactionLeaderProvinceRanker {
|
||||
|
||||
def rankedProvinceIds(
|
||||
factionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
fromProvinceId: ProvinceId
|
||||
): Vector[ProvinceId] = {
|
||||
val ordering = factionLeaderProvinceOrdering(
|
||||
@@ -128,8 +125,8 @@ object FactionLeaderProvinceRanker {
|
||||
|
||||
def bestSettlementPidForFactionLeader(
|
||||
factionId: FactionId,
|
||||
currentProvince: ProvinceProto,
|
||||
gameState: GameStateProto
|
||||
currentProvince: Province,
|
||||
gameState: GameState
|
||||
): Option[ProvinceId] = {
|
||||
val ordering = factionLeaderProvinceOrdering(
|
||||
factionId = factionId,
|
||||
@@ -145,158 +142,6 @@ object FactionLeaderProvinceRanker {
|
||||
.map(_.id)
|
||||
}
|
||||
|
||||
def bestSettlementPidForFactionLeader(
|
||||
factionId: FactionId,
|
||||
currentProvinceId: ProvinceId,
|
||||
gameState: GameStateProto
|
||||
): Option[ProvinceId] =
|
||||
bestSettlementPidForFactionLeader(
|
||||
factionId = factionId,
|
||||
currentProvince = gameState.provinces(currentProvinceId),
|
||||
gameState = gameState
|
||||
)
|
||||
|
||||
// ==================== Protoless overloads ====================
|
||||
|
||||
private def factionLeaderProvinceOrderingC(
|
||||
factionId: FactionId,
|
||||
gameState: GameState,
|
||||
fromProvinceId: ProvinceId
|
||||
): Ordering[ProvinceT] = factionLeaderProvinceOrderingC(factionId, gameState)
|
||||
.orElse(
|
||||
nearestThroughFriendliesOrderingC(factionId, gameState, fromProvinceId)
|
||||
)
|
||||
|
||||
private def nearestThroughFriendliesOrderingC(
|
||||
factionId: FactionId,
|
||||
gameState: GameState,
|
||||
fromProvinceId: ProvinceId
|
||||
): Ordering[ProvinceT] = Ordering.by((p: ProvinceT) =>
|
||||
ProvinceDistances.distanceThroughFriendliesOption(
|
||||
fromProvinceId,
|
||||
p.id,
|
||||
factionId,
|
||||
gameState.provinces
|
||||
)
|
||||
)
|
||||
|
||||
private def hostileNeighborCountOrderingC(
|
||||
factionId: FactionId,
|
||||
gameState: GameState
|
||||
): Ordering[ProvinceT] = Ordering
|
||||
.by((p: ProvinceT) =>
|
||||
FactionUtils.hostileNeighbors(p, factionId, gameState.provinces, gameState.factions.values.toVector).size
|
||||
)
|
||||
.reverse
|
||||
|
||||
private def neutralNeighborCountOrderingC(
|
||||
gameState: GameState
|
||||
): Ordering[ProvinceT] =
|
||||
Ordering.by((p: ProvinceT) => neutralNeighborCount(p, gameState)).reverse
|
||||
|
||||
private def factionLeaderProvinceOrderingC(
|
||||
factionId: FactionId,
|
||||
gameState: GameState
|
||||
): Ordering[ProvinceT] =
|
||||
hostileNeighborCountOrderingC(factionId = factionId, gameState = gameState)
|
||||
.orElse(neutralNeighborCountOrderingC(gameState))
|
||||
|
||||
private def neutralNeighborCount(
|
||||
province: ProvinceT,
|
||||
gameState: GameState
|
||||
): Int =
|
||||
province.neighbors
|
||||
.map(_.provinceId)
|
||||
.map(gameState.provinces)
|
||||
.filterNot(_.rulingFactionId.isDefined)
|
||||
.size
|
||||
|
||||
/** Protoless overload */
|
||||
def rankedProvinceIds(
|
||||
factionId: FactionId,
|
||||
gameState: GameState
|
||||
): Vector[ProvinceId] = {
|
||||
val ordering = factionLeaderProvinceOrderingC(
|
||||
factionId = factionId,
|
||||
gameState = gameState
|
||||
)
|
||||
FactionUtils
|
||||
.provinces(factionId, gameState.provinces.values.toVector)
|
||||
.sorted(ordering)
|
||||
.map(_.id)
|
||||
}
|
||||
|
||||
/** Protoless overload */
|
||||
def bestProvinceId(
|
||||
factionId: FactionId,
|
||||
gameState: GameState
|
||||
): Option[ProvinceId] = {
|
||||
val ordering = factionLeaderProvinceOrderingC(
|
||||
factionId = factionId,
|
||||
gameState = gameState
|
||||
)
|
||||
FactionUtils
|
||||
.provinces(factionId, gameState.provinces.values.toVector)
|
||||
.minOption(ordering)
|
||||
.map(_.id)
|
||||
}
|
||||
|
||||
/** Protoless overload */
|
||||
def bestProvinceId(
|
||||
factionId: FactionId,
|
||||
gameState: GameState,
|
||||
fromProvinceId: ProvinceId
|
||||
): Option[ProvinceId] = {
|
||||
val ordering = factionLeaderProvinceOrderingC(
|
||||
factionId = factionId,
|
||||
gameState = gameState,
|
||||
fromProvinceId = fromProvinceId
|
||||
)
|
||||
FactionUtils
|
||||
.provinces(factionId, gameState.provinces.values.toVector)
|
||||
.minOption(ordering)
|
||||
.map(_.id)
|
||||
}
|
||||
|
||||
/** Protoless overload */
|
||||
def rankedProvinceIds(
|
||||
factionId: FactionId,
|
||||
gameState: GameState,
|
||||
fromProvinceId: ProvinceId
|
||||
): Vector[ProvinceId] = {
|
||||
val ordering = factionLeaderProvinceOrderingC(
|
||||
factionId = factionId,
|
||||
gameState = gameState,
|
||||
fromProvinceId = fromProvinceId
|
||||
)
|
||||
FactionUtils
|
||||
.provinces(factionId, gameState.provinces.values.toVector)
|
||||
.sorted(ordering)
|
||||
.map(_.id)
|
||||
}
|
||||
|
||||
/** Protoless overload */
|
||||
def bestSettlementPidForFactionLeader(
|
||||
factionId: FactionId,
|
||||
currentProvince: ProvinceT,
|
||||
gameState: GameState
|
||||
): Option[ProvinceId] = {
|
||||
val factions = gameState.factions.values.toVector
|
||||
val ordering = factionLeaderProvinceOrderingC(
|
||||
factionId = factionId,
|
||||
gameState = gameState,
|
||||
fromProvinceId = currentProvince.id
|
||||
)
|
||||
|
||||
FactionUtils
|
||||
.provinces(factionId, gameState.provinces.values.toVector)
|
||||
.filter(ordering.lt(_, currentProvince))
|
||||
.filterNot(ProvinceUtils.ruledByFactionLeader(_, factions))
|
||||
.minOption(ordering)
|
||||
.map(_.id)
|
||||
}
|
||||
|
||||
/** Protoless overload */
|
||||
def bestSettlementPidForFactionLeader(
|
||||
factionId: FactionId,
|
||||
currentProvinceId: ProvinceId,
|
||||
|
||||
@@ -4,21 +4,19 @@ import net.eagle0.common.{FunctionalRandom, RandomState}
|
||||
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand}
|
||||
import net.eagle0.eagle.api.selected_command.MarchSelectedCommand
|
||||
import net.eagle0.eagle.common.combat_unit.CombatUnit
|
||||
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.randomSelectionForType
|
||||
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
|
||||
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
|
||||
import net.eagle0.eagle.library.util.CommandSelection
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
object FixLeaderAloneCommandSelector {
|
||||
def maybeFixLeaderAlone(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] = {
|
||||
val provinces = gameState.provinces
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
randomSelectionForType[MarchAvailableCommand](
|
||||
availableCommands,
|
||||
functionalRandom
|
||||
@@ -27,74 +25,21 @@ object FixLeaderAloneCommandSelector {
|
||||
frOuter.nextFlatCollectFirst(
|
||||
ac.oneProvinceCommands
|
||||
.filter(opmc =>
|
||||
provinces(opmc.originProvinceId).rulingFactionHeroIds
|
||||
gameState
|
||||
.provinces(opmc.originProvinceId)
|
||||
.rulingFactionHeroIds
|
||||
.filterNot(LegacyFactionUtils.isFactionLeader(_, gameState))
|
||||
.size > 1
|
||||
)
|
||||
) { opmc => fr =>
|
||||
opmc.availableDestinationProvinces
|
||||
.map(dest => provinces(dest.provinceId))
|
||||
.map(dest => gameState.provinces(dest.provinceId))
|
||||
.filter(_.rulingFactionId.contains(actingFactionId))
|
||||
.filter(_.rulingFactionHeroIds.size == 1)
|
||||
.filter(dest => LegacyFactionUtils.isFactionLeader(dest.rulingFactionHeroIds.head, gameState))
|
||||
.map { destination =>
|
||||
fr.nextRandomElement(opmc.availableHeroIds)
|
||||
.map { hid =>
|
||||
CombatUnit(factionId = actingFactionId, heroId = hid)
|
||||
}
|
||||
.map { combatUnit =>
|
||||
Some(
|
||||
CommandSelection(
|
||||
actingFactionId = actingFactionId,
|
||||
actingProvinceId = ac.actingProvinceId,
|
||||
available = ac,
|
||||
selected = MarchSelectedCommand(
|
||||
gold = 0,
|
||||
food = 0,
|
||||
originProvince = opmc.originProvinceId,
|
||||
destinationProvinceId = destination.id,
|
||||
marchingUnits = Vector(
|
||||
combatUnit
|
||||
)
|
||||
),
|
||||
reason = "Moving a hero so that a faction leader won't be alone"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.headOption
|
||||
.getOrElse(RandomState(None, fr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Protoless overload */
|
||||
def maybeFixLeaderAlone(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] = {
|
||||
val factions = gameState.factions.values.toVector
|
||||
val provinces = gameState.provinces
|
||||
randomSelectionForType[MarchAvailableCommand](
|
||||
availableCommands,
|
||||
functionalRandom
|
||||
) {
|
||||
case (ac, frOuter) =>
|
||||
frOuter.nextFlatCollectFirst(
|
||||
ac.oneProvinceCommands
|
||||
.filter(opmc =>
|
||||
provinces(opmc.originProvinceId).rulingFactionHeroIds
|
||||
.filterNot(FactionUtils.isFactionLeader(_, factions))
|
||||
.size > 1
|
||||
.filter(dest =>
|
||||
LegacyFactionUtils
|
||||
.isFactionLeader(dest.rulingFactionHeroIds.head, gameState)
|
||||
)
|
||||
) { opmc => fr =>
|
||||
opmc.availableDestinationProvinces
|
||||
.map(dest => provinces(dest.provinceId))
|
||||
.filter(_.rulingFactionId.contains(actingFactionId))
|
||||
.filter(_.rulingFactionHeroIds.size == 1)
|
||||
.filter(dest => FactionUtils.isFactionLeader(dest.rulingFactionHeroIds.head, factions))
|
||||
.map { destination =>
|
||||
fr.nextRandomElement(opmc.availableHeroIds)
|
||||
.map { hid =>
|
||||
@@ -124,5 +69,4 @@ object FixLeaderAloneCommandSelector {
|
||||
.getOrElse(RandomState(None, fr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,12 @@ package net.eagle0.eagle.ai
|
||||
import net.eagle0.eagle.api.available_command.{AvailableCommand, DiplomacyAvailableCommand}
|
||||
import net.eagle0.eagle.api.command.util.diplomacy_option.InvitationOption
|
||||
import net.eagle0.eagle.api.selected_command.DiplomacySelectedCommand
|
||||
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.library.settings.MinChanceForAIInvite
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.{ProvinceGoldSurplusCalculator, TrustForDiplomacy}
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
|
||||
import net.eagle0.eagle.library.util.CommandSelection
|
||||
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
object InvitationCommandSelector {
|
||||
@@ -18,29 +17,22 @@ object InvitationCommandSelector {
|
||||
acceptanceChance: Int
|
||||
)
|
||||
|
||||
def maybeInviteOtherFaction(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
maybeInviteOtherFaction(actingFactionId, GameStateConverter.fromProto(gameState), availableCommands)
|
||||
|
||||
/** Protoless overload */
|
||||
def maybeInviteOtherFaction(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[DiplomacyAvailableCommand](acs = availableCommands) { diplomacyAvailableCommand =>
|
||||
val nativeGameState = GameStateConverter.fromProto(gameState)
|
||||
diplomacyAvailableCommand.options.collect { case io: InvitationOption => io }.filter { invitationOption =>
|
||||
TrustForDiplomacy.meetsConditionsForInvitation(
|
||||
actingFactionId = actingFactionId,
|
||||
targetFactionId = invitationOption.targetFactionId,
|
||||
gameState = gameState
|
||||
gameState = nativeGameState
|
||||
)
|
||||
}.filter { invitationOption =>
|
||||
ProvinceGoldSurplusCalculator.provinceGoldSurplus(
|
||||
gameState.provinces(diplomacyAvailableCommand.actingProvinceId)
|
||||
nativeGameState.provinces(diplomacyAvailableCommand.actingProvinceId)
|
||||
) >= invitationOption.goldCost
|
||||
}.map { invitationOption =>
|
||||
InvitationOptionWithChance(
|
||||
@@ -48,8 +40,8 @@ object InvitationCommandSelector {
|
||||
acceptanceChance = ResolveDiplomacyCommandSelector.invitationAcceptanceChance(
|
||||
actingFactionId,
|
||||
invitationOption.targetFactionId,
|
||||
gameState.factions.values.toVector,
|
||||
gameState.provinces.values.toVector
|
||||
nativeGameState.factions.values.toVector,
|
||||
nativeGameState.provinces.values.toVector
|
||||
)
|
||||
)
|
||||
}.maxByOption(_.acceptanceChance)
|
||||
|
||||
@@ -4,10 +4,10 @@ import net.eagle0.eagle.{FactionId, ProvinceId}
|
||||
import net.eagle0.eagle.api.available_command.{MarchAvailableCommand, MarchCommandFromOneProvince}
|
||||
import net.eagle0.eagle.api.selected_command.MarchSelectedCommand
|
||||
import net.eagle0.eagle.common.combat_unit.CombatUnit
|
||||
import net.eagle0.eagle.library.util.{BattalionUtils, CommandSelection, ProvinceDistances}
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.library.util.{CommandSelection, LegacyBattalionUtils, LegacyProvinceDistances}
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusCalculator.provinceGoldSurplus
|
||||
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
|
||||
object MarchTowardProvinceCommandChooser {
|
||||
def marchTowardProvinceCommand(
|
||||
@@ -23,12 +23,12 @@ object MarchTowardProvinceCommandChooser {
|
||||
.map(dest => (opmc, gs.provinces(dest.provinceId)))
|
||||
.flatMap {
|
||||
case (opmc, p) =>
|
||||
ProvinceDistances
|
||||
LegacyProvinceDistances
|
||||
.distanceThroughFriendliesOption(
|
||||
destinationProvinceId,
|
||||
p.id,
|
||||
fid,
|
||||
gs.provinces
|
||||
gs
|
||||
)
|
||||
.map(distance => (opmc, p, distance))
|
||||
}
|
||||
@@ -51,8 +51,7 @@ object MarchTowardProvinceCommandChooser {
|
||||
destinationProvince = dest,
|
||||
availableHeroIds = opmc.availableHeroIds.toVector,
|
||||
favorLeaders = favorLeaders,
|
||||
factions = gs.factions.values.toVector,
|
||||
heroes = gs.heroes
|
||||
gs = gs
|
||||
)
|
||||
|
||||
Option.when(takenHeroIds.nonEmpty) {
|
||||
@@ -75,11 +74,11 @@ object MarchTowardProvinceCommandChooser {
|
||||
CombatUnit(factionId = fid, heroId = hid, battalionId = bid)
|
||||
}
|
||||
|
||||
val foodToTake = 2 * BattalionUtils.monthlyConsumedFood(
|
||||
val foodToTake = 2 * LegacyBattalionUtils.monthlyConsumedFood(
|
||||
takenBattalions,
|
||||
gs.battalionTypes
|
||||
gs.battalionTypes.toVector
|
||||
)
|
||||
val goldToTake = provinceGoldSurplus(originProvince)
|
||||
val goldToTake = provinceGoldSurplus(originProvince.id, gs)
|
||||
CommandSelection(
|
||||
actingFactionId = fid,
|
||||
actingProvinceId = actingProvinceId,
|
||||
|
||||
@@ -9,7 +9,7 @@ import net.eagle0.eagle.api.selected_command.{MarchSelectedCommand, ReconSelecte
|
||||
import net.eagle0.eagle.common.combat_unit.CombatUnit
|
||||
import net.eagle0.eagle.common.improvement_type.ImprovementType
|
||||
import net.eagle0.eagle.internal.faction_relationship.FactionRelationship
|
||||
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.internal.province.Province
|
||||
import net.eagle0.eagle.library.settings.{MinSupportForTaxes, MonthsReconConsideredRecent}
|
||||
import net.eagle0.eagle.library.util.*
|
||||
@@ -21,7 +21,6 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
|
||||
ExpandCommandSelector,
|
||||
FulfillQuestsCommandSelector,
|
||||
ImproveCommandSelector,
|
||||
LegacyCommandChooser,
|
||||
OrganizeCommandSelector,
|
||||
ProvinceGoldSurplusCalculator
|
||||
}
|
||||
@@ -35,14 +34,12 @@ import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
|
||||
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
|
||||
import net.eagle0.eagle.library.util.LegacyProvinceDistances
|
||||
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
|
||||
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.views.battalion_view.BattalionView
|
||||
import net.eagle0.eagle.views.province_view.FullProvinceInfo
|
||||
|
||||
object MidGameAIClient {
|
||||
private def validateNoFactionLeaderAlone(
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
factionId: FactionId
|
||||
): Unit =
|
||||
LegacyFactionUtils
|
||||
@@ -58,27 +55,11 @@ object MidGameAIClient {
|
||||
)
|
||||
}
|
||||
|
||||
/** Protoless version - takes native GameState */
|
||||
def chosenMidGameCommand(
|
||||
factionId: FactionId,
|
||||
gs: GameState,
|
||||
opac: OneProvinceAvailableCommands,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[CommandSelection] =
|
||||
// Internal methods still use proto, so convert here
|
||||
chosenMidGameCommand(
|
||||
factionId = factionId,
|
||||
gs = GameStateConverter.toProto(gs),
|
||||
opac = opac,
|
||||
functionalRandom = functionalRandom
|
||||
)
|
||||
|
||||
/** Proto version */
|
||||
def chosenMidGameCommand(
|
||||
factionId: FactionId,
|
||||
gs: GameStateProto,
|
||||
opac: OneProvinceAvailableCommands,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[CommandSelection] = {
|
||||
validateNoFactionLeaderAlone(gs, factionId)
|
||||
|
||||
@@ -86,9 +67,8 @@ object MidGameAIClient {
|
||||
actingFactionId = factionId,
|
||||
gameState = gs,
|
||||
availableCommands = opac.commands.toVector,
|
||||
rankedChoosers = Vector[LegacyCommandChooser](
|
||||
(fid: FactionId, gs: GameStateProto, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
|
||||
chosenUniversalCommand(fid, gs, acs, fr),
|
||||
rankedChoosers = Vector[CommandChooser](
|
||||
chosenUniversalCommand,
|
||||
chosenRescueLeaderCommand,
|
||||
(actingFactionId, gameState, availableCommands, functionalRandom) =>
|
||||
RandomState(
|
||||
@@ -145,7 +125,7 @@ object MidGameAIClient {
|
||||
// Send supplies to a neighbor that's running low
|
||||
private def maybeChosenRelaySuppliesCommand(
|
||||
factionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[SendSuppliesAvailableCommand](availableCommands) {
|
||||
@@ -172,7 +152,7 @@ object MidGameAIClient {
|
||||
Math.min(actingProvince.food - foodToHoldBack, foodAvailable)
|
||||
val maxGoldToSend = Math.min(
|
||||
ProvinceGoldSurplusCalculator
|
||||
.provinceGoldSurplus(ProvinceConverter.fromProto(actingProvince)),
|
||||
.provinceGoldSurplus(actingProvinceId, gameState),
|
||||
goldAvailable
|
||||
)
|
||||
|
||||
@@ -233,9 +213,9 @@ object MidGameAIClient {
|
||||
|
||||
private def chosenGenericCommand(
|
||||
actingFactionId: FactionId,
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
chooser: LegacyCommandChooser,
|
||||
chooser: CommandChooser,
|
||||
functionalRandom: FunctionalRandom,
|
||||
reason: String
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
@@ -245,9 +225,9 @@ object MidGameAIClient {
|
||||
|
||||
private def chosenGenericCommandFromRankedOptions(
|
||||
actingFactionId: FactionId,
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
choosersAndReasons: Vector[(LegacyCommandChooser, String)],
|
||||
choosersAndReasons: Vector[(CommandChooser, String)],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] = choosersAndReasons match {
|
||||
case cr +: tail =>
|
||||
@@ -275,7 +255,7 @@ object MidGameAIClient {
|
||||
|
||||
private def chosenHostileNeighborsCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
@@ -283,7 +263,7 @@ object MidGameAIClient {
|
||||
actingFactionId = actingFactionId,
|
||||
gs = gameState,
|
||||
acs = availableCommands,
|
||||
choosersAndReasons = Vector[(LegacyCommandChooser, String)](
|
||||
choosersAndReasons = Vector[(CommandChooser, String)](
|
||||
(prepareAttackCommand, "prepare attack with hostile neighbors"),
|
||||
(maybeMoveToRecruitCommand, "travel to recruit"),
|
||||
(
|
||||
@@ -346,7 +326,7 @@ object MidGameAIClient {
|
||||
|
||||
private def chosenNeutralNeighborsCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
@@ -369,7 +349,7 @@ object MidGameAIClient {
|
||||
(
|
||||
(
|
||||
fid: FactionId,
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
fr: FunctionalRandom
|
||||
) =>
|
||||
@@ -430,7 +410,7 @@ object MidGameAIClient {
|
||||
|
||||
private def chosenNoNeutralNeighborsCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] = {
|
||||
@@ -519,7 +499,7 @@ object MidGameAIClient {
|
||||
|
||||
private def chosenAttackCommandWithReconInfo(
|
||||
actingFactionId: FactionId,
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] = {
|
||||
def reconnedFullInfoFor(provinceId: ProvinceId): Option[FullProvinceInfo] =
|
||||
@@ -547,7 +527,7 @@ object MidGameAIClient {
|
||||
|
||||
private def maybeChosenRaiseSupportCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
provinceId: ProvinceId
|
||||
): Option[CommandSelection] =
|
||||
@@ -573,7 +553,7 @@ object MidGameAIClient {
|
||||
|
||||
private def chosenMidGameStrategicCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
opac: OneProvinceAvailableCommands,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[CommandSelection] =
|
||||
@@ -582,7 +562,7 @@ object MidGameAIClient {
|
||||
actingFactionId = actingFactionId,
|
||||
gameState = gameState,
|
||||
availableCommands = opac.commands.toVector,
|
||||
rankedChoosers = Vector[LegacyCommandChooser](
|
||||
rankedChoosers = Vector[CommandChooser](
|
||||
(actingFactionId, gameState, availableCommands, functionalRandom) =>
|
||||
RandomState(
|
||||
MoveLeaderToBetterProvinceCommandChooser
|
||||
@@ -604,7 +584,7 @@ object MidGameAIClient {
|
||||
),
|
||||
(
|
||||
fid: FactionId,
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
fr: FunctionalRandom
|
||||
) =>
|
||||
@@ -654,7 +634,7 @@ object MidGameAIClient {
|
||||
),
|
||||
(
|
||||
fid: FactionId,
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
fr: FunctionalRandom
|
||||
) =>
|
||||
@@ -679,7 +659,7 @@ object MidGameAIClient {
|
||||
),
|
||||
(
|
||||
fid: FactionId,
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
fr: FunctionalRandom
|
||||
) =>
|
||||
@@ -704,7 +684,7 @@ object MidGameAIClient {
|
||||
.map(_.get)
|
||||
|
||||
def foodSurplus(
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
factionId: FactionId
|
||||
): Int =
|
||||
gs.provinces.values
|
||||
@@ -714,7 +694,7 @@ object MidGameAIClient {
|
||||
.max(0) / 2 // making this up
|
||||
|
||||
def maybeChosenImproveForBetterTroopsCommand(
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
factionId: FactionId
|
||||
): Option[CommandSelection] =
|
||||
@@ -781,7 +761,7 @@ object MidGameAIClient {
|
||||
|
||||
private def prepareAttackCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] = {
|
||||
@@ -803,7 +783,7 @@ object MidGameAIClient {
|
||||
actingFactionId = actingFactionId,
|
||||
gs = gameState,
|
||||
acs = availableCommands,
|
||||
choosersAndReasons = Vector[(LegacyCommandChooser, String)](
|
||||
choosersAndReasons = Vector[(CommandChooser, String)](
|
||||
(
|
||||
(fid, gs, acs, fr) =>
|
||||
RandomState(
|
||||
@@ -815,7 +795,7 @@ object MidGameAIClient {
|
||||
(
|
||||
(
|
||||
fid: FactionId,
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
fr: FunctionalRandom
|
||||
) =>
|
||||
@@ -906,7 +886,7 @@ object MidGameAIClient {
|
||||
|
||||
def maybeMoveHeroesTowardFactionLeaderCommand(
|
||||
actingFactionId: FactionId,
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] = {
|
||||
val factionLeaderProvincesNeedingHeroes =
|
||||
@@ -960,7 +940,7 @@ object MidGameAIClient {
|
||||
fid = actingFactionId,
|
||||
opmc = opmc,
|
||||
destinationProvinceId = destinationProvinceId,
|
||||
gs = GameStateConverter.fromProto(gs),
|
||||
gs = gs,
|
||||
favorLeaders = false
|
||||
)
|
||||
}
|
||||
@@ -971,7 +951,7 @@ object MidGameAIClient {
|
||||
|
||||
private def selectedReconCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
ac: ReconAvailableCommand,
|
||||
targetProvinceId: ProvinceId
|
||||
): CommandSelection =
|
||||
@@ -988,7 +968,7 @@ object MidGameAIClient {
|
||||
|
||||
def maybeChosenReconCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
acs: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] = {
|
||||
import DateProtoUtils._Date
|
||||
@@ -1065,7 +1045,7 @@ object MidGameAIClient {
|
||||
|
||||
def maybeSpreadLeadersCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
acs: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] = {
|
||||
val faction = gameState.factions(actingFactionId)
|
||||
@@ -1124,7 +1104,7 @@ object MidGameAIClient {
|
||||
|
||||
private def provincesWithFactionLeader(
|
||||
factionId: FactionId,
|
||||
gs: GameStateProto
|
||||
gs: GameState
|
||||
): Vector[Province] = LegacyFactionUtils
|
||||
.provinces(factionId, gs)
|
||||
.filter(p => LegacyProvinceUtils.ruledByFactionLeader(p, gs))
|
||||
|
||||
@@ -2,14 +2,11 @@ package net.eagle0.eagle.ai
|
||||
|
||||
import net.eagle0.eagle.{FactionId, ProvinceId}
|
||||
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand, MarchCommandFromOneProvince}
|
||||
import net.eagle0.eagle.internal.faction.Faction as FactionProto
|
||||
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
|
||||
import net.eagle0.eagle.internal.faction.Faction
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.library.util.{LegacyProvinceDistances, ProvinceDistances}
|
||||
import net.eagle0.eagle.library.util.CommandSelection
|
||||
import net.eagle0.eagle.library.util.ProvinceDistances.ProvinceAndDistance
|
||||
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
|
||||
import net.eagle0.eagle.model.state.faction.FactionT
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
|
||||
object MoveLeaderToBetterProvinceCommandChooser {
|
||||
private case class MCFOPWithDestinationAndDistance(
|
||||
@@ -18,28 +15,24 @@ object MoveLeaderToBetterProvinceCommandChooser {
|
||||
)
|
||||
|
||||
private type DestinationChooser =
|
||||
(FactionId, ProvinceId, GameStateProto) => Option[ProvinceId]
|
||||
|
||||
private type DestinationChooserC =
|
||||
(FactionId, ProvinceId, GameState) => Option[ProvinceId]
|
||||
|
||||
def maybeMoveLeaderToBetterProvinceCommand(
|
||||
actingFactionId: FactionId,
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
maybeMoveLeaderToBetterProvinceCommandWithChooser(
|
||||
actingFactionId = actingFactionId,
|
||||
gs = gs,
|
||||
acs = acs,
|
||||
betterDestinationChooser = (fid: FactionId, pid: ProvinceId, gameState: GameStateProto) =>
|
||||
FactionLeaderProvinceRanker.bestSettlementPidForFactionLeader(fid, pid, gameState)
|
||||
betterDestinationChooser = FactionLeaderProvinceRanker.bestSettlementPidForFactionLeader
|
||||
)
|
||||
|
||||
private def candidatesForMarchCommand(
|
||||
marchCommand: MarchAvailableCommand,
|
||||
gs: GameStateProto,
|
||||
faction: FactionProto
|
||||
gs: GameState,
|
||||
faction: Faction
|
||||
): Vector[MarchCommandFromOneProvince] =
|
||||
marchCommand.oneProvinceCommands.toVector.filter { opmc =>
|
||||
val originProvince = gs.provinces(opmc.originProvinceId)
|
||||
@@ -50,7 +43,7 @@ object MoveLeaderToBetterProvinceCommandChooser {
|
||||
|
||||
private def candidateCommandsWithDistances(
|
||||
actingFactionId: FactionId,
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
candidateCommands: Vector[MarchCommandFromOneProvince],
|
||||
betterDestinationChooser: DestinationChooser
|
||||
): Vector[MCFOPWithDestinationAndDistance] =
|
||||
@@ -78,7 +71,7 @@ object MoveLeaderToBetterProvinceCommandChooser {
|
||||
|
||||
def maybeMoveLeaderToBetterProvinceCommandWithChooser(
|
||||
actingFactionId: FactionId,
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
betterDestinationChooser: DestinationChooser
|
||||
): Option[CommandSelection] = {
|
||||
@@ -94,97 +87,6 @@ object MoveLeaderToBetterProvinceCommandChooser {
|
||||
betterDestinationChooser = betterDestinationChooser
|
||||
)
|
||||
|
||||
candidatesWithDistances
|
||||
.minByOption(_.provinceAndDistance.distance)
|
||||
.flatMap { mcfopwd =>
|
||||
MarchTowardProvinceCommandChooser.marchTowardProvinceCommand(
|
||||
ac = marchCommand,
|
||||
fid = actingFactionId,
|
||||
opmc = mcfopwd.mcfop,
|
||||
destinationProvinceId = mcfopwd.provinceAndDistance.provinceId,
|
||||
gs = GameStateConverter.fromProto(gs),
|
||||
favorLeaders = true
|
||||
)
|
||||
}
|
||||
}
|
||||
cs.flatten
|
||||
}
|
||||
|
||||
// ==================== Protoless overloads ====================
|
||||
|
||||
/** Protoless overload */
|
||||
def maybeMoveLeaderToBetterProvinceCommand(
|
||||
actingFactionId: FactionId,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
maybeMoveLeaderToBetterProvinceCommandWithChooser(
|
||||
actingFactionId = actingFactionId,
|
||||
gs = gs,
|
||||
acs = acs,
|
||||
betterDestinationChooser = (fid: FactionId, pid: ProvinceId, gameState: GameState) =>
|
||||
FactionLeaderProvinceRanker.bestSettlementPidForFactionLeader(fid, pid, gameState)
|
||||
)
|
||||
|
||||
private def candidatesForMarchCommandC(
|
||||
marchCommand: MarchAvailableCommand,
|
||||
gs: GameState,
|
||||
faction: FactionT
|
||||
): Vector[MarchCommandFromOneProvince] =
|
||||
marchCommand.oneProvinceCommands.toVector.filter { opmc =>
|
||||
val originProvince = gs.provinces(opmc.originProvinceId)
|
||||
if opmc.availableHeroIds.intersect(faction.leaderIds).isEmpty then false
|
||||
else if originProvince.rulingFactionHeroIds.length < 2 then false
|
||||
else true
|
||||
}
|
||||
|
||||
private def candidateCommandsWithDistancesC(
|
||||
actingFactionId: FactionId,
|
||||
gs: GameState,
|
||||
candidateCommands: Vector[MarchCommandFromOneProvince],
|
||||
betterDestinationChooser: DestinationChooserC
|
||||
): Vector[MCFOPWithDestinationAndDistance] =
|
||||
candidateCommands.flatMap { mcfop =>
|
||||
betterDestinationChooser(
|
||||
actingFactionId,
|
||||
mcfop.originProvinceId,
|
||||
gs
|
||||
).flatMap { desiredPid =>
|
||||
ProvinceDistances
|
||||
.closestProvinceThroughFriendliesOption(
|
||||
desiredPid,
|
||||
mcfop.availableDestinationProvinces.map(_.provinceId).toVector,
|
||||
actingFactionId,
|
||||
gs.provinces
|
||||
)
|
||||
.map { provinceAndDistance =>
|
||||
MCFOPWithDestinationAndDistance(
|
||||
mcfop = mcfop,
|
||||
provinceAndDistance = provinceAndDistance
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Protoless overload */
|
||||
def maybeMoveLeaderToBetterProvinceCommandWithChooser(
|
||||
actingFactionId: FactionId,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
betterDestinationChooser: DestinationChooserC
|
||||
): Option[CommandSelection] = {
|
||||
val cs = for {
|
||||
faction <- gs.factions.get(actingFactionId)
|
||||
marchCommand <- acs.collectFirst { case ac: MarchAvailableCommand => ac }
|
||||
} yield {
|
||||
// Find the march command origin provinces that contain a faction leader
|
||||
val candidatesWithDistances = candidateCommandsWithDistancesC(
|
||||
actingFactionId = actingFactionId,
|
||||
gs = gs,
|
||||
candidateCommands = candidatesForMarchCommandC(marchCommand, gs, faction),
|
||||
betterDestinationChooser = betterDestinationChooser
|
||||
)
|
||||
|
||||
candidatesWithDistances
|
||||
.minByOption(_.provinceAndDistance.distance)
|
||||
.flatMap { mcfopwd =>
|
||||
|
||||
@@ -24,7 +24,7 @@ import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus.{
|
||||
DIPLOMACY_OFFER_STATUS_REJECTED
|
||||
}
|
||||
import net.eagle0.eagle.internal.faction_relationship.FactionRelationship.RelationshipLevel.ALLY
|
||||
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.library.settings.{
|
||||
AiAllianceAcceptanceBaseChance,
|
||||
AiTruceAcceptanceBaseChance,
|
||||
@@ -33,7 +33,6 @@ import net.eagle0.eagle.library.settings.{
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.{
|
||||
CommandChooser,
|
||||
CommandChooserImplicits,
|
||||
LegacyCommandChooser,
|
||||
RansomOfferHelpers
|
||||
}
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.{
|
||||
@@ -45,34 +44,12 @@ import net.eagle0.eagle.library.util.CommandSelection
|
||||
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
|
||||
import net.eagle0.eagle.library.EagleInternalException
|
||||
import net.eagle0.eagle.model.state.faction.FactionT
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.province.ProvinceT
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
object ResolveDiplomacyCommandSelector {
|
||||
import CommandChooserImplicits.*
|
||||
|
||||
def resolveDiplomacySelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
CommandChooser.choose(
|
||||
actingFactionId = actingFactionId,
|
||||
gameState = gameState,
|
||||
availableCommands = availableCommands,
|
||||
rankedChoosers = Vector[LegacyCommandChooser](
|
||||
resolveInvitationSelectedCommandProto _,
|
||||
resolveAllianceOfferSelectedCommandProto _,
|
||||
resolveBreakAllianceSelectedCommandProto _,
|
||||
resolveTruceOfferSelectedCommandProto _,
|
||||
resolveRansomOfferSelectedCommandProto _
|
||||
),
|
||||
functionalRandom = functionalRandom
|
||||
)
|
||||
|
||||
/** Protoless overload */
|
||||
def resolveDiplomacySelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
@@ -93,35 +70,6 @@ object ResolveDiplomacyCommandSelector {
|
||||
functionalRandom = functionalRandom
|
||||
)
|
||||
|
||||
private def resolveInvitationSelectedCommandProto(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
randomSelectionForType[ResolveInvitationAvailableCommand](
|
||||
availableCommands,
|
||||
functionalRandom
|
||||
) {
|
||||
case (resolveInvitationAc, fr) =>
|
||||
selectionForResolveInvitationCommandProto(
|
||||
actingFactionId = actingFactionId,
|
||||
ac = resolveInvitationAc,
|
||||
gs = gameState,
|
||||
functionalRandom = fr
|
||||
).map { resolveInviteSelection =>
|
||||
Some(
|
||||
CommandSelection(
|
||||
actingFactionId = actingFactionId,
|
||||
actingProvinceId = 0,
|
||||
available = resolveInvitationAc,
|
||||
selected = resolveInviteSelection,
|
||||
reason = "resolving invitation"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private def resolveInvitationSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
@@ -151,10 +99,10 @@ object ResolveDiplomacyCommandSelector {
|
||||
}
|
||||
}
|
||||
|
||||
private def selectionForResolveInvitationCommandProto(
|
||||
private def selectionForResolveInvitationCommand(
|
||||
actingFactionId: FactionId,
|
||||
ac: ResolveInvitationAvailableCommand,
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
|
||||
val invitedFid = actingFactionId
|
||||
@@ -185,53 +133,16 @@ object ResolveDiplomacyCommandSelector {
|
||||
)
|
||||
}
|
||||
|
||||
private def selectionForResolveInvitationCommand(
|
||||
actingFactionId: FactionId,
|
||||
ac: ResolveInvitationAvailableCommand,
|
||||
gs: GameState,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
|
||||
val invitedFid = actingFactionId
|
||||
val factions = gs.factions.values.toVector
|
||||
val provinces = gs.provinces.values.toVector
|
||||
val bestInvitation = ac.invitations
|
||||
.maxBy(inv => invitationAcceptanceChance(inv.originatingFactionId, invitedFid, factions, provinces))
|
||||
|
||||
internalRequire(
|
||||
bestInvitation.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
|
||||
"Cannot handle invitation without ACCEPTED option"
|
||||
)
|
||||
internalRequire(
|
||||
bestInvitation.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_REJECTED),
|
||||
"Cannot handle invitation without REJECTED option"
|
||||
)
|
||||
|
||||
val bestOriginatingFid = bestInvitation.originatingFactionId
|
||||
|
||||
ResolveInvitationSelectedCommand(
|
||||
originatingFactionId = bestOriginatingFid,
|
||||
resolution =
|
||||
if 100.0 * roll < invitationAcceptanceChance(
|
||||
bestOriginatingFid,
|
||||
invitedFid,
|
||||
factions,
|
||||
provinces
|
||||
)
|
||||
then DIPLOMACY_OFFER_STATUS_ACCEPTED
|
||||
else DIPLOMACY_OFFER_STATUS_REJECTED
|
||||
)
|
||||
}
|
||||
|
||||
def invitationAcceptanceChance(
|
||||
originatingFid: FactionId,
|
||||
invitedFid: FactionId,
|
||||
gs: GameStateProto
|
||||
gs: GameState
|
||||
): Int =
|
||||
(LegacyFactionUtils.prestige(originatingFid, gs)
|
||||
- LegacyFactionUtils.prestige(invitedFid, gs)
|
||||
- MinimumPrestigeDifferenceToInvite.doubleValue).floor.toInt.max(0)
|
||||
|
||||
/** Protoless overload */
|
||||
/** Protoless version */
|
||||
def invitationAcceptanceChance(
|
||||
originatingFid: FactionId,
|
||||
invitedFid: FactionId,
|
||||
@@ -245,35 +156,6 @@ object ResolveDiplomacyCommandSelector {
|
||||
- MinimumPrestigeDifferenceToInvite.doubleValue).floor.toInt.max(0)
|
||||
}
|
||||
|
||||
private def resolveTruceOfferSelectedCommandProto(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
randomSelectionForType[ResolveTruceOfferAvailableCommand](
|
||||
availableCommands,
|
||||
functionalRandom
|
||||
) {
|
||||
case (resolveTruceAc, fr) =>
|
||||
selectionForResolveTruceOfferSelectedCommandProto(
|
||||
actingFactionId = actingFactionId,
|
||||
ac = resolveTruceAc,
|
||||
gs = gameState,
|
||||
functionalRandom = fr
|
||||
).map { resolveTruceOfferSelection =>
|
||||
Some(
|
||||
CommandSelection(
|
||||
actingFactionId = actingFactionId,
|
||||
actingProvinceId = 0,
|
||||
available = resolveTruceAc,
|
||||
selected = resolveTruceOfferSelection,
|
||||
reason = "resolving truce offer"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private def resolveTruceOfferSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
@@ -303,10 +185,10 @@ object ResolveDiplomacyCommandSelector {
|
||||
}
|
||||
}
|
||||
|
||||
private def selectionForResolveTruceOfferSelectedCommandProto(
|
||||
private def selectionForResolveTruceOfferSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
ac: ResolveTruceOfferAvailableCommand,
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
|
||||
val actingFid = actingFactionId
|
||||
@@ -337,47 +219,10 @@ object ResolveDiplomacyCommandSelector {
|
||||
)
|
||||
}
|
||||
|
||||
private def selectionForResolveTruceOfferSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
ac: ResolveTruceOfferAvailableCommand,
|
||||
gs: GameState,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
|
||||
val actingFid = actingFactionId
|
||||
val factions = gs.factions.values.toVector
|
||||
val provinces = gs.provinces.values.toVector
|
||||
val bestOffer = ac.offers
|
||||
.maxBy(inv => truceOfferAcceptanceChance(inv.originatingFactionId, actingFid, factions, provinces))
|
||||
|
||||
internalRequire(
|
||||
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
|
||||
"Cannot handle truce offer without ACCEPTED option"
|
||||
)
|
||||
internalRequire(
|
||||
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_REJECTED),
|
||||
"Cannot handle truce offer without REJECTED option"
|
||||
)
|
||||
|
||||
val bestOriginatingFid = bestOffer.originatingFactionId
|
||||
|
||||
ResolveTruceOfferSelectedCommand(
|
||||
originatingFactionId = bestOriginatingFid,
|
||||
resolution =
|
||||
if 100.0 * roll < truceOfferAcceptanceChance(
|
||||
originatingFid = bestOriginatingFid,
|
||||
targetFid = actingFid,
|
||||
factions = factions,
|
||||
provinces = provinces
|
||||
)
|
||||
then DIPLOMACY_OFFER_STATUS_ACCEPTED
|
||||
else DIPLOMACY_OFFER_STATUS_REJECTED
|
||||
)
|
||||
}
|
||||
|
||||
def truceOfferAcceptanceChance(
|
||||
originatingFid: FactionId,
|
||||
targetFid: FactionId,
|
||||
gs: GameStateProto
|
||||
gs: GameState
|
||||
): Int =
|
||||
(
|
||||
AiTruceAcceptanceBaseChance.doubleValue
|
||||
@@ -390,31 +235,10 @@ object ResolveDiplomacyCommandSelector {
|
||||
)
|
||||
).floor.toInt.max(0)
|
||||
|
||||
/** Protoless overload */
|
||||
def truceOfferAcceptanceChance(
|
||||
originatingFid: FactionId,
|
||||
targetFid: FactionId,
|
||||
factions: Vector[FactionT],
|
||||
provinces: Vector[ProvinceT]
|
||||
): Int = {
|
||||
val originatingFaction = factions.find(_.id == originatingFid).get
|
||||
val targetFaction = factions.find(_.id == targetFid).get
|
||||
(
|
||||
AiTruceAcceptanceBaseChance.doubleValue
|
||||
+ FactionUtils.prestige(originatingFaction, provinces)
|
||||
- FactionUtils.prestige(targetFaction, provinces)
|
||||
+ FactionUtils.trust(
|
||||
by = targetFid,
|
||||
of = originatingFid,
|
||||
factions = factions
|
||||
)
|
||||
).floor.toInt.max(0)
|
||||
}
|
||||
|
||||
def allianceOfferAcceptanceChance(
|
||||
originatingFid: FactionId,
|
||||
targetFid: FactionId,
|
||||
gs: GameStateProto
|
||||
gs: GameState
|
||||
): Int =
|
||||
// Always reject if the faction already has an alliance
|
||||
if gs
|
||||
@@ -444,61 +268,10 @@ object ResolveDiplomacyCommandSelector {
|
||||
)
|
||||
).floor.toInt.max(0)
|
||||
|
||||
/** Protoless overload */
|
||||
def allianceOfferAcceptanceChance(
|
||||
originatingFid: FactionId,
|
||||
targetFid: FactionId,
|
||||
factions: Vector[FactionT],
|
||||
provinces: Vector[ProvinceT]
|
||||
): Int = {
|
||||
import net.eagle0.eagle.model.state.faction.FactionRelationship.RelationshipLevel.Ally
|
||||
val targetFaction = factions.find(_.id == targetFid).get
|
||||
val originatingFaction = factions.find(_.id == originatingFid).get
|
||||
val provincesMap = provinces.map(p => p.id -> p).toMap
|
||||
|
||||
// Always reject if the faction already has an alliance
|
||||
if targetFaction.factionRelationships.exists(_.relationshipLevel == Ally) then 0
|
||||
|
||||
// reject if this is our only neighbor and we have no expansion possibilities
|
||||
else if FactionUtils.provinces(targetFid, provinces).forall { province =>
|
||||
province.neighbors.map(n => provincesMap(n.provinceId)).forall { neighborProvince =>
|
||||
neighborProvince.rulingFactionId.contains(originatingFid) ||
|
||||
neighborProvince.rulingFactionId.contains(targetFid)
|
||||
}
|
||||
}
|
||||
then 0
|
||||
else
|
||||
(
|
||||
AiAllianceAcceptanceBaseChance.doubleValue
|
||||
+ FactionUtils.prestige(originatingFaction, provinces)
|
||||
- FactionUtils.prestige(targetFaction, provinces)
|
||||
+ FactionUtils.trust(
|
||||
by = targetFid,
|
||||
of = originatingFid,
|
||||
factions = factions
|
||||
)
|
||||
).floor.toInt.max(0)
|
||||
end if
|
||||
}
|
||||
|
||||
// Ransom offer doesn't use gameState, so proto version just delegates
|
||||
private def resolveRansomOfferSelectedCommandProto(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
resolveRansomOfferSelectedCommandCore(actingFactionId, availableCommands)
|
||||
|
||||
private def resolveRansomOfferSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
resolveRansomOfferSelectedCommandCore(actingFactionId, availableCommands)
|
||||
|
||||
private def resolveRansomOfferSelectedCommandCore(
|
||||
actingFactionId: FactionId,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
selectionForType[ResolveRansomOfferAvailableCommand](availableCommands) { resolveRansomAc =>
|
||||
CommandSelection(
|
||||
@@ -506,14 +279,18 @@ object ResolveDiplomacyCommandSelector {
|
||||
actingProvinceId = 0,
|
||||
available = resolveRansomAc,
|
||||
selected = selectionForResolveRansomOfferSelectedCommand(
|
||||
ac = resolveRansomAc
|
||||
actingFactionId = actingFactionId,
|
||||
ac = resolveRansomAc,
|
||||
gs = gameState
|
||||
),
|
||||
reason = "resolving ransom offer"
|
||||
)
|
||||
}
|
||||
|
||||
private def selectionForResolveRansomOfferSelectedCommand(
|
||||
ac: ResolveRansomOfferAvailableCommand
|
||||
actingFactionId: FactionId,
|
||||
ac: ResolveRansomOfferAvailableCommand,
|
||||
gs: GameState
|
||||
): SelectedCommand =
|
||||
ac.offers.head.offerDetails match {
|
||||
case rod: RansomOfferDetails =>
|
||||
@@ -525,35 +302,6 @@ object ResolveDiplomacyCommandSelector {
|
||||
case _ => throw new EagleInternalException("Not a ransom offer")
|
||||
}
|
||||
|
||||
private def resolveAllianceOfferSelectedCommandProto(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
randomSelectionForType[ResolveAllianceOfferAvailableCommand](
|
||||
availableCommands,
|
||||
functionalRandom
|
||||
) {
|
||||
case (resolveAllianceAc, fr) =>
|
||||
selectionForResolveAllianceOfferSelectedCommandProto(
|
||||
actingFactionId = actingFactionId,
|
||||
ac = resolveAllianceAc,
|
||||
gs = gameState,
|
||||
functionalRandom = fr
|
||||
).map { resolveAllianceOfferSelection =>
|
||||
Some(
|
||||
CommandSelection(
|
||||
actingFactionId = actingFactionId,
|
||||
actingProvinceId = 0,
|
||||
available = resolveAllianceAc,
|
||||
selected = resolveAllianceOfferSelection,
|
||||
reason = "resolving alliance offer"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private def resolveAllianceOfferSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
@@ -583,27 +331,11 @@ object ResolveDiplomacyCommandSelector {
|
||||
}
|
||||
}
|
||||
|
||||
// Break alliance doesn't use gameState, so proto version just delegates
|
||||
private def resolveBreakAllianceSelectedCommandProto(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
resolveBreakAllianceSelectedCommandCore(actingFactionId, availableCommands, functionalRandom)
|
||||
|
||||
private def resolveBreakAllianceSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
resolveBreakAllianceSelectedCommandCore(actingFactionId, availableCommands, functionalRandom)
|
||||
|
||||
private def resolveBreakAllianceSelectedCommandCore(
|
||||
actingFactionId: FactionId,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
randomSelectionForType[ResolveBreakAllianceAvailableCommand](
|
||||
availableCommands,
|
||||
@@ -611,7 +343,9 @@ object ResolveDiplomacyCommandSelector {
|
||||
) {
|
||||
case (resolveBreakAllianceAc, fr) =>
|
||||
selectionForResolveBreakAllianceSelectedCommand(
|
||||
actingFactionId = actingFactionId,
|
||||
ac = resolveBreakAllianceAc,
|
||||
gs = gameState,
|
||||
functionalRandom = fr
|
||||
).map { resolveBreakAllianceSelection =>
|
||||
Some(
|
||||
@@ -626,10 +360,10 @@ object ResolveDiplomacyCommandSelector {
|
||||
}
|
||||
}
|
||||
|
||||
private def selectionForResolveAllianceOfferSelectedCommandProto(
|
||||
private def selectionForResolveAllianceOfferSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
ac: ResolveAllianceOfferAvailableCommand,
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
|
||||
val actingFid = actingFactionId
|
||||
@@ -660,49 +394,13 @@ object ResolveDiplomacyCommandSelector {
|
||||
)
|
||||
}
|
||||
|
||||
private def selectionForResolveAllianceOfferSelectedCommand(
|
||||
// FIXME: always imprison ambassadors for now
|
||||
private def selectionForResolveBreakAllianceSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
ac: ResolveAllianceOfferAvailableCommand,
|
||||
ac: ResolveBreakAllianceAvailableCommand,
|
||||
gs: GameState,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
|
||||
val actingFid = actingFactionId
|
||||
val factions = gs.factions.values.toVector
|
||||
val provinces = gs.provinces.values.toVector
|
||||
val bestOffer = ac.offers
|
||||
.maxBy(inv => allianceOfferAcceptanceChance(inv.originatingFactionId, actingFid, factions, provinces))
|
||||
|
||||
internalRequire(
|
||||
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
|
||||
"Cannot handle alliance offer without ACCEPTED option"
|
||||
)
|
||||
internalRequire(
|
||||
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_REJECTED),
|
||||
"Cannot handle alliance offer without REJECTED option"
|
||||
)
|
||||
|
||||
val bestOriginatingFid = bestOffer.originatingFactionId
|
||||
|
||||
ResolveAllianceOfferSelectedCommand(
|
||||
originatingFactionId = bestOriginatingFid,
|
||||
resolution =
|
||||
if 100.0 * roll < allianceOfferAcceptanceChance(
|
||||
originatingFid = bestOriginatingFid,
|
||||
targetFid = actingFid,
|
||||
factions = factions,
|
||||
provinces = provinces
|
||||
)
|
||||
then DIPLOMACY_OFFER_STATUS_ACCEPTED
|
||||
else DIPLOMACY_OFFER_STATUS_REJECTED
|
||||
)
|
||||
}
|
||||
|
||||
// FIXME: always imprison ambassadors for now
|
||||
// Note: doesn't use gameState or functionalRandom roll
|
||||
private def selectionForResolveBreakAllianceSelectedCommand(
|
||||
ac: ResolveBreakAllianceAvailableCommand,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { _ =>
|
||||
val offer = ac.offers.head
|
||||
|
||||
ResolveBreakAllianceSelectedCommand(
|
||||
|
||||
@@ -28,13 +28,9 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle:__subpackages__",
|
||||
],
|
||||
exports = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
|
||||
],
|
||||
deps = [
|
||||
":oauth_config",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
|
||||
"//src/main/scala/net/eagle0/common:simple_timed_logger",
|
||||
"@maven//:org_json4s_json4s_ast_3",
|
||||
"@maven//:org_json4s_json4s_core_3",
|
||||
"@maven//:org_json4s_json4s_native_3",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
package net.eagle0.eagle.auth
|
||||
|
||||
object GenerateJwtKey {
|
||||
def main(args: Array[String]): Unit =
|
||||
println(JwtServiceImpl.generateKey().toJSONString)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user