mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 03:45:41 +00:00
Compare commits
149
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a314208fdc | ||
|
|
7239a11254 | ||
|
|
f4aa19119b | ||
|
|
80023d39a0 | ||
|
|
ecffeb0d2c | ||
|
|
1936d65bea | ||
|
|
ea46bb4a90 | ||
|
|
480ae0d0c6 | ||
|
|
14def8899d | ||
|
|
581ffb735e | ||
|
|
74383ae5d9 | ||
|
|
ba9c92222c | ||
|
|
f24c1209e0 | ||
|
|
e7c8df8b33 | ||
|
|
c753c4995a | ||
|
|
51e9e9b14a | ||
|
|
a2feaa6546 | ||
|
|
4d83978951 | ||
|
|
11de6872d4 | ||
|
|
ecd089de59 | ||
|
|
c1756cb36b | ||
|
|
78533a9ff0 | ||
|
|
8172a4e117 | ||
|
|
fbabe9cea4 | ||
|
|
7df24149f7 | ||
|
|
7de74539f0 | ||
|
|
e9dd53fdf8 | ||
|
|
35e8f85627 | ||
|
|
35bc242582 | ||
|
|
c5829b1aff | ||
|
|
6c78d9f733 | ||
|
|
1966dfbc24 | ||
|
|
7cd7edf9a0 | ||
|
|
104ee5429d | ||
|
|
57fd73571f | ||
|
|
9ea6e29752 | ||
|
|
8581232f70 | ||
|
|
053ac4a622 | ||
|
|
09041887c8 | ||
|
|
58fd3dc112 | ||
|
|
d62bb747dd | ||
|
|
f8646c7b50 | ||
|
|
affd0a8180 | ||
|
|
a92a878465 | ||
|
|
10696325e9 | ||
|
|
88273937da | ||
|
|
e472ab4df2 | ||
|
|
11110a5f88 | ||
|
|
d125394028 | ||
|
|
99037c7c3a | ||
|
|
c60d4b80f2 | ||
|
|
041514bd02 | ||
|
|
c410f790ff | ||
|
|
b4d19e7b65 | ||
|
|
9e8169df3e | ||
|
|
f92f400f28 | ||
|
|
28d7da9634 | ||
|
|
79aac7c9ce | ||
|
|
a530a219f1 | ||
|
|
335baf96dc | ||
|
|
584ccbadcb | ||
|
|
06f6ba8340 | ||
|
|
bfe5befff2 | ||
|
|
521dde9503 | ||
|
|
85eccb97f0 | ||
|
|
e270d27a4a | ||
|
|
d9893edcb1 | ||
|
|
5d4719ea19 | ||
|
|
e63a8489b6 | ||
|
|
9b190254a4 | ||
|
|
eb2677bd84 | ||
|
|
23d2cb3968 | ||
|
|
c66263cd56 | ||
|
|
0ba3e0e5ae | ||
|
|
860d0f3c5f | ||
|
|
13f79c37bc | ||
|
|
87cd58f7a4 | ||
|
|
e739e18d77 | ||
|
|
c03daf100c | ||
|
|
6519850ec3 | ||
|
|
df785f557b | ||
|
|
224dc3bc13 | ||
|
|
5f3095dbd3 | ||
|
|
fb12810105 | ||
|
|
fe2a5b0869 | ||
|
|
3f00ba4a03 | ||
|
|
3dd06c0aba | ||
|
|
d95f65d9b4 | ||
|
|
a7c1146c41 | ||
|
|
56a5c27d42 | ||
|
|
1d707ba5f0 | ||
|
|
e4f3df7e26 | ||
|
|
d68302d2b5 | ||
|
|
4a7559c25d | ||
|
|
1eb253d866 | ||
|
|
c770a3693b | ||
|
|
99ad9fb49e | ||
|
|
d80a151303 | ||
|
|
b9f48e14be | ||
|
|
32c62357eb | ||
|
|
a4bf652bf7 | ||
|
|
31458c2a98 | ||
|
|
dabf309e6d | ||
|
|
f3bcf44cb3 | ||
|
|
6f486cbd32 | ||
|
|
bb07a90c0d | ||
|
|
30c3093829 | ||
|
|
d723a7e026 | ||
|
|
7c8453b6c0 | ||
|
|
7e87ca792e | ||
|
|
89db5be805 | ||
|
|
ec64146ca7 | ||
|
|
9e8f9cef0e | ||
|
|
9999f4b1b3 | ||
|
|
85241710c9 | ||
|
|
232ad643e5 | ||
|
|
55f16d1d6c | ||
|
|
42bdd42361 | ||
|
|
b5ad57053a | ||
|
|
939cb49c91 | ||
|
|
9ec79f904a | ||
|
|
ddea05514c | ||
|
|
906da781d5 | ||
|
|
db38c2a5b6 | ||
|
|
72e2f016e2 | ||
|
|
fb2b0beb85 | ||
|
|
e1fb2391ef | ||
|
|
8486ae2420 | ||
|
|
f1a5b2962d | ||
|
|
8a1904b8c8 | ||
|
|
4157863b3d | ||
|
|
6171fe258f | ||
|
|
78fce1ba74 | ||
|
|
d1eac2d7e0 | ||
|
|
1503c20b40 | ||
|
|
470fdd7cd4 | ||
|
|
694f853408 | ||
|
|
5f251b5482 | ||
|
|
24745ac466 | ||
|
|
35a0be4c71 | ||
|
|
d910a7d832 | ||
|
|
853d703492 | ||
|
|
5f40464529 | ||
|
|
1d3b467b8c | ||
|
|
38a22f30c4 | ||
|
|
93d2e7c323 | ||
|
|
d1de042a61 | ||
|
|
389474f27b | ||
|
|
e799b2c966 |
@@ -36,3 +36,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
name: Auth Service Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/main/go/net/eagle0/authservice/**'
|
||||
- 'src/main/go/net/eagle0/authcli/**'
|
||||
- 'src/main/protobuf/net/eagle0/eagle/api/auth.proto'
|
||||
- 'src/main/protobuf/net/eagle0/eagle/api/admin/**'
|
||||
- 'src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
|
||||
- 'ci/BUILD.bazel'
|
||||
- '.github/workflows/auth_build.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_images:
|
||||
description: 'Push images to container registry'
|
||||
required: true
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-auth:
|
||||
runs-on: self-hosted
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-auth.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Auth Server Docker image
|
||||
id: build-auth
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Build auth server image (Go binary has explicit goos/goarch in BUILD.bazel)
|
||||
bazel build //ci:auth_server_image
|
||||
|
||||
# Save the resolved path before any other bazel command changes bazel-bin symlink
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/auth_server_image)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
mkdir -p .docker
|
||||
cp ~/.docker/config.json .docker/
|
||||
|
||||
- name: Push Auth image to DO registry
|
||||
id: push-auth
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DOCKER_CONFIG: ${{ github.workspace }}/.docker
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
AUTH_IMAGE="${{ steps.build-auth.outputs.image_path }}"
|
||||
echo "Using Auth image: $AUTH_IMAGE"
|
||||
|
||||
if [ -z "$AUTH_IMAGE" ] || [ ! -d "$AUTH_IMAGE" ]; then
|
||||
echo "ERROR: Auth image not found at: $AUTH_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the push target to get crane in runfiles
|
||||
bazel build //ci:auth_server_push
|
||||
|
||||
# Use crane directly for push
|
||||
CRANE="bazel-bin/ci/push_auth_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
|
||||
echo "Using crane: $CRANE"
|
||||
|
||||
# Push with SHA tag
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
IMAGE_TAG="registry.digitalocean.com/eagle0/auth-server:${GIT_SHA}"
|
||||
echo "Pushing auth image: $IMAGE_TAG"
|
||||
$CRANE push "$AUTH_IMAGE" "$IMAGE_TAG"
|
||||
|
||||
# Output the full image tag for deploy step
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also update :latest for convenience
|
||||
echo "Copying to :latest tag"
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/auth-server:latest"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-auth]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
env:
|
||||
AUTH_IMAGE: ${{ needs.build-auth.outputs.image_tag }}
|
||||
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
|
||||
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy auth service to production
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.DO_DROPLET_IP }}
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
script_stop: true
|
||||
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,JWT_PRIVATE_KEY
|
||||
script: |
|
||||
set -x
|
||||
cd /opt/eagle0
|
||||
|
||||
# Update AUTH_IMAGE in .env file (preserve other vars)
|
||||
if [ -f .env ]; then
|
||||
# Remove old AUTH_IMAGE line and add new one
|
||||
grep -v '^AUTH_IMAGE=' .env > .env.tmp || true
|
||||
echo "AUTH_IMAGE=${AUTH_IMAGE}" >> .env.tmp
|
||||
# Also update OAuth credentials
|
||||
grep -v '^DISCORD_CLIENT_ID=' .env.tmp > .env.tmp2 || true
|
||||
echo "DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}" >> .env.tmp2
|
||||
grep -v '^DISCORD_CLIENT_SECRET=' .env.tmp2 > .env.tmp3 || true
|
||||
echo "DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}" >> .env.tmp3
|
||||
grep -v '^GOOGLE_CLIENT_ID=' .env.tmp3 > .env.tmp4 || true
|
||||
echo "GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}" >> .env.tmp4
|
||||
grep -v '^GOOGLE_CLIENT_SECRET=' .env.tmp4 > .env.tmp5 || true
|
||||
echo "GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}" >> .env.tmp5
|
||||
# Update JWT private key (JWK format for auth service bootstrap)
|
||||
grep -v '^JWT_PRIVATE_KEY=' .env.tmp5 > .env || true
|
||||
echo "JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}" >> .env
|
||||
rm -f .env.tmp .env.tmp2 .env.tmp3 .env.tmp4 .env.tmp5
|
||||
chmod 600 .env
|
||||
else
|
||||
echo "ERROR: .env file not found. Run main deploy first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Login to registry
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
|
||||
echo "Deploying auth service: $AUTH_IMAGE"
|
||||
|
||||
# Use crane to pull image
|
||||
echo "Installing crane..."
|
||||
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
|
||||
chmod +x crane
|
||||
|
||||
echo "Pulling Auth image with crane..."
|
||||
./crane pull "${AUTH_IMAGE}" auth.tar || { echo "ERROR: Failed to pull auth image"; exit 1; }
|
||||
echo "Loading Auth image into Docker..."
|
||||
docker load -i auth.tar
|
||||
rm auth.tar
|
||||
rm ./crane
|
||||
|
||||
# Only recreate the auth container (not eagle, shardok, etc.)
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate auth
|
||||
|
||||
# Wait for health check
|
||||
sleep 5
|
||||
docker compose -f docker-compose.prod.yml ps auth
|
||||
|
||||
# Verify container is using correct image
|
||||
echo "=== Verifying auth container image ==="
|
||||
docker compose -f docker-compose.prod.yml images auth
|
||||
|
||||
# Cleanup old images
|
||||
docker image prune -f
|
||||
@@ -8,12 +8,22 @@ on:
|
||||
required: true
|
||||
default: 'v2'
|
||||
type: string
|
||||
architecture:
|
||||
description: 'Target architecture'
|
||||
required: true
|
||||
default: 'amd64'
|
||||
type: choice
|
||||
options:
|
||||
- amd64
|
||||
- arm64
|
||||
- both
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-sysroot:
|
||||
build-sysroot-amd64:
|
||||
if: ${{ inputs.architecture == 'amd64' || inputs.architecture == 'both' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -25,7 +35,7 @@ jobs:
|
||||
- name: Upload sysroot artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ubuntu-noble-sysroot
|
||||
name: ubuntu-noble-sysroot-amd64
|
||||
path: tools/sysroot/output/
|
||||
|
||||
- name: Install AWS CLI
|
||||
@@ -41,7 +51,7 @@ jobs:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: |
|
||||
# Upload sysroot tarball to DO Spaces (using eagle0-windows bucket, same as other workflows)
|
||||
# Upload sysroot tarball to DO Spaces
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
@@ -54,7 +64,7 @@ jobs:
|
||||
--acl public-read
|
||||
|
||||
echo ""
|
||||
echo "=== Sysroot uploaded ==="
|
||||
echo "=== AMD64 Sysroot uploaded ==="
|
||||
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
|
||||
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)"
|
||||
echo ""
|
||||
@@ -64,3 +74,64 @@ jobs:
|
||||
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)\","
|
||||
echo " urls = [\"https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
|
||||
echo ")"
|
||||
|
||||
build-sysroot-arm64:
|
||||
if: ${{ inputs.architecture == 'arm64' || inputs.architecture == 'both' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU for ARM64 emulation
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build ARM64 sysroot
|
||||
run: ./tools/sysroot/build_sysroot_arm64.sh
|
||||
|
||||
- name: Upload sysroot artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ubuntu-noble-sysroot-arm64
|
||||
path: tools/sysroot/output/
|
||||
|
||||
- name: Install AWS CLI
|
||||
run: |
|
||||
if ! command -v aws &> /dev/null; then
|
||||
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
|
||||
unzip -q awscliv2.zip
|
||||
sudo ./aws/install
|
||||
fi
|
||||
|
||||
- name: Upload to DigitalOcean Spaces
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: |
|
||||
# Upload sysroot tarball to DO Spaces
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.tar.xz \
|
||||
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
# Upload sha256 file
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256 \
|
||||
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.sha256 \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
echo ""
|
||||
echo "=== ARM64 Sysroot uploaded ==="
|
||||
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz"
|
||||
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)"
|
||||
echo ""
|
||||
echo "Update MODULE.bazel with:"
|
||||
echo "sysroot("
|
||||
echo " name = \"linux_sysroot_arm64\","
|
||||
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)\","
|
||||
echo " urls = [\"https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz\"],"
|
||||
echo ")"
|
||||
|
||||
@@ -11,6 +11,8 @@ on:
|
||||
- 'src/main/resources/**'
|
||||
- 'ci/BUILD.bazel'
|
||||
- 'MODULE.bazel'
|
||||
- 'docker-compose.prod.yml'
|
||||
- 'nginx/**'
|
||||
- '.github/workflows/docker_build.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -348,20 +350,99 @@ 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]
|
||||
needs: [build-eagle, build-shardok, build-admin, build-jfr-sidecar]
|
||||
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 }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
SHARDOK_ADDRESS: ${{ secrets.SHARDOK_ADDRESS }}
|
||||
SHARDOK_AUTH_TOKEN: ${{ secrets.SHARDOK_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -382,19 +463,45 @@ jobs:
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
script_stop: true
|
||||
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY
|
||||
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,JFR_SIDECAR_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY,JWT_PRIVATE_KEY,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,SHARDOK_ADDRESS,SHARDOK_AUTH_TOKEN,SENTRY_DSN
|
||||
script: |
|
||||
set -x
|
||||
cd /opt/eagle0
|
||||
|
||||
# Check Docker has IPv6 support for connecting to Hetzner Shardok
|
||||
# (One-time setup: sudo tee /etc/docker/daemon.json <<< '{"ipv6": true, "ip6tables": true, "experimental": true, "fixed-cidr-v6": "fd00::/80"}' && sudo systemctl restart docker)
|
||||
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
|
||||
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
|
||||
echo "Run: sudo tee /etc/docker/daemon.json <<< '{\"ipv6\": true, \"ip6tables\": true, \"experimental\": true, \"fixed-cidr-v6\": \"fd00::/80\"}' && sudo systemctl restart docker"
|
||||
fi
|
||||
|
||||
# Write env vars to .env file for docker-compose
|
||||
# Preserve AUTH_IMAGE if it exists (managed by auth_build.yml)
|
||||
EXISTING_AUTH_IMAGE=""
|
||||
if [ -f .env ]; then
|
||||
EXISTING_AUTH_IMAGE=$(grep '^AUTH_IMAGE=' .env | cut -d= -f2- || true)
|
||||
fi
|
||||
|
||||
rm -f .env 2>/dev/null || true
|
||||
cat > .env << EOF
|
||||
EAGLE_IMAGE=${EAGLE_IMAGE}
|
||||
SHARDOK_IMAGE=${SHARDOK_IMAGE}
|
||||
ADMIN_IMAGE=${ADMIN_IMAGE}
|
||||
JFR_SIDECAR_IMAGE=${JFR_SIDECAR_IMAGE}
|
||||
AUTH_IMAGE=${EXISTING_AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
|
||||
OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
GPT_MODEL_NAME=${GPT_MODEL_NAME:-gpt-4o}
|
||||
EAGLE_ENABLE_S3=${EAGLE_ENABLE_S3:-false}
|
||||
DO_SPACES_ACCESS_KEY=${DO_SPACES_ACCESS_KEY:-}
|
||||
DO_SPACES_SECRET_KEY=${DO_SPACES_SECRET_KEY:-}
|
||||
JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}
|
||||
DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}
|
||||
DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}
|
||||
GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||
SHARDOK_ADDRESS=${SHARDOK_ADDRESS:-shardok:40042}
|
||||
SHARDOK_AUTH_TOKEN=${SHARDOK_AUTH_TOKEN:-}
|
||||
SENTRY_DSN=${SENTRY_DSN:-}
|
||||
EOF
|
||||
chmod 600 .env
|
||||
|
||||
@@ -402,7 +509,8 @@ 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"
|
||||
# Note: AUTH_IMAGE is managed separately by auth_build.yml
|
||||
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE, $ADMIN_IMAGE, $JFR_SIDECAR_IMAGE"
|
||||
|
||||
# Use crane to pull images (handles OCI format correctly) then load into Docker
|
||||
# This avoids digest mismatch from DO registry's OCI->Docker format conversion
|
||||
@@ -429,6 +537,12 @@ 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
|
||||
@@ -437,12 +551,19 @@ jobs:
|
||||
|
||||
echo "All images pulled successfully"
|
||||
|
||||
# Force recreate containers to ensure new image is used
|
||||
docker compose -f docker-compose.prod.yml up -d --force-recreate --remove-orphans
|
||||
# Recreate only the services we're updating (not auth - managed by auth_build.yml)
|
||||
# This prevents restarting auth service during every Eagle deploy
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle shardok admin jfr-sidecar
|
||||
|
||||
# Ensure auth is running (but don't force-recreate it)
|
||||
docker compose -f docker-compose.prod.yml up -d auth
|
||||
|
||||
# Restart nginx to pick up new container IPs
|
||||
# (nginx caches DNS at startup, so it needs restart after eagle/shardok)
|
||||
docker compose -f docker-compose.prod.yml restart nginx
|
||||
docker compose -f docker-compose.prod.yml up -d --force-recreate nginx
|
||||
|
||||
# Cleanup orphaned containers
|
||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||
|
||||
# Wait for health checks
|
||||
sleep 10
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Cleanup Old Container Images
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run daily at 3am UTC
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Dry run (show what would be deleted without deleting)'
|
||||
required: true
|
||||
default: 'true'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install doctl
|
||||
uses: digitalocean/action-doctl@v2
|
||||
with:
|
||||
token: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Cleanup old images
|
||||
env:
|
||||
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
RETENTION_DAYS=5
|
||||
CUTOFF_DATE=$(date -d "-${RETENTION_DAYS} days" +%s)
|
||||
REGISTRY="eagle0"
|
||||
|
||||
echo "Cleaning up images older than ${RETENTION_DAYS} days"
|
||||
echo "Cutoff date: $(date -d "@${CUTOFF_DATE}" -Iseconds)"
|
||||
echo "Dry run: ${DRY_RUN}"
|
||||
echo ""
|
||||
|
||||
# List of repositories to clean
|
||||
REPOS=$(doctl registry repository list-v2 --format Name --no-header)
|
||||
|
||||
for REPO in $REPOS; do
|
||||
echo "=== Processing repository: ${REPO} ==="
|
||||
|
||||
# Get all manifests with their tags and dates
|
||||
MANIFESTS=$(doctl registry repository list-manifests "${REPO}" --format Digest,UpdatedAt,Tags --no-header 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$MANIFESTS" ]; then
|
||||
echo " No manifests found"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "$MANIFESTS" | while read -r DIGEST UPDATED_AT TAGS; do
|
||||
# Skip if no digest
|
||||
if [ -z "$DIGEST" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Parse the date
|
||||
MANIFEST_DATE=$(date -d "$UPDATED_AT" +%s 2>/dev/null || echo "0")
|
||||
|
||||
# Skip protected tags (latest, arm64-latest)
|
||||
if echo "$TAGS" | grep -qE '(^|,)(latest|arm64-latest)(,|$)'; then
|
||||
echo " KEEP: ${DIGEST:0:20}... (protected tag: $TAGS)"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if older than cutoff
|
||||
if [ "$MANIFEST_DATE" -lt "$CUTOFF_DATE" ]; then
|
||||
echo " DELETE: ${DIGEST:0:20}... (updated: $UPDATED_AT, tags: $TAGS)"
|
||||
if [ "$DRY_RUN" != "true" ]; then
|
||||
doctl registry repository delete-manifest "${REPO}" "$DIGEST" --force
|
||||
fi
|
||||
else
|
||||
echo " KEEP: ${DIGEST:0:20}... (updated: $UPDATED_AT, tags: $TAGS)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
- name: Run garbage collection
|
||||
if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'false')
|
||||
run: |
|
||||
echo "Starting garbage collection..."
|
||||
doctl registry garbage-collection start --force
|
||||
echo "Garbage collection started. It may take a few minutes to complete."
|
||||
@@ -0,0 +1,159 @@
|
||||
name: Shardok ARM64 Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/main/cpp/**'
|
||||
- 'src/main/protobuf/net/eagle0/shardok/**'
|
||||
- 'src/main/protobuf/net/eagle0/common/**'
|
||||
- 'src/main/resources/net/eagle0/shardok/**'
|
||||
- 'ci/BUILD.bazel'
|
||||
- 'MODULE.bazel'
|
||||
- '.github/workflows/shardok_arm64_build.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_images:
|
||||
description: 'Push images to container registry'
|
||||
required: true
|
||||
default: 'true'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-shardok-arm64:
|
||||
runs-on: self-hosted
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Shardok ARM64 binary (cross-compile for Linux ARM64)
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
echo "=== Building shardok-server binary for linux-aarch64 ==="
|
||||
bazel build \
|
||||
--platforms=//:linux_arm64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux_arm64//:all \
|
||||
//src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
LINUX_BIN="bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server"
|
||||
echo "=== Checking binary at: $LINUX_BIN ==="
|
||||
|
||||
if [ ! -f "$LINUX_BIN" ]; then
|
||||
echo "ERROR: Binary not found at $LINUX_BIN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify it's ELF (Linux) not Mach-O (macOS)
|
||||
echo "=== Verifying binary format ==="
|
||||
MAGIC=$(head -c 4 "$LINUX_BIN" | xxd -p)
|
||||
echo "Binary magic bytes: $MAGIC"
|
||||
|
||||
if [ "$MAGIC" = "7f454c46" ]; then
|
||||
echo "SUCCESS: Binary is ELF format (Linux)"
|
||||
# Check if it's ARM64 (e_machine = 0xB7 = 183 for aarch64)
|
||||
E_MACHINE=$(od -An -j18 -N2 -tx2 "$LINUX_BIN" | tr -d ' ')
|
||||
echo "ELF e_machine: $E_MACHINE"
|
||||
if [ "$E_MACHINE" = "b700" ]; then
|
||||
echo "SUCCESS: Binary is ARM64 (aarch64)"
|
||||
else
|
||||
echo "WARNING: Binary e_machine is $E_MACHINE (expected b700 for aarch64)"
|
||||
fi
|
||||
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
|
||||
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
|
||||
exit 1
|
||||
else
|
||||
echo "WARNING: Unknown binary format: $MAGIC"
|
||||
file "$LINUX_BIN" || true
|
||||
fi
|
||||
|
||||
- name: Build Shardok ARM64 Docker image
|
||||
id: build-shardok
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
bazel build \
|
||||
--platforms=//:linux_arm64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux_arm64//:all \
|
||||
//ci:shardok_server_image_arm64
|
||||
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/shardok_server_image_arm64)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
# Verify the binary inside the tar layer is ARM64 ELF
|
||||
echo "=== Verifying binary in image tar ==="
|
||||
BINARY_TAR="bazel-bin/ci/shardok_binary_layer_arm64.tar"
|
||||
if [ -f "$BINARY_TAR" ]; then
|
||||
echo "Checking binary in $BINARY_TAR"
|
||||
MAGIC=$(tar -xOf "$BINARY_TAR" app/shardok-server 2>/dev/null | head -c 4 | xxd -p)
|
||||
echo "Binary magic in tar: $MAGIC"
|
||||
if [ "$MAGIC" = "7f454c46" ]; then
|
||||
echo "SUCCESS: Binary in tar is ELF format (Linux)"
|
||||
else
|
||||
echo "ERROR: Binary in tar is NOT ELF format!"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "WARNING: Could not find $BINARY_TAR"
|
||||
fi
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
AUTH=$(echo -n "${DO_REGISTRY_TOKEN}:${DO_REGISTRY_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
|
||||
- name: Push Shardok ARM64 image to DigitalOcean
|
||||
id: push-shardok
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
CROSS_IMAGE="${{ steps.build-shardok.outputs.image_path }}"
|
||||
echo "Using cross-compiled image: $CROSS_IMAGE"
|
||||
|
||||
if [ -z "$CROSS_IMAGE" ] || [ ! -d "$CROSS_IMAGE" ]; then
|
||||
echo "ERROR: Cross-compiled image not found at: $CROSS_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build a push target to get crane in runfiles
|
||||
bazel build //ci:eagle_server_push
|
||||
|
||||
# Find the Darwin crane binary
|
||||
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
|
||||
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
|
||||
if [ -z "$CRANE" ]; then
|
||||
CRANE=$(find "$RUNFILES" -name crane 2>/dev/null | head -1)
|
||||
fi
|
||||
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
|
||||
echo "ERROR: crane not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using crane: $CRANE"
|
||||
|
||||
# Push with arm64-prefixed SHA tag (same repo as x86, different tag)
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
IMAGE_TAG="registry.digitalocean.com/eagle0/shardok-server:arm64-${GIT_SHA}"
|
||||
echo "Pushing shardok ARM64 image: $IMAGE_TAG"
|
||||
$CRANE push "$CROSS_IMAGE" "$IMAGE_TAG"
|
||||
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also update :arm64-latest tag for convenience
|
||||
echo "Copying to :arm64-latest tag"
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
|
||||
|
||||
echo "=== Push complete ==="
|
||||
echo "Image: $IMAGE_TAG"
|
||||
echo "Also tagged as: registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
|
||||
@@ -12,6 +12,15 @@ platform(
|
||||
],
|
||||
)
|
||||
|
||||
# Platform for cross-compiling to Linux ARM64
|
||||
platform(
|
||||
name = "linux_arm64",
|
||||
constraint_values = [
|
||||
"@platforms//os:linux",
|
||||
"@platforms//cpu:aarch64",
|
||||
],
|
||||
)
|
||||
|
||||
gazelle(name = "gazelle")
|
||||
|
||||
# gazelle:proto file
|
||||
|
||||
+135
-10
@@ -8,6 +8,18 @@ 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:
|
||||
@@ -36,6 +48,7 @@ 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
|
||||
@@ -56,17 +69,127 @@ When migrating a file:
|
||||
|
||||
### Fully Protoless
|
||||
|
||||
- [x] `AIClientUtils` - has protoless overloads (`takenHeroIdsForMarchTowardFocus`, `mostPowerfulHeroes`)
|
||||
- [x] `AttackCommandChooser` - uses Scala `GameState` and `BattalionViewC` (Scala view type)
|
||||
- [x] `BattalionPower` - has `estimatedPower(BattalionViewC)` for recon data with optional stats
|
||||
- [x] `CommandChoiceHelpers` - fully protoless, uses Scala `GameState` throughout
|
||||
- [x] `ProvinceGoldSurplusCalculator` - fully protoless (callers use converters)
|
||||
- [x] `HeroSelector` - fully protoless (removed dead `minimallyFatiguedHeroesProto`)
|
||||
|
||||
### Blocked (still uses proto GameState)
|
||||
### AI Layer ✅ COMPLETE
|
||||
|
||||
- [ ] `CommandChoiceHelpers` - main target, uses proto GameState extensively
|
||||
- Depends on many Legacy* utils
|
||||
- [ ] `AttackDecisionCommandChooser` - uses proto types
|
||||
- [ ] `CommandChooser` - uses proto GameState
|
||||
- [ ] `FulfillQuestsCommandSelector` - takes proto, converts to native immediately
|
||||
- Called by `MidGameAIClient` which uses proto GameState
|
||||
All AI and command chooser code is now fully protoless:
|
||||
- [x] `AttackDecisionCommandChooser` - uses Scala GameState
|
||||
- [x] `CommandChooser` - trait uses Scala GameState
|
||||
- [x] `FulfillQuestsCommandSelector` - uses Scala GameState
|
||||
- [x] `MidGameAIClient` - uses Scala GameState internally
|
||||
|
||||
### Still Using Proto GameState (Boundary Code)
|
||||
|
||||
These files use proto GameState because they're at system boundaries:
|
||||
|
||||
**View Filters (client projection):**
|
||||
- `view_filters/GameStateViewFilter` - has Scala overload, uses Scala sub-filters
|
||||
- `view_filters/ProvinceViewFilter` - has Scala overloads for some methods
|
||||
- `view_filters/FactionViewFilter` - has Scala overload
|
||||
- `view_filters/HeroViewFilter` - has Scala overload
|
||||
- `view_filters/BattalionNameFilter` - has Scala overload
|
||||
- `view_filters/BattleFilter` - has Scala overload
|
||||
|
||||
**Legacy Utilities (to be deprecated):**
|
||||
- `LegacyProvinceDistances`, `LegacyFactionUtils`, `LegacyHeroUtils`, etc.
|
||||
- Used by code that still needs proto GameState
|
||||
|
||||
**Persistence/Action System:**
|
||||
- `ActionResultTApplier`, `ActionResultProtoApplier` - apply results to proto state
|
||||
- `ActionWithResultingState` - caches both proto and Scala state
|
||||
|
||||
**Shardok Interface (gRPC boundary):**
|
||||
- `ShardokInterfaceGrpcClient`, `ShardokInterfaceProxy` - must use proto for C++ communication
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Phase 1-3: AI Layer ✅ COMPLETE
|
||||
|
||||
The entire AI decision-making layer is now protoless.
|
||||
|
||||
### Phase 4: View Filters ✅ COMPLETE
|
||||
|
||||
The view_filters package migration is complete:
|
||||
|
||||
**Completed:**
|
||||
- [x] `GameStateViewFilter` - added Scala GameState overload (uses Scala sub-filters)
|
||||
- [x] `ProvinceViewFilter` - already has Scala overloads for server-side views
|
||||
- [x] `HumanPlayerClientConnectionState` - updated to pass Scala GameState directly
|
||||
- [x] `HeroViewFilter` - added Scala overload
|
||||
- [x] `FactionViewFilter` - added Scala overload
|
||||
- [x] `Visibility` - added Scala overloads
|
||||
|
||||
**Still Using Proto:**
|
||||
- [x] `BattalionNameFilter` - has Scala overload
|
||||
- [x] `BattleFilter` - has Scala overload
|
||||
- [ ] `ActionResultFilter` - uses proto internally (boundary code)
|
||||
|
||||
**Strategy:**
|
||||
1. Add Scala GameState overloads to view filter methods
|
||||
2. Update callers to pass Scala GameState where available
|
||||
3. Eventually deprecate proto versions
|
||||
|
||||
### Phase 5: Legacy Utility Cleanup (IN PROGRESS)
|
||||
|
||||
Remove Legacy* utilities by migrating remaining callers:
|
||||
1. Identify callers of each Legacy* util
|
||||
2. Update callers to use protoless versions
|
||||
3. Delete Legacy* files when no longer needed
|
||||
|
||||
**Deleted (no production callers):**
|
||||
- [x] `LegacyProvinceDistances` - deleted (no callers)
|
||||
- [x] `LegacyBattalionSuitability` - deleted (no callers)
|
||||
- [x] `LegacyFoodConsumptionUtils` - deleted (no callers)
|
||||
- [x] `LegacyHandleRiotUtils` - deleted (no callers)
|
||||
|
||||
**Refactored to Thin Wrappers (delegating to protoless versions):**
|
||||
- [x] `LegacyRansomValidity` - already a thin wrapper delegating to `RansomValidity`
|
||||
- [x] `LegacyRecruitmentOdds` - refactored to delegate to `RecruitmentOdds`
|
||||
|
||||
**Parallel Implementations (proto mirrors protoless):**
|
||||
- [x] `FactionUtils` / `LegacyFactionUtils` - both have matching APIs; LegacyFactionUtils used by boundary code (24+ callers)
|
||||
- [x] `HeroUtils` / `LegacyHeroUtils` - both have matching APIs; LegacyHeroUtils used by boundary code (10 callers)
|
||||
- [x] `ProvinceUtils` / `LegacyProvinceUtils` - both have matching APIs; LegacyProvinceUtils used by boundary code (20 callers: availability factories, view filters)
|
||||
|
||||
**Parallel Implementations (awaiting migration of callers):**
|
||||
- [x] `BattalionUtils` / `LegacyBattalionUtils` - both have matching core methods; LegacyBattalionUtils used by boundary code (4 callers)
|
||||
- [x] `BattalionViewFilter` / `LegacyBattalionViewFilter` - protoless version exists; Legacy used by view filters, action appliers (3 callers)
|
||||
- [x] `BattalionTypeFinder` / `LegacyBattalionTypeFinder` - protoless version exists; Legacy used by validators (1 caller: RuntimeValidator)
|
||||
|
||||
### Recent Caller Migration
|
||||
|
||||
**CheckForFulfilledQuestsAction** - migrated to use protoless `BattalionTypeFinder`:
|
||||
- Changed `battalionTypes` parameter from proto `Vector[BattalionType]` to Scala `Vector[BattalionType]`
|
||||
- Updated callers (EngineImpl, EndVassalCommandsPhaseAction) to pass Scala types directly
|
||||
- Eliminated wasteful `BattalionTypeConverter.toProto()` conversions
|
||||
|
||||
**ExpandedUnaffiliatedHeroUtils** - added Scala overload:
|
||||
- New overload takes Scala `GameState` and `UnaffiliatedHeroT` directly
|
||||
- Added `UnaffiliatedHeroConverter.unaffiliatedHeroTypeToProto()` helper for efficient enum conversion
|
||||
- Proto overload retained for backward compatibility
|
||||
|
||||
**AvailablePleaseRecruitMeCommandFactory** - eliminated wasteful proto conversions:
|
||||
- Now uses `ExpandedUnaffiliatedHeroUtils.expandedUnaffiliatedHero(ScalaGameState, UnaffiliatedHeroT)` directly
|
||||
- Removed `GameStateConverter.toProto()` and `UnaffiliatedHeroConverter.toProto()` calls
|
||||
- Factory is now fully protoless internally (still returns proto types for API boundary)
|
||||
|
||||
**ProvinceViewFilter** - added Scala overload with faction filtering:
|
||||
- New `filteredProvinceView(province: ProvinceT, gs: ScalaGameState, factionId: FactionId)` overload
|
||||
- Uses protoless `FactionUtils.hasAlliance`, `Visibility.hasFullVisibility`, and `ProvinceUtils.incomingOthers`
|
||||
- Handles reconned provinces directly from Scala `FactionT.reconnedProvinces` (already Scala type)
|
||||
- Added helper methods: `fullProvinceInfoScala`, `maybeIncomingAttackersScala`, `unaffiliatedHeroInfoScala`
|
||||
- Events still converted to proto at the end (ProvinceView.knownEvents uses proto events)
|
||||
|
||||
**GameStateViewFilter** - eliminated GameStateConverter.toProto() call:
|
||||
- Scala overload now fully protoless internally
|
||||
- Uses the new ProvinceViewFilter Scala overload with faction filtering
|
||||
- Converts `battalionTypes` and `chronicleEntries` to proto only at output boundary
|
||||
|
||||
## Key Files
|
||||
|
||||
@@ -83,6 +206,8 @@ When migrating a file:
|
||||
|
||||
## Notes
|
||||
|
||||
- The AI client code (`src/main/scala/net/eagle0/eagle/ai/`) currently uses proto types extensively
|
||||
- `PerformUnaffiliatedHeroesAction` already uses protoless `GameState`
|
||||
- Migration should proceed incrementally: utilities first, then higher-level selectors/choosers
|
||||
- The AI client code (`src/main/scala/net/eagle0/eagle/ai/`) is now fully protoless
|
||||
- Proto GameState is still needed at boundaries: persistence, gRPC to Shardok
|
||||
- `PerformUnaffiliatedHeroesAction` and the LLM pipeline use protoless `GameState`
|
||||
- `GameStateViewFilter` Scala overload is now fully protoless internally (converts to proto only at output)
|
||||
- `ProvinceViewFilter` has Scala overloads for all three modes: no filtering, faction filtering, and withdrawn-from view
|
||||
|
||||
+46
-7
@@ -57,25 +57,47 @@ llvm.toolchain(
|
||||
llvm_version = "20.1.2",
|
||||
)
|
||||
|
||||
# Linux sysroot for cross-compilation (Chromium's Debian sysroot)
|
||||
# Linux x86_64 sysroot for cross-compilation
|
||||
llvm.sysroot(
|
||||
name = "llvm_toolchain_linux",
|
||||
label = "@linux_sysroot//sysroot",
|
||||
targets = ["linux-x86_64"],
|
||||
)
|
||||
|
||||
use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux")
|
||||
# Cross-compilation toolchain (macOS -> Linux ARM64)
|
||||
llvm.toolchain(
|
||||
name = "llvm_toolchain_linux_arm64",
|
||||
llvm_version = "20.1.2",
|
||||
)
|
||||
|
||||
# Download the Linux sysroot (Ubuntu 24.04 Noble for C++23 support)
|
||||
# Linux ARM64 sysroot for cross-compilation
|
||||
llvm.sysroot(
|
||||
name = "llvm_toolchain_linux_arm64",
|
||||
label = "@linux_sysroot_arm64//sysroot",
|
||||
targets = ["linux-aarch64"],
|
||||
)
|
||||
|
||||
use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux", "llvm_toolchain_linux_arm64")
|
||||
|
||||
# Download the Linux sysroots (Ubuntu 24.04 Noble for C++23 support)
|
||||
# Built by: .github/workflows/build_sysroot.yml
|
||||
# To rebuild: Run the "Build Linux Sysroot" workflow with a new version, then update sha256 and URL
|
||||
sysroot = use_repo_rule("@toolchains_llvm//toolchain:sysroot.bzl", "sysroot")
|
||||
|
||||
# x86_64 sysroot
|
||||
sysroot(
|
||||
name = "linux_sysroot",
|
||||
sha256 = "a06475004fe8003ae7ccb4fe1d5511feb9b27cce4a8826eb1dfd686ed83f3dba",
|
||||
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
|
||||
)
|
||||
|
||||
# ARM64 sysroot
|
||||
sysroot(
|
||||
name = "linux_sysroot_arm64",
|
||||
sha256 = "87469137737e09bc73855007dab835477eb10a7b3ce3f725f93f64e25747f3f9",
|
||||
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v4/ubuntu_noble_arm64_sysroot.tar.xz"],
|
||||
)
|
||||
|
||||
#
|
||||
# Language Support - Go
|
||||
#
|
||||
@@ -94,6 +116,8 @@ use_repo(
|
||||
"com_github_aws_aws_sdk_go_v2_config",
|
||||
"com_github_aws_aws_sdk_go_v2_credentials",
|
||||
"com_github_aws_aws_sdk_go_v2_service_s3",
|
||||
"com_github_golang_jwt_jwt_v5",
|
||||
"com_github_google_uuid",
|
||||
"org_golang_google_grpc",
|
||||
"org_golang_google_protobuf",
|
||||
)
|
||||
@@ -130,19 +154,22 @@ 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)
|
||||
# Base image for Eagle (Java 17 JDK - includes jcmd for JFR dumps)
|
||||
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)
|
||||
oci.pull(
|
||||
name = "ubuntu_24_04",
|
||||
image = "docker.io/library/ubuntu",
|
||||
platforms = ["linux/amd64"],
|
||||
platforms = [
|
||||
"linux/amd64",
|
||||
"linux/arm64/v8",
|
||||
],
|
||||
tag = "24.04",
|
||||
)
|
||||
|
||||
@@ -153,7 +180,7 @@ oci.pull(
|
||||
platforms = ["linux/amd64"],
|
||||
tag = "3.21",
|
||||
)
|
||||
use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64")
|
||||
use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64", "ubuntu_24_04_linux_arm64_v8")
|
||||
|
||||
#
|
||||
# Java/Scala Dependencies
|
||||
@@ -219,6 +246,9 @@ maven.install(
|
||||
|
||||
# JWT (for OAuth token handling)
|
||||
"com.nimbusds:nimbus-jose-jwt:9.37.3",
|
||||
|
||||
# Error tracking
|
||||
"io.sentry:sentry:7.19.0",
|
||||
],
|
||||
duplicate_version_warning = "error",
|
||||
fail_if_repin_required = True,
|
||||
@@ -273,6 +303,14 @@ http_file(
|
||||
executable = True,
|
||||
)
|
||||
|
||||
http_file(
|
||||
name = "busybox_aarch64",
|
||||
sha256 = "141adb1b625a6f44c4b114f76b4387b4ea4f7ab802b88eb40e0d2f6adcccb1c3",
|
||||
urls = ["https://busybox.net/downloads/binaries/1.35.0-aarch64-linux-musl/busybox"],
|
||||
downloaded_file_path = "busybox",
|
||||
executable = True,
|
||||
)
|
||||
|
||||
#
|
||||
# Toolchain Registration
|
||||
#
|
||||
@@ -286,5 +324,6 @@ register_toolchains(
|
||||
register_toolchains(
|
||||
"@llvm_toolchain//:all",
|
||||
"@llvm_toolchain_linux//:all",
|
||||
"@llvm_toolchain_linux_arm64//:all",
|
||||
dev_dependency = True,
|
||||
)
|
||||
|
||||
Generated
+20
-4
@@ -1293,7 +1293,7 @@
|
||||
"@@rules_oci~//oci:extensions.bzl%oci": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
|
||||
"usagesDigest": "KXZUVR9ea29hTmhxC4+BG0pTXTijLpsFrDVraHG4OyU=",
|
||||
"usagesDigest": "39yHQmifPoGf+JSYpnQSnJGugxbrFVge/+ENmUiqZ6M=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
@@ -1306,7 +1306,7 @@
|
||||
"scheme": "https",
|
||||
"registry": "index.docker.io",
|
||||
"repository": "library/eclipse-temurin",
|
||||
"identifier": "sha256:d286b5352d98777bbf727f54038b04f0145cd9b76ca83f38a67aa111d4303748",
|
||||
"identifier": "17-jdk",
|
||||
"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": "sha256:d286b5352d98777bbf727f54038b04f0145cd9b76ca83f38a67aa111d4303748",
|
||||
"identifier": "17-jdk",
|
||||
"platforms": {
|
||||
"@@platforms//cpu:x86_64": "@eclipse_temurin_17_linux_amd64"
|
||||
},
|
||||
@@ -1343,6 +1343,20 @@
|
||||
"bazel_tags": []
|
||||
}
|
||||
},
|
||||
"ubuntu_24_04_linux_arm64_v8": {
|
||||
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
|
||||
"ruleClassName": "oci_pull",
|
||||
"attributes": {
|
||||
"www_authenticate_challenges": {},
|
||||
"scheme": "https",
|
||||
"registry": "index.docker.io",
|
||||
"repository": "library/ubuntu",
|
||||
"identifier": "24.04",
|
||||
"platform": "linux/arm64/v8",
|
||||
"target_name": "ubuntu_24_04_linux_arm64_v8",
|
||||
"bazel_tags": []
|
||||
}
|
||||
},
|
||||
"ubuntu_24_04": {
|
||||
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
|
||||
"ruleClassName": "oci_alias",
|
||||
@@ -1354,7 +1368,8 @@
|
||||
"repository": "library/ubuntu",
|
||||
"identifier": "24.04",
|
||||
"platforms": {
|
||||
"@@platforms//cpu:x86_64": "@ubuntu_24_04_linux_amd64"
|
||||
"@@platforms//cpu:x86_64": "@ubuntu_24_04_linux_amd64",
|
||||
"@@platforms//cpu:arm64": "@ubuntu_24_04_linux_arm64_v8"
|
||||
},
|
||||
"bzlmod_repository": "ubuntu_24_04",
|
||||
"reproducible": true
|
||||
@@ -1528,6 +1543,7 @@
|
||||
"eclipse_temurin_17_linux_amd64",
|
||||
"ubuntu_24_04",
|
||||
"ubuntu_24_04_linux_amd64",
|
||||
"ubuntu_24_04_linux_arm64_v8",
|
||||
"alpine_linux",
|
||||
"alpine_linux_linux_amd64"
|
||||
],
|
||||
|
||||
+155
-7
@@ -17,6 +17,18 @@ pkg_tar(
|
||||
},
|
||||
)
|
||||
|
||||
pkg_tar(
|
||||
name = "busybox_layer_arm64",
|
||||
srcs = ["@busybox_aarch64//file"],
|
||||
package_dir = "/usr/local/bin",
|
||||
remap_paths = {
|
||||
"file/busybox": "busybox",
|
||||
},
|
||||
symlinks = {
|
||||
"/usr/local/bin/nc": "busybox",
|
||||
},
|
||||
)
|
||||
|
||||
#
|
||||
# Eagle Server Docker Image
|
||||
#
|
||||
@@ -51,19 +63,17 @@ oci_image(
|
||||
base = "@eclipse_temurin_17_linux_amd64",
|
||||
entrypoint = [
|
||||
"java",
|
||||
"-Xmx4g",
|
||||
"-Xmx2g",
|
||||
"-XX:+UseG1GC",
|
||||
# JFR: Continuous low-overhead profiling (~1% overhead).
|
||||
# Keeps 1 hour of data in a 100MB circular buffer.
|
||||
# Dump recording: docker exec eagle-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
|
||||
# Then copy out: docker cp eagle-server:/app/jfr/profile.jfr .
|
||||
"-XX:StartFlightRecording=disk=true,maxage=1h,maxsize=100m,dumponexit=true,filename=/app/jfr/recording.jfr",
|
||||
# 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": "-Xmx4g -XX:+UseG1GC",
|
||||
"JAVA_OPTS": "-Xmx2g -XX:+UseG1GC",
|
||||
},
|
||||
exposed_ports = ["40032/tcp"],
|
||||
tars = [
|
||||
@@ -157,6 +167,54 @@ oci_push(
|
||||
repository = "registry.digitalocean.com/eagle0/shardok-server",
|
||||
)
|
||||
|
||||
#
|
||||
# Shardok Server ARM64 Docker Image (for Hetzner on-demand compute)
|
||||
#
|
||||
# Build: bazel build //ci:shardok_server_image_arm64 --platforms=//:linux_arm64 --extra_toolchains=@llvm_toolchain_linux_arm64//:all
|
||||
# Load: bazel run //ci:shardok_server_load_arm64
|
||||
# Push: bazel run //ci:shardok_server_push_arm64
|
||||
#
|
||||
|
||||
# Package the Shardok binary (ARM64 version - must be built with --platforms=//:linux_arm64)
|
||||
pkg_tar(
|
||||
name = "shardok_binary_layer_arm64",
|
||||
srcs = ["//src/main/cpp/net/eagle0/shardok:shardok-server"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "shardok_server_image_arm64",
|
||||
base = "@ubuntu_24_04_linux_arm64_v8",
|
||||
entrypoint = ["/app/shardok-server"],
|
||||
exposed_ports = [
|
||||
"40042/tcp",
|
||||
"40052/tcp",
|
||||
],
|
||||
tars = [
|
||||
# Note: busybox_layer_arm64 omitted - busybox.net has SSL issues
|
||||
# Health checks can use the shardok-server binary itself or be added later
|
||||
":shardok_binary_layer_arm64",
|
||||
":shardok_resources_layer",
|
||||
":shardok_maps_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally (ARM64): bazel run //ci:shardok_server_load_arm64
|
||||
oci_load(
|
||||
name = "shardok_server_load_arm64",
|
||||
image = ":shardok_server_image_arm64",
|
||||
repo_tags = ["eagle0/shardok-server:latest-arm64"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry (for Hetzner deployment)
|
||||
# Uses same repository as x86 but with arm64- tag prefix
|
||||
oci_push(
|
||||
name = "shardok_server_push_arm64",
|
||||
image = ":shardok_server_image_arm64",
|
||||
repository = "registry.digitalocean.com/eagle0/shardok-server",
|
||||
)
|
||||
|
||||
#
|
||||
# Admin Server Docker Image (Go)
|
||||
#
|
||||
@@ -197,3 +255,93 @@ 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",
|
||||
)
|
||||
|
||||
#
|
||||
# Auth Server Docker Image (Go)
|
||||
#
|
||||
# This is the external OAuth service that handles OAuth flows and JWT creation.
|
||||
# Build: bazel build //ci:auth_server_image
|
||||
# Load: bazel run //ci:auth_server_load
|
||||
# Push: bazel run //ci:auth_server_push
|
||||
#
|
||||
|
||||
# Package the Go auth binary (explicit Linux x86_64 target)
|
||||
pkg_tar(
|
||||
name = "auth_binary_layer",
|
||||
srcs = [
|
||||
"//src/main/go/net/eagle0/authcli:authcli_linux_amd64",
|
||||
"//src/main/go/net/eagle0/authservice:authservice_linux_amd64",
|
||||
],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "auth_server_image",
|
||||
base = "@alpine_linux_linux_amd64",
|
||||
entrypoint = ["/app/authservice_linux_amd64"],
|
||||
exposed_ports = [
|
||||
"40033/tcp", # gRPC
|
||||
"8080/tcp", # HTTP OAuth callback
|
||||
],
|
||||
tars = [
|
||||
":busybox_layer",
|
||||
":auth_binary_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally: bazel run //ci:auth_server_load
|
||||
oci_load(
|
||||
name = "auth_server_load",
|
||||
image = ":auth_server_image",
|
||||
repo_tags = ["eagle0/auth-server:latest"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry
|
||||
oci_push(
|
||||
name = "auth_server_push",
|
||||
image = ":auth_server_image",
|
||||
repository = "registry.digitalocean.com/eagle0/auth-server",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Docker Compose for Shardok-AI server deployment (Hetzner)
|
||||
#
|
||||
# This configuration runs Shardok in AI-only mode, providing AI computation
|
||||
# services for Shardok-Primary instances running on DigitalOcean.
|
||||
#
|
||||
# Architecture:
|
||||
# Unity Client -> Eagle -> Shardok-Primary (DO) -> Shardok-AI (Hetzner)
|
||||
# - Shardok-Primary handles all command processing (low latency for humans)
|
||||
# - Shardok-AI handles AI computation only (high throughput for AI moves)
|
||||
#
|
||||
# Deployment:
|
||||
# 1. Copy this file to the Hetzner server
|
||||
# 2. Set environment variables (see below)
|
||||
# 3. Run: docker compose -f docker-compose.ai.yml up -d
|
||||
#
|
||||
# Required environment variables:
|
||||
# SHARDOK_AI_AUTH_TOKEN_PATH - Path to file containing auth token
|
||||
|
||||
services:
|
||||
shardok-ai:
|
||||
image: ${SHARDOK_IMAGE:-registry.digitalocean.com/eagle0/shardok-server:arm64-latest}
|
||||
container_name: shardok-ai-server
|
||||
mem_limit: 4g # More memory for AI computation
|
||||
memswap_limit: 4g
|
||||
ports:
|
||||
- "40043:40043" # AI service port
|
||||
environment:
|
||||
SHARDOK_RESOURCES_PATH: "/app/resources"
|
||||
SHARDOK_MAPS_PATH: "/app/resources/maps"
|
||||
# Disable Eagle interface - this is AI-only mode
|
||||
SHARDOK_EAGLE_INTERFACE_ADDRESS: ""
|
||||
# Enable AI service
|
||||
SHARDOK_AI_SERVICE_ADDRESS: "0.0.0.0:40043"
|
||||
# Auth token for validating requests from Shardok-Primary
|
||||
SHARDOK_AUTH_TOKEN_PATH: "${SHARDOK_AI_AUTH_TOKEN_PATH:-/etc/eagle0/auth/shardok-ai.token}"
|
||||
# TLS configuration (optional but recommended for production)
|
||||
SHARDOK_SSL_CERT_PATH: "${SHARDOK_SSL_CERT_PATH:-}"
|
||||
SHARDOK_SSL_PRIVATE_KEY_PATH: "${SHARDOK_SSL_PRIVATE_KEY_PATH:-}"
|
||||
volumes:
|
||||
- ./auth:/etc/eagle0/auth:ro # Auth token
|
||||
- ./certbot/conf:/etc/letsencrypt:ro # TLS certificates
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "5"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z localhost 40043 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Optional: nginx for TLS termination and rate limiting
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: nginx
|
||||
ports:
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx-ai.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./certbot/conf:/etc/letsencrypt:ro
|
||||
depends_on:
|
||||
- shardok-ai
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
# Optional: certbot for TLS certificate renewal
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
container_name: certbot
|
||||
volumes:
|
||||
- ./certbot/conf:/etc/letsencrypt
|
||||
- ./certbot/www:/var/www/certbot
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
enable_ipv6: true
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.29.0.0/16
|
||||
- subnet: fd00:dead:cafe::/48
|
||||
+112
-3
@@ -1,7 +1,7 @@
|
||||
# Docker Compose for production deployment
|
||||
#
|
||||
# Local testing:
|
||||
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load
|
||||
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load && bazel run //ci:auth_server_load
|
||||
# Run: docker compose -f docker-compose.prod.yml up
|
||||
#
|
||||
# Production deployment:
|
||||
@@ -15,7 +15,9 @@ services:
|
||||
- "--gpt-model-name"
|
||||
- "${GPT_MODEL_NAME:-gpt-5.1}"
|
||||
- "--shardok-interface-remote-address"
|
||||
- "shardok:40042"
|
||||
- "${SHARDOK_ADDRESS:-shardok:40042}"
|
||||
- "--auth-service-url"
|
||||
- "auth:40033"
|
||||
ports:
|
||||
- "40032:40032"
|
||||
environment:
|
||||
@@ -24,11 +26,25 @@ 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:-}"
|
||||
# Auth token for remote Shardok on Hetzner (only used when shardok address contains .eagle0.net)
|
||||
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
|
||||
# Use persistent volume for save data (users, games, etc.)
|
||||
EAGLE_SAVE_DIR: "/app/saves"
|
||||
EAGLE_ARCHIVE_DIR: "/app/archived"
|
||||
SENTRY_DSN: "${SENTRY_DSN:-}"
|
||||
SENTRY_ENVIRONMENT: "production"
|
||||
volumes:
|
||||
- ./saves:/app/saves
|
||||
- ./saves:/app/saves # Game saves and user database
|
||||
- ./archived:/app/archived # Archived completed games
|
||||
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
|
||||
- jvm-tmp:/tmp # Shared with jfr-sidecar for JVM attach socket files
|
||||
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
|
||||
depends_on:
|
||||
- shardok
|
||||
- auth
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
@@ -42,9 +58,51 @@ services:
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
auth:
|
||||
image: ${AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
|
||||
container_name: auth-server
|
||||
environment:
|
||||
# gRPC port for Auth service
|
||||
AUTH_GRPC_PORT: "40033"
|
||||
# HTTP port for OAuth callbacks
|
||||
AUTH_HTTP_PORT: "8080"
|
||||
# User data persistence directory
|
||||
AUTH_DATA_DIR: "/app/data"
|
||||
# Legacy path for migrating users from Eagle (Phase 1 migration)
|
||||
AUTH_LEGACY_DATA_DIR: "/app/saves/auth"
|
||||
# OAuth provider credentials
|
||||
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
|
||||
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
|
||||
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}"
|
||||
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}"
|
||||
# Server base URL for OAuth callbacks
|
||||
SERVER_BASE_URL: "${SERVER_BASE_URL:-https://prod.eagle0.net}"
|
||||
# JWT keys - PEM files in volume, bootstrapped from JWK on first run
|
||||
JWT_KEYS_PATH: "/etc/eagle0/keys"
|
||||
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
|
||||
# Note: port 40033 is exposed via nginx, not directly
|
||||
volumes:
|
||||
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with Eagle
|
||||
- ./auth-data:/app/data # User database persistence
|
||||
- ./saves:/app/saves:ro # Read-only access to Eagle's saves for migration
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z localhost 40033 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
shardok:
|
||||
image: ${SHARDOK_IMAGE:-registry.digitalocean.com/eagle0/shardok-server:latest}
|
||||
container_name: shardok-server
|
||||
mem_limit: 1g
|
||||
memswap_limit: 1g # Prevent swap, OOM-kill cleanly instead
|
||||
ports:
|
||||
- "40042:40042"
|
||||
- "40052:40052"
|
||||
@@ -52,6 +110,13 @@ services:
|
||||
SHARDOK_RESOURCES_PATH: "/app/resources"
|
||||
SHARDOK_MAPS_PATH: "/app/resources/maps"
|
||||
SHARDOK_EAGLE_INTERFACE_ADDRESS: "0.0.0.0:40042"
|
||||
# Remote AI offload configuration (optional)
|
||||
# When set, AI computation is offloaded to a remote server for reduced latency
|
||||
SHARDOK_REMOTE_AI_ADDRESS: "${SHARDOK_REMOTE_AI_ADDRESS:-}"
|
||||
SHARDOK_REMOTE_AI_AUTH_TOKEN_PATH: "${SHARDOK_REMOTE_AI_AUTH_TOKEN_PATH:-}"
|
||||
SHARDOK_REMOTE_AI_TIMEOUT_MS: "${SHARDOK_REMOTE_AI_TIMEOUT_MS:-10000}"
|
||||
volumes:
|
||||
- ./auth:/etc/eagle0/auth:ro # Auth token for remote AI
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
@@ -71,6 +136,7 @@ services:
|
||||
ports:
|
||||
- "443:443"
|
||||
- "80:80"
|
||||
- "40033:40033" # Go Auth service gRPC (Phase 2 direct client connections)
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./certbot/conf:/etc/letsencrypt:ro
|
||||
@@ -91,12 +157,18 @@ services:
|
||||
command:
|
||||
- "--eagle-addr"
|
||||
- "eagle:40032"
|
||||
- "--auth-addr"
|
||||
- "auth:40033"
|
||||
- "--jfr-sidecar-addr"
|
||||
- "jfr-sidecar:8081"
|
||||
- "--http-port"
|
||||
- "8080"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- eagle
|
||||
- auth
|
||||
- jfr-sidecar
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
@@ -110,6 +182,28 @@ 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
|
||||
@@ -117,3 +211,18 @@ 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
|
||||
jwt-keys:
|
||||
# Shared JWT RSA keys between Eagle and auth service
|
||||
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
enable_ipv6: true
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.0.0/16
|
||||
- subnet: fd00:dead:beef::/48
|
||||
|
||||
@@ -6,10 +6,18 @@ This document outlines enhancements to the Go admin server (`src/main/go/net/eag
|
||||
|
||||
### Current State
|
||||
|
||||
The admin server exposes REST endpoints that return JSON:
|
||||
- `GET /health` - health check
|
||||
- `GET /games` - list running games
|
||||
- `GET /games/{id}/history` - paginated action history
|
||||
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
|
||||
|
||||
@@ -388,24 +396,59 @@ trait GameHistory {
|
||||
|
||||
**Deliverable**: View and edit settings via admin UI
|
||||
|
||||
### Phase 3: Game Rewind (2-3 days)
|
||||
### Phase 3: Game Rewind
|
||||
|
||||
1. Add `RewindGame` to `eagle.proto`
|
||||
2. Implement `stateAt` and `truncateTo` in `GameHistory`
|
||||
3. Implement rewind logic in `GameController`
|
||||
4. Add rewind confirmation modal
|
||||
5. Handle client disconnection gracefully
|
||||
6. Add rewind button to history rows
|
||||
**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 (1-2 days)
|
||||
### Phase 4: Polish
|
||||
|
||||
1. Add loading states and error handling
|
||||
2. Improve action history display (type icons, summaries)
|
||||
3. Add settings categories and filtering
|
||||
4. Add basic auth (optional)
|
||||
5. Documentation
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
# Eagle0 Media Asset Audit
|
||||
|
||||
This document catalogs all media assets in the Unity project for licensing review.
|
||||
|
||||
**Total Assets:** ~12,500 files | **Size:** 1.4 GB
|
||||
|
||||
---
|
||||
|
||||
## Summary by Category
|
||||
|
||||
| Category | Count | Notes |
|
||||
|----------|-------|-------|
|
||||
| Images | 10,637 | Mostly PNG icons and UI sprites |
|
||||
| Audio | 1,778 | 26 music tracks + 1,752 sound effects |
|
||||
| 3D Models | 52 | Bridge pack only |
|
||||
| Fonts | 16 | TTF files |
|
||||
|
||||
---
|
||||
|
||||
## 1. Purchased Asset Store Packages
|
||||
|
||||
These are commercial Unity Asset Store purchases tied to your account:
|
||||
|
||||
### 4000_Fantasy_Icons
|
||||
- **Location:** `Assets/4000_Fantasy_Icons/`
|
||||
- **Size:** 495 MB (5,621 PNG files)
|
||||
- **Contents:** Icons for armor, weapons, skills, resources
|
||||
- **License:** Unity Asset Store (check invoice/account)
|
||||
|
||||
### GUI Pro Kit Fantasy RPG
|
||||
- **Location:** `Assets/GUI Pro Kit Fantasy RPG/`
|
||||
- **Size:** 117 MB (3,755 PNG files)
|
||||
- **Contents:** UI sprites, animations, prefabs
|
||||
- **Includes fonts:** Alata-Regular.ttf, JosefinSans-Bold.ttf
|
||||
- **License:** Unity Asset Store
|
||||
|
||||
### Modern UI Pack v4.2.0
|
||||
- **Location:** `Assets/Modern UI Pack/`
|
||||
- **Size:** 40 MB (191 PNG files)
|
||||
- **Author:** Michsky (support@michsky.com)
|
||||
- **Website:** https://www.michsky.com
|
||||
- **Includes fonts:** Open Sans family (12 variants)
|
||||
- **License:** Unity Asset Store
|
||||
|
||||
### Pixel Fonts Megapack
|
||||
- **Location:** `Assets/Pixel Fonts Megapack/`
|
||||
- **Publisher ID:** 17384
|
||||
- **Author:** @pixelmush_ on Twitter
|
||||
- **Asset Store Link:** http://u3d.as/w4v
|
||||
- **License:** Unity Asset Store
|
||||
|
||||
### TileableBridgePack
|
||||
- **Location:** `Assets/TileableBridgePack/`
|
||||
- **Size:** 3.1 MB (52 FBX models)
|
||||
- **Contents:** Bridge construction pieces
|
||||
- **License:** Unity Asset Store
|
||||
|
||||
### Fantasy Interface Sounds
|
||||
- **Location:** `Assets/Fantasy Interface Sounds/`
|
||||
- **Count:** 320 WAV files
|
||||
- **Contents:** UI sounds (bag, book, coins, dice, etc.)
|
||||
- **License:** Unity Asset Store (verify)
|
||||
|
||||
### Medieval Combat Sounds
|
||||
- **Location:** `Assets/Medieval Combat Sounds/`
|
||||
- **Count:** 1,072 WAV files
|
||||
- **Contents:** Footsteps, swings, shields, weapons, magic
|
||||
- **License:** Unity Asset Store (verify)
|
||||
|
||||
### Magic Spells Sound Effects LITE
|
||||
- **Location:** `Assets/Magic Spells Sound Effects LITE/`
|
||||
- **Count:** 254 WAV files
|
||||
- **Contents:** Spell casting, element effects
|
||||
- **Note:** "LITE" version - check if restrictions apply
|
||||
- **License:** Unity Asset Store (verify)
|
||||
|
||||
---
|
||||
|
||||
## 2. Creative Commons Music (Properly Licensed)
|
||||
|
||||
**Location:** `Assets/Resources/Music/`
|
||||
**Documentation:** `Music Credits.txt` (attribution file exists)
|
||||
|
||||
All 26 tracks have CC licenses with proper attribution:
|
||||
|
||||
| Track | Artist | License |
|
||||
|-------|--------|---------|
|
||||
| A Robust Crew | Darren Curtis | CC BY 3.0 |
|
||||
| Asian Graveyard | Darren Curtis | CC BY 3.0 |
|
||||
| Fall From Grace | Darren Curtis | CC BY 3.0 |
|
||||
| Samurai Sake Showdown | Darren Curtis | CC BY 3.0 |
|
||||
| Deflector | Ghostrifter Official | CC BY-SA 3.0 |
|
||||
| Chase | Alexander Nakarada | CC BY 4.0 |
|
||||
| Wintersong | Alexander Nakarada | CC BY 4.0 |
|
||||
| One Bard Band | Alexander Nakarada | CC BY 4.0 |
|
||||
| Now We Ride | Alexander Nakarada | CC BY 4.0 |
|
||||
| The Northern Path | Alexander Nakarada | CC BY 4.0 |
|
||||
| Victory | MaxKoMusic | CC BY-SA 3.0 |
|
||||
| Sakuya2 | PeriTune | CC BY 3.0 |
|
||||
| Under The Sun | Keys of Moon | CC BY 4.0 |
|
||||
| One Piece of Summer | Keys of Moon | CC BY 4.0 |
|
||||
| Fluffing a Duck | Kevin MacLeod | CC BY 3.0 |
|
||||
| Space Jazz | Kevin MacLeod | CC BY 3.0 |
|
||||
| The Ice Giants | Kevin MacLeod | CC BY 4.0 |
|
||||
| Epic Cinematic Trailer ELITE | Alex-Productions | CC BY 3.0 |
|
||||
| Push | Alex-Productions | CC BY 3.0 |
|
||||
| Virus | Alex-Productions | CC BY 3.0 |
|
||||
| Duel | Makai Symphony | CC BY-SA 3.0 |
|
||||
| Dragon Castle | Makai Symphony | CC BY-SA 3.0 |
|
||||
| Durandal | Makai Symphony | CC BY-SA 3.0 |
|
||||
|
||||
**Tracks without specific license (verify):**
|
||||
- Market Day
|
||||
- Shopping List
|
||||
- Medieval: Victory Theme
|
||||
- Tracks by Dima Koltsov (AUDIUS): No Time for Greatness, Warriors of Demacia, Forest Queen Tale, Valor, Clouds
|
||||
|
||||
---
|
||||
|
||||
## 3. CC0 / Public Domain Assets
|
||||
|
||||
### SimpleFileBrowser Icons
|
||||
- **Location:** `Assets/Plugins/SimpleFileBrowser/Sprites/FileIcons/`
|
||||
- **License:** CC0 (documented in LICENSE.txt)
|
||||
- **Source:** pngrepo.com
|
||||
- **Items:** Archive, Audio, Default, Drive, Folder, Image, PDF, Text, Video icons
|
||||
|
||||
---
|
||||
|
||||
## 4. Potentially Problematic Assets (Review Needed)
|
||||
|
||||
### Stock Images (Possible License Issues)
|
||||
These appear to be stock images that may have been used as placeholders:
|
||||
|
||||
| File | Concern |
|
||||
|------|---------|
|
||||
| `Assets/Eagle/79066358-stock-illustration-raster-illustration-medieval-purse-bag...jpg` | Stock image watermark in filename |
|
||||
| `Assets/Images/kisspng-hammer-hand-saws-tool-clip-art...jpg` | KissPNG source (verify license) |
|
||||
| `Assets/Images/lee-ermy-cropped.jpg` | Photo of R. Lee Ermey (publicity rights?) |
|
||||
| `Assets/Eagle/images.jpeg` | Generic filename, unknown source |
|
||||
|
||||
### Shardok Sound Effects
|
||||
- **Location:** `Assets/Shardok/soundEffects/`
|
||||
- **Count:** 56 MP3 files
|
||||
- **Contents:** Spell effects, movement, combat sounds
|
||||
- **Status:** Unknown origin - may be custom or need verification
|
||||
|
||||
### Free Icons
|
||||
- **Location:** `Assets/free_icons/`
|
||||
- **Count:** 8 PNG weather icons
|
||||
- **Status:** Verify "free" means commercially usable
|
||||
|
||||
### Terrain Hexes
|
||||
- **Location:** `Assets/Terrain Hexes/`
|
||||
- **Count:** 85 PNG files
|
||||
- **Status:** Unknown source - verify licensing
|
||||
|
||||
### StrategyGameIcons
|
||||
- **Location:** `Assets/StrategyGameIcons/`
|
||||
- **Count:** 138 PNG files
|
||||
- **Status:** Unknown source - verify licensing
|
||||
|
||||
---
|
||||
|
||||
## 5. Fonts
|
||||
|
||||
| Font | Location | License |
|
||||
|------|----------|---------|
|
||||
| Open Sans (12 variants) | Modern UI Pack | Apache 2.0 (Google Font) |
|
||||
| Alata-Regular | GUI Pro Kit | SIL OFL (Google Font) |
|
||||
| JosefinSans-Bold | GUI Pro Kit | SIL OFL (Google Font) |
|
||||
| LiberationSans | TextMesh Pro | SIL OFL |
|
||||
| NotoColorEmoji | Assets root | SIL OFL (Google) |
|
||||
| Stoke-Light, Stoke-Regular | Assets root | SIL OFL (Google Font) |
|
||||
|
||||
All fonts appear to be open-source Google Fonts or Liberation fonts - should be fine.
|
||||
|
||||
---
|
||||
|
||||
## 6. Third-Party Code Packages
|
||||
|
||||
NuGet packages in `Assets/Packages/` all include LICENSE.TXT files:
|
||||
- Microsoft.Extensions.* - MIT License
|
||||
- System.* - MIT License
|
||||
- Grpc.* - Apache 2.0
|
||||
|
||||
---
|
||||
|
||||
## Action Items
|
||||
|
||||
### Must Verify Before Opening Public Access:
|
||||
|
||||
1. ~~**Stock images** - The JPG files with stock image filenames need review.~~ **DONE** - Deleted lee-ermy, kisspng, stock-illustration, Yosemite Sam, and images.jpeg (2025-01-04)
|
||||
|
||||
2. **Shardok sound effects** - 56 MP3 files of unknown origin. Either:
|
||||
- Document their source
|
||||
- Replace with known-licensed alternatives
|
||||
- Confirm they were custom-created
|
||||
|
||||
3. **Terrain Hexes** - 85 hex tiles of unknown source
|
||||
- **TODO:** Confirm if this is a Unity Asset Store purchase (owner believes it is)
|
||||
|
||||
4. **StrategyGameIcons** - 138 icons of unknown source
|
||||
- **TODO:** Investigate origin - check Unity Asset Store purchase history
|
||||
|
||||
5. **AUDIUS music tracks** - Verify Dima Koltsov tracks are licensed for commercial use
|
||||
|
||||
6. **Discord logo** (`Eagle/Discord-Logo-Blurple.png`) - Likely fine for "Login with Discord" button per Discord brand guidelines, but verify usage complies with their terms
|
||||
|
||||
### Already Safe:
|
||||
|
||||
- All Asset Store purchases (license tied to your account)
|
||||
- CC-licensed music (attribution in Music Credits.txt)
|
||||
- CC0 SimpleFileBrowser icons
|
||||
- Google Fonts / Liberation fonts
|
||||
- NuGet packages
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
Before removing HTTP basic auth:
|
||||
|
||||
1. Delete or replace the 4 suspicious JPG/JPEG files in `Assets/Eagle/` and `Assets/Images/`
|
||||
2. Verify source of `Assets/Shardok/soundEffects/` MP3s
|
||||
3. Verify source of `Assets/Terrain Hexes/` and `Assets/StrategyGameIcons/`
|
||||
4. If any are from early development with unclear licensing, replace them
|
||||
|
||||
The bulk of your assets (95%+) are properly licensed Asset Store purchases or CC content.
|
||||
+22
-31
@@ -190,24 +190,24 @@ Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versi
|
||||
| `RandomStateSequencer.scala` | Threads Scala GameState | ✅ **Complete** |
|
||||
| `VigorXPApplier.scala` | Has both proto and Scala methods | Scala method exists, delete proto method when unused |
|
||||
| `PerformForcedTurnBackAction.scala` | Fully protoless | ✅ **Complete** |
|
||||
| `ResolveBattleAction.scala` | Heavy proto usage | Blocked by proto dependencies |
|
||||
| `ResolveBattleAction.scala` | ✅ Fully protoless | Complete (PR #5048) |
|
||||
| `InMemoryHistory.scala` | Stores proto results | Pending - vend Scala types |
|
||||
| `PersistedHistory.scala` | Stores proto results | Pending - vend Scala types, convert for disk |
|
||||
| `GameController.scala` | Uses proto for client communication | Keep proto (gRPC boundary) |
|
||||
|
||||
### Remaining Proto Usage in Actions
|
||||
|
||||
**Progress: 47 of 52 action files (90%) are fully protoless.**
|
||||
**Progress: 52 of 52 action files (100%) are fully protoless.** ✅
|
||||
|
||||
The following 5 actions still have proto usage:
|
||||
All action files have been migrated to use Scala types:
|
||||
|
||||
| Action | Proto Usages | Blocker | Effort |
|
||||
|--------|--------------|---------|--------|
|
||||
| `ResolveBattleAction` | 24 | Shardok interface, complex battle logic | High |
|
||||
| `PerformVassalCommandsPhaseAction` | 3 | `CommandChoiceHelpers` takes proto GameState | Medium |
|
||||
| `EndHandleRiotsPhaseAction` | 2 | `CommandChoiceHelpers` takes proto GameState | Medium |
|
||||
| `PerformVassalDefenseDecisionsAction` | 2 | `CommandChoiceHelpers` takes proto GameState | Medium |
|
||||
| `EndVassalCommandsPhaseAction` | 1 | `CommandChoiceHelpers` takes proto GameState | Medium |
|
||||
| Action | Status | Notes |
|
||||
|--------|--------|-------|
|
||||
| `ResolveBattleAction` | ✅ Complete | PR #5048 - uses Scala GameState and ActionResultApplier |
|
||||
| `PerformVassalCommandsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
|
||||
| `EndHandleRiotsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
|
||||
| `PerformVassalDefenseDecisionsAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
|
||||
| `EndVassalCommandsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
|
||||
|
||||
**Note:** `NewRoundAction` is now fully protoless after converting `ChronicleEventGenerator` to return Scala `ChronicleEvent` types directly.
|
||||
|
||||
@@ -223,13 +223,13 @@ The following 5 actions still have proto usage:
|
||||
|
||||
| Component | Lines | Complexity | Blocks |
|
||||
|-----------|-------|------------|--------|
|
||||
| `CommandChoiceHelpers` to Scala | ~2000 | High | 4 vassal actions |
|
||||
| `ResolveBattleAction` refactor | ~500 | High | 1 action (complex) |
|
||||
| History API updates | ~100 | Low | - |
|
||||
| **Total Remaining** | **~2600** | | |
|
||||
| **Total Remaining** | **~100** | | |
|
||||
|
||||
**Completed:**
|
||||
- `ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
|
||||
- ✅ `ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
|
||||
- ✅ `CommandChoiceHelpers` migrated to Scala types
|
||||
- ✅ `ResolveBattleAction` refactored to use Scala GameState and ActionResultApplier (PR #5048)
|
||||
|
||||
### CommandChoiceHelpers Migration Status
|
||||
|
||||
@@ -246,28 +246,18 @@ Several command selectors have already been converted to use Scala types:
|
||||
| `ImproveCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `HeroT` |
|
||||
| `OrganizeCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `BattalionT`, `BattalionType` |
|
||||
| `RansomOfferHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState`, `FactionT` |
|
||||
| `CommandChoiceHelpers.scala` | ❌ Proto | Main entry point, converts to Scala when calling converted selectors |
|
||||
| `ProvinceGoldSurplusCalculator.scala` | **Partial** | Has both Scala and proto overloads |
|
||||
| Other selectors | ❌ Proto | Various proto dependencies |
|
||||
| `CommandChoiceHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState` throughout |
|
||||
| `ProvinceGoldSurplusCalculator.scala` | ✅ **Protoless** | Uses Scala types |
|
||||
|
||||
**Pattern**: `CommandChoiceHelpers` currently uses `GameStateConverter.fromProto(gameState)` when calling already-converted selectors like `AlmsCommandSelector` and `AttackCommandChooser`. This allows incremental migration.
|
||||
|
||||
**Next Steps**:
|
||||
1. ~~Convert `ExpandCommandSelector` to Scala types~~ ✅ Done
|
||||
2. ~~Convert `ImproveCommandSelector` to Scala types~~ ✅ Done
|
||||
3. ~~Convert `OrganizeCommandSelector` to Scala types~~ ✅ Done (PR #4812)
|
||||
4. ~~Convert `RansomOfferHelpers` to Scala types~~ ✅ Done (PR #4821)
|
||||
5. Convert remaining selectors one at a time
|
||||
6. Update `CommandChoiceHelpers` to accept Scala `GameState` once all selectors are converted
|
||||
**All CommandChoiceHelpers selectors have been migrated to Scala types.** ✅
|
||||
|
||||
### Progress Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Action files fully protoless | 47 / 52 (90%) |
|
||||
| Proto usages in remaining actions | 32 total |
|
||||
| Biggest blocker | `ResolveBattleAction` (24 usages) |
|
||||
| Second biggest blocker | `CommandChoiceHelpers` (blocks 4 actions) |
|
||||
| Action files fully protoless | 52 / 52 (100%) ✅ |
|
||||
| Proto usages in remaining actions | 0 |
|
||||
| Next target | History APIs (InMemoryHistory, PersistedHistory) |
|
||||
|
||||
### Validation
|
||||
- [x] `ActionResultApplier` created and tested
|
||||
@@ -276,8 +266,9 @@ Several command selectors have already been converted to use Scala types:
|
||||
- [x] `ProvinceViewFilter` has Scala overload for server-side use (PR #4752)
|
||||
- [x] `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` use Scala `ProvinceView`
|
||||
- [x] `ProvinceViewFilter.withdrawnFromProvinceView` has Scala overload
|
||||
- [x] `CommandChoiceHelpers` uses Scala types ✅
|
||||
- [x] All action files (52/52) are fully protoless ✅
|
||||
- [ ] `ProvinceViewFilter` faction-filtered views use Scala types
|
||||
- [ ] `CommandChoiceHelpers` uses Scala types
|
||||
- [ ] History APIs vend Scala types
|
||||
- [ ] No `ActionResultProtoConverter.toProto()` calls except at persistence/gRPC boundaries
|
||||
- [ ] All tests pass
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# Hetzner Setup Guide
|
||||
|
||||
This guide walks through setting up Hetzner Cloud infrastructure for running Shardok on-demand compute.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- All code PRs merged (#4990, #4996, #4998, #5001, #5009)
|
||||
- Access to DigitalOcean Container Registry (for pulling Shardok ARM64 image)
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create Hetzner Cloud Account
|
||||
|
||||
1. Go to https://console.hetzner.cloud/
|
||||
2. Sign up and add payment method
|
||||
3. Create a new project (e.g., "eagle0")
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Generate Hetzner API Token
|
||||
|
||||
1. In Hetzner Console → Security → API Tokens
|
||||
2. Click "Generate API Token"
|
||||
3. Give it **Read & Write** permissions
|
||||
4. Copy the token (you'll only see it once)
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Generate Shardok Auth Token
|
||||
|
||||
Generate a 256-bit random token for Eagle-Shardok authentication:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Save this output - it's the shared secret between Eagle and Shardok.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Store Secrets in GitHub Actions
|
||||
|
||||
Add these secrets in GitHub → Settings → Secrets and variables → Actions:
|
||||
|
||||
| Secret Name | Description |
|
||||
|-------------|-------------|
|
||||
| `HETZNER_API_TOKEN` | From Step 2 - for Hetzner API calls |
|
||||
| `SHARDOK_AUTH_TOKEN` | From Step 3 - shared secret for gRPC auth |
|
||||
|
||||
Note: `DO_REGISTRY_TOKEN` already exists and will be used for Hetzner to pull container images.
|
||||
|
||||
These secrets will be passed to Eagle at runtime via `docker_build.yml`, similar to how `OPENAI_API_KEY` and other secrets are handled.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: DNS Setup (for Let's Encrypt)
|
||||
|
||||
You need a domain pointing to the Shardok instance for TLS certificates.
|
||||
|
||||
### Option A: Floating IP (Recommended)
|
||||
|
||||
1. In Hetzner Console → Networking → Floating IPs
|
||||
2. Create a **Floating IPv6** in **Hillsboro, Oregon (hil)** region
|
||||
- IPv6 costs €1/month vs €3/month for IPv4
|
||||
- Hillsboro has better latency to DigitalOcean SFO than Ashburn
|
||||
- Server-to-server communication works fine with IPv6-only
|
||||
3. Point `shardok.prod.eagle0.net` to this IP via AAAA record
|
||||
4. The ShardokInstanceManager will attach this IP to instances on spin-up
|
||||
|
||||
**Location choice**: Hillsboro, OR (`hil`) is recommended for US West Coast. Same pricing as Ashburn (`ash`).
|
||||
|
||||
### Option B: Dynamic DNS
|
||||
|
||||
Update DNS programmatically when instance spins up. More complex but avoids floating IP cost.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Upload SSH Key to Hetzner
|
||||
|
||||
For debugging access to instances:
|
||||
|
||||
1. In Hetzner Console → Security → SSH Keys
|
||||
2. Click "Add SSH Key"
|
||||
3. Paste your public key (e.g., `~/.ssh/id_rsa.pub`)
|
||||
4. Give it a name (e.g., "eagle-deploy")
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Wire Security Config into Eagle
|
||||
|
||||
Update Eagle's startup code to use the security config when connecting to remote Shardok:
|
||||
|
||||
```scala
|
||||
val securityConfig = ShardokSecurityConfig(
|
||||
useTls = true,
|
||||
authToken = Some(sys.env("SHARDOK_AUTH_TOKEN"))
|
||||
)
|
||||
|
||||
val channel = ServerSetupHelpers.newChannel(
|
||||
"shardok.prod.eagle0.net",
|
||||
50051,
|
||||
securityConfig
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Instance Spin-up
|
||||
|
||||
Test the Hetzner integration by triggering instance creation:
|
||||
|
||||
```scala
|
||||
val manager = new ShardokInstanceManager(
|
||||
hetznerApiToken = sys.env("HETZNER_API_TOKEN"),
|
||||
// ... other config
|
||||
)
|
||||
|
||||
manager.ensureInstanceRunning()
|
||||
```
|
||||
|
||||
### Verify TLS and Auth
|
||||
|
||||
1. Instance spins up and gets Let's Encrypt certificate
|
||||
2. Eagle connects via TLS
|
||||
3. Auth token is validated on each request
|
||||
|
||||
---
|
||||
|
||||
## Cost Estimate
|
||||
|
||||
| Component | Cost |
|
||||
|-----------|------|
|
||||
| CAX41 (16 ARM cores) | ~$0.04/hour |
|
||||
| Floating IP | ~$4/month |
|
||||
| Typical usage (20 hrs/week) | ~$3.50/month compute |
|
||||
|
||||
**Total: ~$7-8/month** for typical usage.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Instance won't start
|
||||
- Check Hetzner API token has Read & Write permissions
|
||||
- Verify you're using the correct region (`hil` for Hillsboro OR, or `ash` for Ashburn VA)
|
||||
|
||||
### TLS certificate fails
|
||||
- Ensure DNS points to the instance IP before certbot runs
|
||||
- Check port 80 is open for Let's Encrypt HTTP-01 challenge
|
||||
|
||||
### Auth failures
|
||||
- Verify `SHARDOK_AUTH_TOKEN` matches on both Eagle and Shardok
|
||||
- Check the token file is readable by Shardok container
|
||||
|
||||
### Can't pull container image
|
||||
- Ensure `DO_REGISTRY_TOKEN` is passed to cloud-init
|
||||
- Verify the ARM64 image exists: `registry.digitalocean.com/eagle0/shardok-server:arm64-latest`
|
||||
@@ -0,0 +1,383 @@
|
||||
# Plan: Extract OAuth to Go Service
|
||||
|
||||
## Goal
|
||||
Move OAuth authentication handling from the Eagle Scala server into a separate Go service. This simplifies Eagle (gRPC-only, no HTTP), reduces complexity, and sets up for potentially moving JWT validation outside Eagle too.
|
||||
|
||||
## Architecture Decision: Sidecar Service (Not DO Functions)
|
||||
|
||||
**Recommendation: Go sidecar service on the same droplet, in a separate container**
|
||||
|
||||
**Why not DO Functions:**
|
||||
- OAuth requires **stateful sessions** (pendingOAuth/completedOAuth maps with 10-min TTL)
|
||||
- Client polling pattern (every 2 seconds) would incur high function invocation costs
|
||||
- Cold start latency problematic for auth flows
|
||||
- State would require external store (Redis), adding complexity
|
||||
|
||||
**Why sidecar (separate container):**
|
||||
- Simple process on same droplet, minimal network latency
|
||||
- In-memory state management (like current Scala impl)
|
||||
- Easy to monitor/debug alongside Eagle
|
||||
- Can share filesystem for key files (RSA keys) via volume mounts
|
||||
- **Independent deployment**: Deploying Eagle doesn't restart auth service (and vice versa)
|
||||
- **Independent scaling**: Could move to separate droplet later if needed
|
||||
|
||||
## Current Architecture (What Exists)
|
||||
|
||||
```
|
||||
Unity Client
|
||||
├── GetOAuthUrl RPC → Eagle AuthServiceImpl → OAuthService.getAuthUrl()
|
||||
├── [User browser auth] → HTTP callback → OAuthHttpHandler → OAuthService.handleCallback()
|
||||
├── CheckOAuthStatus RPC (polling) → AuthServiceImpl → OAuthService.checkStatus()
|
||||
└── All other RPCs include JWT → AuthorizationInterceptor validates
|
||||
```
|
||||
|
||||
**Key files:**
|
||||
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow, state management
|
||||
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation
|
||||
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD (persisted)
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
|
||||
- `src/main/scala/net/eagle0/eagle/service/OAuthHttpHandler.scala` - HTTP callback handler
|
||||
|
||||
## Target Architecture (Phase 1)
|
||||
|
||||
```
|
||||
Unity Client
|
||||
├── GetOAuthUrl RPC ──────────────┐
|
||||
├── CheckOAuthStatus RPC (polling)├──→ Eagle (port 40032) ──proxy──→ Go Auth Container (port 40033)
|
||||
├── RefreshToken RPC ─────────────┘ │
|
||||
├── [User browser] → HTTP callback ────────────────────────────────────────┤
|
||||
│ ↓
|
||||
│ (Internal gRPC: GetOrCreateUser, GetUser)
|
||||
│ ↓
|
||||
└── Game RPCs with JWT ─────────────────────→ Eagle (port 40032) ← JWT validation stays here
|
||||
|
||||
[Same Droplet]
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌──────────────────────┐ ┌────────────────────────────────────┐ │
|
||||
│ │ Go Auth Container │◄────────►│ Eagle Container │ │
|
||||
│ │ (eagle0-auth) │ internal │ (eagle0-server) │ │
|
||||
│ │ │ gRPC │ │ │
|
||||
│ │ - OAuth flow │ │ - JWT validation │ │
|
||||
│ │ - JWT creation │ │ - UserService (persistence) │ │
|
||||
│ │ - HTTP callback │ │ - Game logic │ │
|
||||
│ └──────────────────────┘ └────────────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ └────────────────┬───────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ /etc/eagle0/keys/ (shared volume) │
|
||||
│ - private.pem │
|
||||
│ - public.pem │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
### Go Auth Service (NEW - separate container)
|
||||
- **OAuth flow**: getAuthUrl, handleCallback (HTTP), checkStatus
|
||||
- **State management**: pendingOAuth, completedOAuth maps with TTL
|
||||
- **JWT creation**: Issue access/refresh tokens (shares RSA private key with Eagle)
|
||||
- **Token refresh**: Validate refresh token, issue new access token
|
||||
- Calls Eagle's internal UserService gRPC to find/create users
|
||||
|
||||
### Eagle Server (SIMPLIFIED)
|
||||
- **JWT validation**: AuthorizationInterceptor stays (validates tokens on game RPCs)
|
||||
- **UserService**: Stays in Eagle (user persistence, display name logic)
|
||||
- **New internal gRPC**: Expose GetOrCreateUser, GetUser for Go service to call
|
||||
- **Proxy (Phase 1)**: Forward OAuth RPCs to Go service
|
||||
- **Remove (Phase 2)**: OAuthService, OAuthHttpHandler, HTTP server setup
|
||||
|
||||
### Unity Client (NO CHANGES in Phase 1)
|
||||
- Eagle proxies Auth RPCs to Go service
|
||||
- Client still connects to Eagle on port 40032
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Go Auth Service with Eagle Proxy (Zero Client Changes)
|
||||
|
||||
1. **Create Go service structure**
|
||||
```
|
||||
src/main/go/net/eagle0/authservice/
|
||||
├── main.go # Entry point, starts gRPC + HTTP servers
|
||||
├── oauth.go # OAuth state management, provider configs
|
||||
├── jwt.go # JWT creation (copy logic from Scala)
|
||||
├── handlers.go # gRPC handlers for Auth service
|
||||
├── http_callback.go # HTTP handler for OAuth callback
|
||||
└── BUILD.bazel
|
||||
```
|
||||
|
||||
2. **Internal gRPC proto for Eagle UserService**
|
||||
```protobuf
|
||||
// src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto
|
||||
|
||||
service InternalUserService {
|
||||
rpc GetOrCreateUser(GetOrCreateUserRequest) returns (GetOrCreateUserResponse);
|
||||
rpc GetUser(GetUserRequest) returns (GetUserResponse);
|
||||
}
|
||||
|
||||
message GetOrCreateUserRequest {
|
||||
string provider = 1; // "discord" or "google"
|
||||
string provider_user_id = 2;
|
||||
string email = 3;
|
||||
string avatar_url = 4;
|
||||
}
|
||||
|
||||
message GetOrCreateUserResponse {
|
||||
string user_id = 1;
|
||||
string display_name = 2;
|
||||
string avatar_url = 3;
|
||||
bool is_admin = 4;
|
||||
bool is_new_user = 5;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Eagle: Expose InternalUserService**
|
||||
- New `InternalUserServiceImpl.scala` wrapping UserService
|
||||
- Bind to same port, different service name (internal only)
|
||||
|
||||
4. **Eagle: Proxy Auth RPCs to Go**
|
||||
- AuthServiceImpl delegates GetOAuthUrl, CheckOAuthStatus, RefreshToken to Go service
|
||||
- SetDisplayName, GetCurrentUser, Logout stay in Eagle
|
||||
|
||||
5. **Share RSA keys via volume mount**
|
||||
- Go service reads same key files as Eagle
|
||||
- Both can create valid JWTs
|
||||
- Eagle continues to validate JWTs
|
||||
|
||||
6. **Docker/Container setup**
|
||||
- New Dockerfile for Go auth service
|
||||
- docker-compose or Kubernetes config for both containers
|
||||
- Shared volume for /etc/eagle0/keys/
|
||||
- Internal network for container-to-container gRPC
|
||||
|
||||
### Phase 2: Client Direct to Go Service (Future)
|
||||
|
||||
1. **Update Unity client**
|
||||
- Connect to Go Auth service directly for OAuth RPCs
|
||||
- Keep connecting to Eagle for game RPCs
|
||||
|
||||
2. **Remove Eagle proxy code**
|
||||
- Delete AuthServiceImpl OAuth delegation
|
||||
- AuthServiceImpl only handles SetDisplayName, GetCurrentUser, Logout
|
||||
|
||||
### Phase 3: Move JWT Validation to Go (Optional Future)
|
||||
|
||||
1. **Go service validates JWTs**
|
||||
- Add ValidateToken RPC or use shared middleware pattern
|
||||
|
||||
2. **Eagle calls Go for validation**
|
||||
- AuthorizationInterceptor calls Go to validate tokens
|
||||
- OR: Use stateless validation (both share public key)
|
||||
|
||||
## Files to Create
|
||||
|
||||
### Go Service
|
||||
- `src/main/go/net/eagle0/authservice/main.go`
|
||||
- `src/main/go/net/eagle0/authservice/oauth.go`
|
||||
- `src/main/go/net/eagle0/authservice/jwt.go`
|
||||
- `src/main/go/net/eagle0/authservice/handlers.go`
|
||||
- `src/main/go/net/eagle0/authservice/http_callback.go`
|
||||
- `src/main/go/net/eagle0/authservice/BUILD.bazel`
|
||||
|
||||
### Protos
|
||||
- `src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto`
|
||||
|
||||
### Scala
|
||||
- `src/main/scala/net/eagle0/eagle/service/InternalUserServiceImpl.scala`
|
||||
|
||||
### Docker/Deployment
|
||||
- `ci/auth_service.Dockerfile`
|
||||
- Update `docker-compose.yml` (or equivalent)
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### Scala (Phase 1)
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - Proxy OAuth RPCs to Go
|
||||
- `src/main/scala/net/eagle0/eagle/Main.scala` - Start internal user service, add auth-service-url flag
|
||||
|
||||
### Scala (Phase 2 - Removal)
|
||||
- Delete `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala`
|
||||
- Delete `src/main/scala/net/eagle0/eagle/service/OAuthHttpHandler.scala`
|
||||
- Simplify `src/main/scala/net/eagle0/eagle/Main.scala` - Remove HTTP server
|
||||
|
||||
### Unity (Phase 2)
|
||||
- `Assets/Auth/OAuthManager.cs` - Point OAuth RPCs to Go service port
|
||||
- `Assets/EagleConnection.cs` - Add second channel for auth service
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### State Management in Go
|
||||
```go
|
||||
type OAuthState struct {
|
||||
Provider string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type OAuthResult struct {
|
||||
Success bool
|
||||
UserInfo *ProviderUserInfo
|
||||
Provider string
|
||||
Error string
|
||||
}
|
||||
|
||||
var pendingOAuth = sync.Map{} // state -> OAuthState
|
||||
var completedOAuth = sync.Map{} // state -> OAuthResult
|
||||
|
||||
const stateExpiration = 10 * time.Minute
|
||||
|
||||
// Background goroutine cleans expired states every minute
|
||||
func cleanupExpiredStates() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
for range ticker.C {
|
||||
cutoff := time.Now().Add(-stateExpiration)
|
||||
pendingOAuth.Range(func(key, value any) bool {
|
||||
if value.(OAuthState).CreatedAt.Before(cutoff) {
|
||||
pendingOAuth.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
// Similar for completedOAuth
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### JWT Creation in Go
|
||||
```go
|
||||
import "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
type EagleClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
UserId string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
|
||||
func CreateAccessToken(userId, displayName string, isAdmin bool) (string, error) {
|
||||
claims := EagleClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
UserId: userId,
|
||||
DisplayName: displayName,
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
return token.SignedString(privateKey)
|
||||
}
|
||||
```
|
||||
|
||||
### OAuth Provider Configs
|
||||
- Read from environment variables (same as current OAuthConfig.scala)
|
||||
- DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET
|
||||
- GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
|
||||
- OAUTH_CALLBACK_URL (e.g., https://eagle0.shardok.games/oauth/callback)
|
||||
|
||||
### Container Networking
|
||||
```yaml
|
||||
# docker-compose.yml example
|
||||
services:
|
||||
eagle0-auth:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ci/auth_service.Dockerfile
|
||||
ports:
|
||||
- "40033:40033" # gRPC
|
||||
- "8080:8080" # HTTP callback
|
||||
volumes:
|
||||
- ./keys:/etc/eagle0/keys:ro
|
||||
environment:
|
||||
- DISCORD_CLIENT_ID
|
||||
- DISCORD_CLIENT_SECRET
|
||||
- GOOGLE_CLIENT_ID
|
||||
- GOOGLE_CLIENT_SECRET
|
||||
- EAGLE_INTERNAL_URL=eagle0-server:40034
|
||||
|
||||
eagle0-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ci/eagle_run.Dockerfile
|
||||
ports:
|
||||
- "40032:40032" # Public gRPC
|
||||
expose:
|
||||
- "40034" # Internal gRPC (container-to-container only)
|
||||
volumes:
|
||||
- ./keys:/etc/eagle0/keys:ro
|
||||
- ./data:/var/lib/eagle0
|
||||
environment:
|
||||
- AUTH_SERVICE_URL=eagle0-auth:40033
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Development
|
||||
```bash
|
||||
# Terminal 1: Go Auth Service
|
||||
bazel run //src/main/go/net/eagle0/authservice:authservice -- \
|
||||
--grpc-port=40033 \
|
||||
--http-port=8080 \
|
||||
--eagle-internal-url=localhost:40034
|
||||
|
||||
# Terminal 2: Eagle Server
|
||||
bazel run //src/main/scala/net/eagle0/eagle:eagle_server -- \
|
||||
--eagle-grpc-port=40032 \
|
||||
--internal-grpc-port=40034 \
|
||||
--auth-service-url=localhost:40033
|
||||
```
|
||||
|
||||
### Production
|
||||
- Both containers on same droplet via docker-compose
|
||||
- Shared volume for RSA keys at /etc/eagle0/keys/
|
||||
- Internal Docker network for container-to-container communication
|
||||
- External access: 40032 (Eagle gRPC), 8080 (OAuth HTTP callback)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit tests for Go service**
|
||||
- OAuth state management (expiration, cleanup)
|
||||
- JWT creation matches Scala output (test with same keys)
|
||||
- HTTP callback parsing
|
||||
|
||||
2. **Integration tests**
|
||||
- Go service ↔ Eagle internal gRPC
|
||||
- Full OAuth flow with mock provider
|
||||
|
||||
3. **Existing tests continue to pass**
|
||||
- All Scala tests (JWT validation, user service)
|
||||
|
||||
4. **End-to-end test**
|
||||
- Spin up both containers
|
||||
- Run OAuth flow through proxy
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Key file permissions | Shared volume with read-only mount |
|
||||
| State loss on Go restart | Document this (same as current Scala behavior); consider Redis later |
|
||||
| Clock skew affecting JWT | Both on same machine |
|
||||
| OAuth callback race | HTTP callback completes before gRPC poll |
|
||||
| Container networking | Use docker-compose for reliable internal DNS |
|
||||
| Proxy adds latency | Minimal (same machine), remove in Phase 2 |
|
||||
|
||||
## Estimated Scope
|
||||
|
||||
- **Phase 1**: ~500-700 lines Go, ~100 lines Scala changes, ~50 lines Docker config
|
||||
- **Phase 2**: ~50 lines Unity, deletion of ~300 lines Scala
|
||||
- **Phase 3**: Optional, separate decision
|
||||
|
||||
## Alternative Considered: Move Everything to Go
|
||||
|
||||
Could move UserService to Go as well, but:
|
||||
- UserService is tightly integrated with game persistence
|
||||
- Would require duplicating persistence layer
|
||||
- Not worth the complexity for now
|
||||
|
||||
Keep UserService in Eagle, expose via internal gRPC.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **HTTP callback routing**: Does the OAuth callback URL need to change, or can we route traffic from the existing URL to the new Go service?
|
||||
2. **Health checks**: Should we add health check endpoints for container orchestration?
|
||||
3. **Logging**: Should Go service log to same format/destination as Eagle?
|
||||
@@ -0,0 +1,350 @@
|
||||
# OAuth Implementation: Next Steps and Design
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The OAuth implementation is functional but has several gaps that need addressing before it's production-ready. This document outlines the known issues, proposes a comprehensive user identity model, and provides a prioritized implementation plan.
|
||||
|
||||
## Current State (Updated January 2026)
|
||||
|
||||
### What Works ✅
|
||||
- Discord OAuth flow (server-mediated polling)
|
||||
- Google OAuth flow
|
||||
- JWT token generation and validation
|
||||
- User creation and display name setting
|
||||
- Auto-login with stored tokens
|
||||
- Basic game creation and play with OAuth users
|
||||
- Headshot fetching via public CDN (no auth required)
|
||||
- Logout button in lobby (preserves tokens for quick reconnect)
|
||||
- Environment (prod/qa) and user display in lobby
|
||||
- Game identity with userName = displayName (PR #4964 merged)
|
||||
|
||||
### Known Issues
|
||||
|
||||
#### 1. Game Identity Model Fragility (Deferred)
|
||||
**Status**: Accepted for now. PR #4964 merged with `userName = displayName`.
|
||||
|
||||
**Current behavior**:
|
||||
- Games store `userNameToFactionId: Map[String, Int]`
|
||||
- For JWT users, this maps displayName → factionId
|
||||
- displayName is technically mutable (users could change it)
|
||||
- No migration path when displayName changes
|
||||
|
||||
**Why this is acceptable**:
|
||||
1. We don't currently have a "change display name" feature
|
||||
2. The alternative (using userId) requires more extensive changes
|
||||
3. Can migrate to userId-based identity later if needed
|
||||
|
||||
#### 2. In-Game Headshot Fetching ✅ FIXED
|
||||
**Solution**: Made the `eagle0-headshots` S3 bucket public and enabled CDN.
|
||||
- Client now fetches directly from `https://eagle0-headshots.sfo3.cdn.digitaloceanspaces.com/`
|
||||
- No authentication required
|
||||
- Works for both OAuth and Basic Auth users
|
||||
- Simpler architecture, no dependency on home Mac server
|
||||
|
||||
#### 3. Logout from Lobby ✅ FIXED
|
||||
**Solution**: Added logout button to lobby UI (PR #4967).
|
||||
- Button disconnects from server and returns to connection screen
|
||||
- Intentionally does NOT clear OAuth tokens
|
||||
- Allows quick reconnect with same account without full OAuth flow
|
||||
|
||||
#### 4. Display Name Uniqueness Not Enforced (Medium) - OPEN
|
||||
**Problem**: User was able to set displayName "nolen" when that name was already taken.
|
||||
|
||||
**Root cause**: Unknown - needs investigation. Either:
|
||||
- The uniqueness check is buggy
|
||||
- The displayNameIndex wasn't populated correctly during user creation
|
||||
- Race condition during concurrent registrations
|
||||
|
||||
#### 5. Admin Server Crashes ✅ FIXED
|
||||
**Solution**: PR #4964 sets `userName = displayName` for JWT users.
|
||||
|
||||
#### 6. Intermittent "Expired" Errors During Login (Medium) - INVESTIGATING
|
||||
**Problem**: Users occasionally get "OAuth session expired" errors even when server logs show the callback succeeded.
|
||||
|
||||
**Status**: Added diagnostic logging in PR #4974 to trace:
|
||||
- State creation in `getAuthUrl`
|
||||
- State lookup in `handleCallback`
|
||||
- Result lookup in `checkStatus`
|
||||
|
||||
**Possible causes**:
|
||||
- State mismatch between client and server
|
||||
- Race condition in polling
|
||||
- Cleanup running at wrong time
|
||||
|
||||
#### 7. Token Expiry Field Bug ✅ FIXED
|
||||
**Problem**: `CheckOAuthStatusResponse.expiresAt` was returning refresh token expiry (30 days) instead of access token expiry (7 days).
|
||||
|
||||
**Solution**: Fixed in PR #4974 to calculate correct access token expiry.
|
||||
|
||||
---
|
||||
|
||||
## Proposed User Identity Model
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Stable Internal Identity**: `userId` (UUID) is the only key used for persistent associations
|
||||
2. **Display Name is Cosmetic**: Can change without breaking game associations
|
||||
3. **Backwards Compatibility**: Basic Auth continues to work for local development
|
||||
4. **Multi-Provider Support**: Users can link Discord, Google, and future providers
|
||||
5. **Avatar Flexibility**: Use OAuth avatar by default, support custom uploads later
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
User {
|
||||
userId: String (UUID) // Primary key, immutable, used for all internal references
|
||||
displayName: String // Unique, user-visible, mutable with migration
|
||||
displayNameLower: String // Case-insensitive uniqueness
|
||||
email: String // Primary email for account recovery/linking
|
||||
avatarUrl: String // Current avatar URL
|
||||
avatarData: bytes // Cached avatar for offline/fast access (future)
|
||||
oauthIdentities: [OAuthIdentity]
|
||||
createdAt: Timestamp
|
||||
lastLoginAt: Timestamp
|
||||
isAdmin: Boolean
|
||||
}
|
||||
|
||||
OAuthIdentity {
|
||||
provider: String // "discord", "google", etc.
|
||||
providerUserId: String // Provider's user ID
|
||||
providerEmail: String // Email from this provider
|
||||
avatarUrl: String // Avatar from this provider
|
||||
linkedAt: Timestamp
|
||||
}
|
||||
```
|
||||
|
||||
### Identity Resolution Strategy
|
||||
|
||||
The key question: **What should `AuthorizationUtils.userName` return?**
|
||||
|
||||
#### Option A: userName = displayName (Current PR #4964)
|
||||
- **Pro**: Human-readable in logs, game saves, debugging
|
||||
- **Con**: Breaks if displayName changes
|
||||
- **Migration**: None needed now, complex later
|
||||
|
||||
#### Option B: userName = userId (Recommended)
|
||||
- **Pro**: Stable identity, displayName changes are safe
|
||||
- **Con**: UUIDs in logs are ugly, need display name lookup for UI
|
||||
- **Migration**: Cleaner long-term, but breaking change for any existing OAuth games
|
||||
|
||||
#### Option C: Hybrid with Migration Support
|
||||
- **userName** = userId for new games
|
||||
- **Legacy lookup** for old games by displayName
|
||||
- **Display layer** resolves userId → displayName for UI
|
||||
|
||||
**Recommendation**: Option B with a display name resolution layer. The ugliness in logs is acceptable for the stability it provides. Implement a `UserService.resolveDisplayName(identifier: String): String` that returns displayName for UUIDs or the identifier itself for legacy usernames.
|
||||
|
||||
### Account Linking Strategy
|
||||
|
||||
#### Automatic Linking (Future)
|
||||
When a user logs in with a new OAuth provider:
|
||||
1. Check if the provider email matches an existing user's email
|
||||
2. If match found, prompt: "An account exists with this email. Link accounts?"
|
||||
3. If confirmed, add new OAuthIdentity to existing user
|
||||
4. If declined, create separate account (different email required)
|
||||
|
||||
#### Manual Linking (MVP)
|
||||
1. User logs in with primary account
|
||||
2. User goes to Settings → Linked Accounts
|
||||
3. User clicks "Link Discord" or "Link Google"
|
||||
4. OAuth flow adds new identity to current user
|
||||
|
||||
### Avatar/Headshot Strategy
|
||||
|
||||
#### Phase 1: OAuth Avatars (MVP)
|
||||
- Store `avatarUrl` from OAuth provider during login
|
||||
- Server proxies avatar requests to avoid CORS issues
|
||||
- Cache avatars locally with TTL
|
||||
|
||||
#### Phase 2: Avatar Caching
|
||||
- Download avatar to local storage on login
|
||||
- Serve from local storage for reliability
|
||||
- Refresh periodically or on login
|
||||
|
||||
#### Phase 3: Custom Avatars (Future)
|
||||
- Allow users to upload custom avatar
|
||||
- Store in S3/DO Spaces
|
||||
- Custom avatar overrides OAuth avatar
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Stabilization ✅ COMPLETE
|
||||
|
||||
#### 1.1 Fix Display Name Uniqueness Bug - OPEN
|
||||
- [ ] Investigate why "nolen" was allowed when it existed
|
||||
- [ ] Add logging to `setDisplayName` to trace the issue
|
||||
- [ ] Ensure `displayNameIndex` is correctly maintained
|
||||
- [ ] Add unit tests for uniqueness enforcement
|
||||
|
||||
#### 1.2 Add Logout Button to Lobby ✅ DONE
|
||||
- [x] Add "Logout" button to lobby UI
|
||||
- [x] Disconnect from server
|
||||
- [x] Navigate to connection screen
|
||||
- [x] Preserve OAuth tokens for quick reconnect (intentional change from original plan)
|
||||
|
||||
#### 1.3 Merge PR #4964 (userName = displayName) ✅ DONE
|
||||
- [x] Merged - games work with OAuth users
|
||||
- [x] Documented limitation (games break if displayName changes)
|
||||
|
||||
#### 1.4 Fix Headshot Fetching ✅ DONE
|
||||
- [x] Made eagle0-headshots bucket public
|
||||
- [x] Client fetches directly from CDN
|
||||
- [x] No authentication required
|
||||
|
||||
#### 1.5 Add Lobby Status Display ✅ DONE
|
||||
- [x] Show environment (prod/qa) in lobby
|
||||
- [x] Show current user in lobby (OAuth displayName or classic username)
|
||||
|
||||
### Phase 2: Remaining Work (Priority Order)
|
||||
|
||||
#### 2.1 Diagnose Intermittent "Expired" Errors - IN PROGRESS
|
||||
- [x] Add diagnostic logging (PR #4974)
|
||||
- [ ] Deploy and reproduce the issue
|
||||
- [ ] Analyze logs to identify root cause
|
||||
- [ ] Implement fix based on findings
|
||||
|
||||
#### 2.2 Fix Display Name Uniqueness
|
||||
- [ ] Investigate UserService.setDisplayName logic
|
||||
- [ ] Check displayNameIndex population
|
||||
- [ ] Add logging to trace the issue
|
||||
- [ ] Fix the bug and add tests
|
||||
|
||||
#### 2.3 Wire Up Lobby UI in Unity
|
||||
- [ ] Connect `lobbyEnvironmentText` to TextMeshProUGUI in scene
|
||||
- [ ] Connect `lobbyUserText` to TextMeshProUGUI in scene
|
||||
|
||||
#### 2.4 Implement Token Refresh During Gameplay
|
||||
- [ ] Implement `RefreshToken` RPC on server (currently throws UNIMPLEMENTED)
|
||||
- [ ] Store refresh tokens server-side for validation
|
||||
- [ ] Add proactive refresh in client before token expires
|
||||
- [ ] Handle refresh during reconnection attempts
|
||||
|
||||
### Phase 3: Nice-to-Haves (Future)
|
||||
|
||||
#### 3.1 Proactive Token Refresh
|
||||
- [ ] Monitor token expiry in client
|
||||
- [ ] Refresh automatically when < 5 minutes remaining
|
||||
- [ ] Update TokenStorage with new access token
|
||||
|
||||
#### 3.2 Better Error Messages
|
||||
- [ ] Distinguish between network errors and auth errors
|
||||
- [ ] Show user-friendly messages for OAuth failures
|
||||
- [ ] Add retry suggestions
|
||||
|
||||
#### 3.3 Session Persistence Across Server Restarts
|
||||
- [ ] Move pendingOAuth from in-memory TrieMap to Redis/database
|
||||
- [ ] Move completedOAuth to Redis with TTL
|
||||
- [ ] Server can restart without breaking in-flight OAuth flows
|
||||
|
||||
#### 3.4 Migrate to userId-based Game Identity (Deferred)
|
||||
- [ ] Change `AuthorizationUtils.userName` to return `userId` for JWT users
|
||||
- [ ] Add `UserService.resolveDisplayName(id: String): String` for UI display
|
||||
- [ ] Update game UI to resolve userIds to displayNames
|
||||
- [ ] Existing Basic Auth games continue to work (userName is literal)
|
||||
|
||||
#### 3.5 Display Name Change Support (Requires 3.4)
|
||||
- [ ] Add `ChangeDisplayName` RPC
|
||||
- [ ] Validate new name is unique
|
||||
- [ ] Update user record
|
||||
- [ ] No game migration needed (games use userId)
|
||||
|
||||
### Phase 3: Multi-Provider Support (Future)
|
||||
|
||||
#### 3.1 Account Linking UI
|
||||
- [ ] Add Settings page with "Linked Accounts" section
|
||||
- [ ] Show currently linked providers
|
||||
- [ ] "Link Another Account" button triggers OAuth flow
|
||||
- [ ] `LinkOAuthProvider` RPC adds identity to current user
|
||||
|
||||
#### 3.2 Login Provider Selection
|
||||
- [ ] If user has multiple providers, any can be used to login
|
||||
- [ ] All resolve to same userId
|
||||
- [ ] Session shows which provider was used
|
||||
|
||||
#### 3.3 Account Merging (Complex)
|
||||
- [ ] Handle case where user created separate accounts
|
||||
- [ ] Merge game history, stats, etc.
|
||||
- [ ] Delete duplicate user record
|
||||
- [ ] This is complex - may defer or not implement
|
||||
|
||||
### Phase 4: Enhanced Avatars (Future)
|
||||
|
||||
#### 4.1 Avatar Caching
|
||||
- [ ] Download avatars to S3/DO Spaces on login
|
||||
- [ ] Serve from our CDN
|
||||
- [ ] Refresh on login if changed
|
||||
|
||||
#### 4.2 Custom Avatar Upload
|
||||
- [ ] Upload endpoint with size/format validation
|
||||
- [ ] Store in S3/DO Spaces
|
||||
- [ ] Custom avatar overrides OAuth avatar
|
||||
|
||||
---
|
||||
|
||||
## Technical Debt to Address
|
||||
|
||||
1. **Context Propagation in Futures**: PR #4960 fixed `setDisplayName` and `getCurrentUser`, but audit all `Future` blocks that access `AuthorizationUtils`
|
||||
|
||||
2. **Dual Auth Support**: The system supports both Basic Auth and JWT. Consider:
|
||||
- Should Basic Auth be deprecated for production?
|
||||
- Should it remain for local development only?
|
||||
- How do Basic Auth users interact with OAuth users in the same game?
|
||||
|
||||
3. **Token Refresh**: `RefreshToken` RPC throws UNIMPLEMENTED. Need to:
|
||||
- Implement refresh token storage and validation
|
||||
- Handle token refresh in client
|
||||
- Consider refresh token rotation for security
|
||||
|
||||
4. **Session Management**: No server-side session tracking. Consider:
|
||||
- Track active sessions per user
|
||||
- Allow "logout all devices"
|
||||
- Detect concurrent logins
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **What happens when a Basic Auth user and OAuth user have the same name?**
|
||||
- Currently possible - Basic Auth doesn't check UserService
|
||||
- Could cause confusion in games
|
||||
- Solution: Require OAuth for multiplayer? Or namespace Basic Auth names?
|
||||
|
||||
2. **Should displayName changes be allowed?**
|
||||
- With userId-based identity, it's safe
|
||||
- But could cause confusion ("who is this new player?")
|
||||
- Consider: rate limit changes, show "formerly known as" temporarily
|
||||
|
||||
3. **How to handle OAuth provider account deletion?**
|
||||
- User deletes their Discord account
|
||||
- Their Eagle0 account still exists
|
||||
- They can't login unless they linked another provider
|
||||
- Solution: Encourage linking multiple providers, or add email/password fallback
|
||||
|
||||
4. **Admin impersonation with OAuth**
|
||||
- Currently works via X-Impersonate-User header
|
||||
- Should this use userId or displayName?
|
||||
- Probably userId for stability
|
||||
|
||||
---
|
||||
|
||||
## Appendix: File Locations
|
||||
|
||||
### Server (Scala)
|
||||
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD
|
||||
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - Token generation/validation
|
||||
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala` - Auth middleware
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala` - Context accessors
|
||||
|
||||
### Client (C#)
|
||||
- `Assets/Auth/AuthClient.cs` - gRPC client for Auth service
|
||||
- `Assets/Auth/OAuthManager.cs` - OAuth flow orchestration
|
||||
- `Assets/Auth/TokenStorage.cs` - Persistent token storage
|
||||
- `Assets/Auth/JwtAuthInterceptor.cs` - Attaches JWT to requests
|
||||
|
||||
### Protos
|
||||
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth service definition
|
||||
- `src/main/protobuf/net/eagle0/eagle/internal/user/user.proto` - User data model
|
||||
@@ -0,0 +1,216 @@
|
||||
# Shardok Latency Hiding Strategies
|
||||
|
||||
## Problem Statement
|
||||
|
||||
With Shardok running on Hetzner (Helsinki) and Eagle on DigitalOcean (US), the round-trip latency for human commands is ~200-400ms:
|
||||
|
||||
```
|
||||
Human posts command:
|
||||
Unity → Eagle (DO) → Shardok (Hetzner) → Eagle (DO) → Unity
|
||||
~10ms ~100ms ~100ms ~10ms
|
||||
Total: ~220ms round-trip
|
||||
```
|
||||
|
||||
This latency is acceptable for AI turns (users watch animations anyway), but creates noticeable lag when humans post commands.
|
||||
|
||||
---
|
||||
|
||||
## Strategy 1: Client-Side Animation Masking
|
||||
|
||||
### Concept
|
||||
|
||||
Start animations immediately when the user clicks, before server confirmation arrives. The animation duration masks the network latency.
|
||||
|
||||
### Implementation by Command Type
|
||||
|
||||
**Movement Commands** (deterministic):
|
||||
- Client knows the destination hex and movement path
|
||||
- Start movement animation immediately on click
|
||||
- Server confirms the move (should always match)
|
||||
- If server rejects (invalid state), snap unit back to origin
|
||||
|
||||
**Attack Commands** (RNG-dependent):
|
||||
- Show attack animation immediately (unit swings sword, fires arrow)
|
||||
- Wait for server to return dice roll result
|
||||
- Show damage numbers / hit effects when server responds
|
||||
- Animation typically takes 300-500ms, masking most of the latency
|
||||
|
||||
**End Turn**:
|
||||
- Latency not noticeable (user expects transition delay)
|
||||
|
||||
### Unity Implementation Sketch
|
||||
|
||||
```csharp
|
||||
// In CommandHandler.cs
|
||||
public void OnCommandSelected(Command command) {
|
||||
// Start animation immediately
|
||||
if (command.Type == CommandType.Move) {
|
||||
unitController.StartMoveAnimation(command.TargetHex);
|
||||
} else if (command.Type == CommandType.Attack) {
|
||||
unitController.StartAttackAnimation(command.TargetUnit);
|
||||
}
|
||||
|
||||
// Send to server in parallel
|
||||
connection.SendCommand(command, (response) => {
|
||||
if (response.Success) {
|
||||
// Animation continues, apply result
|
||||
ApplyCommandResult(response);
|
||||
} else {
|
||||
// Rollback animation
|
||||
unitController.CancelAnimation();
|
||||
ShowError(response.ErrorMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Pros
|
||||
- Simple implementation
|
||||
- No server-side changes
|
||||
- Works with existing architecture
|
||||
|
||||
### Cons
|
||||
- Doesn't eliminate latency for attacks with RNG (must wait for dice roll)
|
||||
- Rollback needed if server rejects command (rare but possible)
|
||||
|
||||
### Estimated Improvement
|
||||
- Movement: ~200ms latency hidden (feels instant)
|
||||
- Attacks: ~100-200ms hidden by animation, ~100ms visible wait for dice result
|
||||
|
||||
---
|
||||
|
||||
## Strategy 2: Split Shardok Architecture
|
||||
|
||||
### Concept
|
||||
|
||||
Run two Shardok instances:
|
||||
- **Shardok-Primary (DigitalOcean)**: Handles command processing, source of truth
|
||||
- **Shardok-AI (Hetzner)**: AI computation only
|
||||
|
||||
Human commands go to the nearby Primary for low latency. AI computation uses the powerful Hetzner instance.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Human commands (low latency ~20ms)
|
||||
Unity ←→ Eagle ←→ Shardok-Primary (DigitalOcean)
|
||||
↓ state sync (when AI turn starts)
|
||||
Shardok-AI (Hetzner)
|
||||
↑ AI command response
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
**Human Turn:**
|
||||
1. Human posts command → Eagle → Shardok-Primary (DO)
|
||||
2. Primary processes command immediately (~10ms local)
|
||||
3. Primary streams result to client via Eagle (~10ms)
|
||||
4. **Total latency: ~20ms** (vs ~220ms current)
|
||||
|
||||
**AI Turn:**
|
||||
1. When AI's turn starts, Primary sends game state snapshot to Hetzner
|
||||
2. Shardok-AI computes best command using full CPU power
|
||||
3. Shardok-AI returns command index to Primary
|
||||
4. Primary executes command locally and streams to client
|
||||
5. Repeat until AI turn ends
|
||||
|
||||
### Protocol Changes
|
||||
|
||||
```protobuf
|
||||
// New service for AI-only computation
|
||||
service ShardokAIService {
|
||||
// Send game state, receive AI's chosen command
|
||||
rpc GetAICommand(AICommandRequest) returns (AICommandResponse);
|
||||
}
|
||||
|
||||
message AICommandRequest {
|
||||
bytes game_state = 1; // Serialized game state
|
||||
int32 player_id = 2; // Which AI player
|
||||
repeated bytes available_commands = 3; // Available command descriptors
|
||||
}
|
||||
|
||||
message AICommandResponse {
|
||||
int32 command_index = 1; // Index into available_commands
|
||||
int32 search_depth = 2; // For debugging
|
||||
double best_score = 3; // For debugging
|
||||
}
|
||||
```
|
||||
|
||||
### Shardok-Primary Requirements
|
||||
|
||||
Shardok-Primary on DigitalOcean needs to:
|
||||
- Process all commands (human and AI)
|
||||
- Maintain authoritative game state
|
||||
- Serialize/deserialize game state for AI requests
|
||||
- Run on minimal CPU (command processing is fast)
|
||||
|
||||
This is essentially the current Shardok, but without running the AI search.
|
||||
|
||||
### Shardok-AI Requirements
|
||||
|
||||
Shardok-AI on Hetzner needs to:
|
||||
- Receive game state snapshots
|
||||
- Run AI evaluation (IterativeDeepeningAI or MCTS)
|
||||
- Return best command index
|
||||
- No persistent state (stateless worker)
|
||||
|
||||
### AI Turn Latency
|
||||
|
||||
Each AI command has ~200ms network latency. This is acceptable because:
|
||||
1. User is watching animations anyway
|
||||
2. Natural pacing lets user observe AI decisions
|
||||
3. AI computation is fast on Hetzner's 16 cores
|
||||
|
||||
For a typical AI turn with 5 commands: 5 × 200ms = 1 second network overhead, plus AI thinking time. With animations, this feels natural.
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
**Phase 1: Add Shardok-Primary (minimal)**
|
||||
- Deploy existing Shardok container to DigitalOcean
|
||||
- Configure Eagle to use local Shardok for all commands
|
||||
- Human latency immediately improves
|
||||
|
||||
**Phase 2: Add AI offload**
|
||||
- Implement `ShardokAIService` RPC
|
||||
- Shardok-Primary calls Hetzner for AI commands
|
||||
- Shardok-AI processes requests statelessly
|
||||
|
||||
**Phase 3: Optimize**
|
||||
- Batch multiple AI actions if possible
|
||||
- Pre-warm Shardok-AI connection
|
||||
- Add fallback if Hetzner unavailable
|
||||
|
||||
### Pros
|
||||
- Human command latency drops from ~220ms to ~20ms
|
||||
- AI still gets Hetzner's CPU power
|
||||
- Clear separation of concerns
|
||||
- Shardok-Primary can fall back to local AI if Hetzner unavailable
|
||||
|
||||
### Cons
|
||||
- Two Shardok instances to maintain
|
||||
- State serialization overhead for AI requests
|
||||
- Each AI action has network round-trip (acceptable with animations)
|
||||
|
||||
---
|
||||
|
||||
## Comparison
|
||||
|
||||
| Approach | Human Latency | AI Throughput | Complexity | Changes Required |
|
||||
|----------|---------------|---------------|------------|------------------|
|
||||
| Current | ~220ms | High | - | - |
|
||||
| Animation masking | ~220ms (perceived ~50ms) | High | Low | Unity only |
|
||||
| Split architecture | ~20ms | High | Medium | New RPC, two deployments |
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Phase 1 (now)**: Implement animation masking in Unity client
|
||||
- Quick win, no server changes
|
||||
- Improves perceived latency significantly for movement
|
||||
- Attacks still show dice animation while waiting
|
||||
|
||||
**Phase 2 (future)**: Split architecture if animation masking insufficient
|
||||
- Only needed if users complain about attack latency
|
||||
- More complex but provides true low latency
|
||||
- Natural evolution of current architecture
|
||||
@@ -9,6 +9,8 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/config v1.28.10
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.51
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
google.golang.org/grpc v1.68.0
|
||||
google.golang.org/protobuf v1.36.3
|
||||
)
|
||||
|
||||
@@ -34,9 +34,13 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.33.6 h1:VwhTrsTuVn52an4mXx29PqRzs2Dv
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.6/go.mod h1:+8h7PZb3yY5ftmVLD7ocEoE98hdc8PoKS0H3wfx1dlc=
|
||||
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
|
||||
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
+33
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
|
||||
"__INPUT_ARTIFACTS_HASH": -1064460283,
|
||||
"__RESOLVED_ARTIFACTS_HASH": -1574144850,
|
||||
"__INPUT_ARTIFACTS_HASH": -2049857450,
|
||||
"__RESOLVED_ARTIFACTS_HASH": -1728186926,
|
||||
"conflict_resolution": {
|
||||
"com.google.guava:failureaccess:1.0.1": "com.google.guava:failureaccess:1.0.2",
|
||||
"com.squareup.okio:okio:2.10.0": "com.squareup.okio:okio:3.6.0",
|
||||
@@ -413,6 +413,12 @@
|
||||
},
|
||||
"version": "0.27.0"
|
||||
},
|
||||
"io.sentry:sentry": {
|
||||
"shasums": {
|
||||
"jar": "740a118182fc089d307830f4e508372e01ad94639b00b4e1b1d83762298a5f35"
|
||||
},
|
||||
"version": "7.19.0"
|
||||
},
|
||||
"javax.activation:javax.activation-api": {
|
||||
"shasums": {
|
||||
"jar": "43fdef0b5b6ceb31b0424b208b930c74ab58fac2ceeb7b3f6fd3aeb8b5ca4393"
|
||||
@@ -1785,6 +1791,30 @@
|
||||
"io.perfmark:perfmark-api": [
|
||||
"io.perfmark"
|
||||
],
|
||||
"io.sentry:sentry": [
|
||||
"io.sentry",
|
||||
"io.sentry.backpressure",
|
||||
"io.sentry.cache",
|
||||
"io.sentry.clientreport",
|
||||
"io.sentry.config",
|
||||
"io.sentry.exception",
|
||||
"io.sentry.hints",
|
||||
"io.sentry.instrumentation.file",
|
||||
"io.sentry.internal.debugmeta",
|
||||
"io.sentry.internal.gestures",
|
||||
"io.sentry.internal.modules",
|
||||
"io.sentry.internal.viewhierarchy",
|
||||
"io.sentry.metrics",
|
||||
"io.sentry.profilemeasurements",
|
||||
"io.sentry.protocol",
|
||||
"io.sentry.rrweb",
|
||||
"io.sentry.transport",
|
||||
"io.sentry.util",
|
||||
"io.sentry.util.thread",
|
||||
"io.sentry.vendor",
|
||||
"io.sentry.vendor.gson.internal.bind.util",
|
||||
"io.sentry.vendor.gson.stream"
|
||||
],
|
||||
"javax.activation:javax.activation-api": [
|
||||
"javax.activation"
|
||||
],
|
||||
@@ -2452,6 +2482,7 @@
|
||||
"io.opencensus:opencensus-contrib-grpc-metrics",
|
||||
"io.opencensus:opencensus-contrib-http-util",
|
||||
"io.perfmark:perfmark-api",
|
||||
"io.sentry:sentry",
|
||||
"javax.activation:javax.activation-api",
|
||||
"javax.xml.bind:jaxb-api",
|
||||
"joda-time:joda-time",
|
||||
|
||||
@@ -81,6 +81,84 @@ http {
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# gRPC proxy for Auth service
|
||||
location /net.eagle0.eagle.api.auth.Auth {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# gRPC proxy
|
||||
grpc_pass grpc://eagle_grpc;
|
||||
|
||||
# Timeouts
|
||||
grpc_read_timeout 30s;
|
||||
grpc_send_timeout 30s;
|
||||
|
||||
# Error handling
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# OAuth callback endpoint (proxied to Go auth service)
|
||||
location /oauth/callback {
|
||||
proxy_pass http://auth:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "OK\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# gRPC error handling
|
||||
location = /error502grpc {
|
||||
internal;
|
||||
default_type application/grpc;
|
||||
add_header grpc-status 14;
|
||||
add_header grpc-message "unavailable";
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server for Go Auth service (port 40033)
|
||||
# Clients connect here directly for OAuth RPCs in Phase 2
|
||||
server {
|
||||
listen 40033 ssl;
|
||||
http2 on;
|
||||
server_name prod.eagle0.net;
|
||||
|
||||
# SSL certificates (same as main server)
|
||||
ssl_certificate /etc/letsencrypt/live/prod.eagle0.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/prod.eagle0.net/privkey.pem;
|
||||
|
||||
# SSL configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# gRPC proxy for Auth service
|
||||
# Uses variable-based resolution so nginx can start even if auth isn't ready yet
|
||||
# DNS is cached by the resolver directive (valid=10s)
|
||||
location /net.eagle0.eagle.api.auth.Auth {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# Dynamic upstream resolution (doesn't block nginx startup)
|
||||
set $auth_backend "auth:40033";
|
||||
grpc_pass grpc://$auth_backend;
|
||||
|
||||
# Timeouts
|
||||
grpc_read_timeout 30s;
|
||||
grpc_send_timeout 30s;
|
||||
|
||||
# Error handling
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
|
||||
@@ -11,9 +11,12 @@ cc_library(
|
||||
],
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:remote_ai_client",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
"//src/main/cpp/net/eagle0/shardok/server:ai_service_grpc_server",
|
||||
"//src/main/cpp/net/eagle0/shardok/server:eagle_interface_grpc_server",
|
||||
"//src/main/cpp/net/eagle0/shardok/server:server_configuration",
|
||||
"//src/main/cpp/net/eagle0/shardok/server:token_auth",
|
||||
"//src/main/protobuf/net/eagle0/common:common_unit_cc_proto",
|
||||
"@grpc//:grpc++",
|
||||
],
|
||||
|
||||
@@ -25,7 +25,8 @@ auto CalculateTimeBudget(
|
||||
const PlayerId playerId,
|
||||
const GameSettingsSPtr &settings,
|
||||
const GameStateW &state,
|
||||
const size_t numCommands) -> AITimeBudget {
|
||||
const size_t numCommands,
|
||||
const bool isAllAiBattle) -> AITimeBudget {
|
||||
const auto settingsGetter = settings->GetGetter();
|
||||
const auto castleCoords = AllCastleCoords(state->hex_map());
|
||||
|
||||
@@ -34,8 +35,10 @@ 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 =
|
||||
settingsGetter.Backing().lookahead_time_budget_maximum_seconds();
|
||||
isAllAiBattle ? settingsGetter.Backing().all_ai_battle_time_budget_maximum()
|
||||
: settingsGetter.Backing().lookahead_time_budget_maximum_seconds();
|
||||
const double maxBudgetMs = maxBudgetSeconds * 1000.0;
|
||||
|
||||
// During setup, use the setup-specific time budget
|
||||
|
||||
@@ -38,11 +38,13 @@ 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) -> AITimeBudget;
|
||||
size_t numCommands,
|
||||
bool isAllAiBattle) -> AITimeBudget;
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
|
||||
@@ -393,3 +393,17 @@ cc_library(
|
||||
"@com_google_protobuf//:protobuf",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "remote_ai_client",
|
||||
srcs = ["RemoteAIClient.cpp"],
|
||||
hdrs = ["RemoteAIClient.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
":shardok_ai_client", # For CommandChoiceResults
|
||||
"//src/main/cpp/net/eagle0/common:protobuf_warning_suppression",
|
||||
"//src/main/protobuf/net/eagle0/common:shardok_internal_interface_cc_grpc",
|
||||
"@grpc//:grpc++",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//
|
||||
// RemoteAIClient.cpp
|
||||
// shardok
|
||||
//
|
||||
// Created by Claude on 2025.
|
||||
// Copyright © 2025 none. All rights reserved.
|
||||
//
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/RemoteAIClient.hpp"
|
||||
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace shardok {
|
||||
|
||||
using grpc::Channel;
|
||||
using grpc::ClientContext;
|
||||
using grpc::Status;
|
||||
using net::eagle0::common::ChooseAICommandRequest;
|
||||
using net::eagle0::common::ChooseAICommandResponse;
|
||||
using net::eagle0::common::ShardokAIService;
|
||||
|
||||
RemoteAIClient::RemoteAIClient(
|
||||
std::shared_ptr<Channel> channel,
|
||||
std::string gameId,
|
||||
std::chrono::milliseconds timeout)
|
||||
: stub_(ShardokAIService::NewStub(channel)),
|
||||
timeout_(timeout),
|
||||
gameId_(std::move(gameId)) {}
|
||||
|
||||
auto RemoteAIClient::ChooseCommandIndex(
|
||||
const PlayerId playerId,
|
||||
const bool isDefender,
|
||||
const bool isAllAiBattle,
|
||||
const std::vector<uint8_t>& gameStateBytes,
|
||||
const std::chrono::milliseconds timeBudgetMs) -> CommandChoiceResults {
|
||||
ChooseAICommandRequest request;
|
||||
request.set_game_id(gameId_);
|
||||
request.set_player_id(playerId);
|
||||
request.set_is_defender(isDefender);
|
||||
request.set_is_all_ai_battle(isAllAiBattle);
|
||||
request.set_game_state_bytes(gameStateBytes.data(), gameStateBytes.size());
|
||||
request.set_time_budget_ms(static_cast<int32_t>(timeBudgetMs.count()));
|
||||
|
||||
ChooseAICommandResponse response;
|
||||
ClientContext context;
|
||||
|
||||
// Set deadline for the RPC
|
||||
context.set_deadline(std::chrono::system_clock::now() + timeout_);
|
||||
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
const Status status = stub_->ChooseAICommand(&context, request, &response);
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - start);
|
||||
|
||||
if (!status.ok()) {
|
||||
std::cerr << "[RemoteAIClient] RPC failed for game " << gameId_ << " player " << playerId
|
||||
<< ": " << status.error_message() << " (code=" << status.error_code()
|
||||
<< ", elapsed=" << elapsed.count() << "ms)" << std::endl;
|
||||
throw std::runtime_error("Remote AI call failed: " + status.error_message());
|
||||
}
|
||||
|
||||
std::cout << "[RemoteAIClient] Remote AI completed for game " << gameId_ << " player "
|
||||
<< playerId << ": chose index " << response.chosen_index()
|
||||
<< " (depth=" << response.depth_achieved()
|
||||
<< ", evaluated=" << response.commands_evaluated()
|
||||
<< ", remote_time=" << response.elapsed_ms() << "ms, rtt=" << elapsed.count() << "ms)"
|
||||
<< std::endl;
|
||||
|
||||
return CommandChoiceResults{
|
||||
.chosenIndex = static_cast<size_t>(response.chosen_index()),
|
||||
.availableCommandCount = 0, // Not provided by remote
|
||||
.depthAchieved = response.depth_achieved(),
|
||||
.commandCountEvaluated = static_cast<size_t>(response.commands_evaluated()),
|
||||
.completionReason = EvaluationCompletionReason::RAN_OUT_OF_TIME // Assumed
|
||||
};
|
||||
}
|
||||
|
||||
// RemoteAIClientFactory implementation
|
||||
|
||||
namespace {
|
||||
// Custom call credentials that add bearer token
|
||||
class BearerTokenAuthenticator : public grpc::MetadataCredentialsPlugin {
|
||||
public:
|
||||
explicit BearerTokenAuthenticator(std::string token) : token_(std::move(token)) {}
|
||||
|
||||
auto GetMetadata(
|
||||
grpc::string_ref /*service_url*/,
|
||||
grpc::string_ref /*method_name*/,
|
||||
const grpc::AuthContext& /*channel_auth_context*/,
|
||||
std::multimap<grpc::string, grpc::string>* metadata) -> grpc::Status override {
|
||||
metadata->insert(std::make_pair("authorization", "Bearer " + token_));
|
||||
return grpc::Status::OK;
|
||||
}
|
||||
|
||||
private:
|
||||
std::string token_;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
RemoteAIClientFactory::RemoteAIClientFactory(
|
||||
const std::string& address,
|
||||
const std::string& authToken,
|
||||
std::chrono::milliseconds timeout)
|
||||
: timeout_(timeout) {
|
||||
std::cout << "[RemoteAIClientFactory] Creating channel to " << address << std::endl;
|
||||
|
||||
if (authToken.empty()) {
|
||||
// Insecure channel for local testing
|
||||
channel_ = grpc::CreateChannel(address, grpc::InsecureChannelCredentials());
|
||||
} else {
|
||||
// SSL channel with bearer token authentication
|
||||
grpc::SslCredentialsOptions sslOpts;
|
||||
auto channelCreds = grpc::SslCredentials(sslOpts);
|
||||
|
||||
auto callCreds = grpc::MetadataCredentialsFromPlugin(
|
||||
std::make_unique<BearerTokenAuthenticator>(authToken));
|
||||
|
||||
auto compositeCreds = grpc::CompositeChannelCredentials(channelCreds, callCreds);
|
||||
channel_ = grpc::CreateChannel(address, compositeCreds);
|
||||
}
|
||||
}
|
||||
|
||||
auto RemoteAIClientFactory::CreateClient(const std::string& gameId)
|
||||
-> std::unique_ptr<RemoteAIClient> {
|
||||
return std::make_unique<RemoteAIClient>(channel_, gameId, timeout_);
|
||||
}
|
||||
|
||||
auto RemoteAIClientFactory::IsConnected() const -> bool {
|
||||
if (!channel_) { return false; }
|
||||
const auto state = channel_->GetState(false);
|
||||
return state == GRPC_CHANNEL_READY || state == GRPC_CHANNEL_IDLE;
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
@@ -0,0 +1,109 @@
|
||||
//
|
||||
// RemoteAIClient.hpp
|
||||
// shardok
|
||||
//
|
||||
// Created by Claude on 2025.
|
||||
// Copyright © 2025 none. All rights reserved.
|
||||
//
|
||||
|
||||
#ifndef RemoteAIClient_hpp
|
||||
#define RemoteAIClient_hpp
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/common/ProtobufWarningSuppression.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
|
||||
#pragma GCC diagnostic push
|
||||
SUPPRESS_PROTOBUF_WARNINGS
|
||||
#include <grpcpp/grpcpp.h>
|
||||
|
||||
#include "src/main/protobuf/net/eagle0/common/shardok_internal_interface.grpc.pb.h"
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
namespace shardok {
|
||||
|
||||
/**
|
||||
* Client for offloading AI computation to a remote Shardok-AI server.
|
||||
*
|
||||
* Used by Shardok-Primary on DigitalOcean to call Shardok-AI on Hetzner
|
||||
* for AI command selection while keeping command processing local for
|
||||
* low latency.
|
||||
*/
|
||||
class RemoteAIClient {
|
||||
private:
|
||||
std::unique_ptr<net::eagle0::common::ShardokAIService::Stub> stub_;
|
||||
std::chrono::milliseconds timeout_;
|
||||
std::string gameId_;
|
||||
|
||||
public:
|
||||
/**
|
||||
* Create a remote AI client.
|
||||
*
|
||||
* @param channel Shared gRPC channel to the remote AI server
|
||||
* @param gameId Game identifier for logging/debugging
|
||||
* @param timeout Timeout for remote calls (should be > AI time budget + network latency)
|
||||
*/
|
||||
RemoteAIClient(
|
||||
std::shared_ptr<grpc::Channel> channel,
|
||||
std::string gameId,
|
||||
std::chrono::milliseconds timeout = std::chrono::milliseconds(10000));
|
||||
|
||||
/**
|
||||
* Choose a command using the remote AI server.
|
||||
*
|
||||
* @param playerId AI player ID
|
||||
* @param isDefender Whether this AI is defending
|
||||
* @param isAllAiBattle Whether this is an AI-only battle (affects time budget)
|
||||
* @param gameStateBytes Serialized game state (FlatBuffers)
|
||||
* @param timeBudgetMs Time budget for AI thinking
|
||||
* @return CommandChoiceResults with chosen index and performance metrics
|
||||
* @throws std::runtime_error if remote call fails
|
||||
*/
|
||||
[[nodiscard]] auto ChooseCommandIndex(
|
||||
PlayerId playerId,
|
||||
bool isDefender,
|
||||
bool isAllAiBattle,
|
||||
const std::vector<uint8_t>& gameStateBytes,
|
||||
std::chrono::milliseconds timeBudgetMs) -> CommandChoiceResults;
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory for creating RemoteAIClient instances with shared channel.
|
||||
*
|
||||
* Maintains a persistent gRPC channel to the remote AI server that can be
|
||||
* shared across multiple games.
|
||||
*/
|
||||
class RemoteAIClientFactory {
|
||||
private:
|
||||
std::shared_ptr<grpc::Channel> channel_;
|
||||
std::chrono::milliseconds timeout_;
|
||||
|
||||
public:
|
||||
/**
|
||||
* Create a factory with connection to remote AI server.
|
||||
*
|
||||
* @param address Remote AI server address (e.g., "ai.eagle0.net:40043")
|
||||
* @param authToken Optional bearer token for authentication
|
||||
* @param timeout Timeout for remote calls
|
||||
*/
|
||||
RemoteAIClientFactory(
|
||||
const std::string& address,
|
||||
const std::string& authToken = "",
|
||||
std::chrono::milliseconds timeout = std::chrono::milliseconds(10000));
|
||||
|
||||
/**
|
||||
* Create a RemoteAIClient for a specific game.
|
||||
*/
|
||||
[[nodiscard]] auto CreateClient(const std::string& gameId) -> std::unique_ptr<RemoteAIClient>;
|
||||
|
||||
/**
|
||||
* Check if the remote AI server is reachable.
|
||||
*/
|
||||
[[nodiscard]] auto IsConnected() const -> bool;
|
||||
};
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif /* RemoteAIClient_hpp */
|
||||
@@ -53,6 +53,7 @@ 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,
|
||||
@@ -60,6 +61,7 @@ ShardokAIClient::ShardokAIClient(
|
||||
const mcts::MCTSConfig &mctsConfig)
|
||||
: playerId(playerId),
|
||||
isDefender(isDefender),
|
||||
isAllAiBattle(isAllAiBattle),
|
||||
aiAlgorithmType(aiAlgorithmType),
|
||||
scoringCalculatorType(scoringCalculatorType),
|
||||
alCache(std::make_unique<AttackLocationsCache>(hexMap, settings)),
|
||||
@@ -135,7 +137,8 @@ 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);
|
||||
const auto timeBudget =
|
||||
CalculateTimeBudget(playerId, settings, guessedState, commandCount, isAllAiBattle);
|
||||
|
||||
// Configure MCTS based on proximity to enemy
|
||||
// When far from enemy: use AVERAGING with maxPlayerFlips=0 (single-player lookahead)
|
||||
|
||||
@@ -41,6 +41,7 @@ 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;
|
||||
|
||||
@@ -69,6 +70,7 @@ public:
|
||||
explicit ShardokAIClient(
|
||||
PlayerId playerId,
|
||||
bool isDefender,
|
||||
bool isAllAiBattle,
|
||||
const HexMap* hexMap,
|
||||
const SettingsGetter& settings,
|
||||
AIAlgorithmType aiAlgorithmType,
|
||||
|
||||
@@ -12,6 +12,7 @@ cc_library(
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
":game_update_receiver",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:remote_ai_client",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:shardok_ai_client",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
|
||||
|
||||
@@ -71,11 +71,17 @@ vector<shared_ptr<ShardokAIClient>> ShardokGameController::MakeAIClients(
|
||||
mctsConfig.maxSimulationFlips = 1;
|
||||
mctsConfig.simulationPolicy = mcts::MCTSSimulationPolicy::WEIGHTED_HEURISTIC;
|
||||
|
||||
for (const auto &pi : e->GetPlayerInfos()) {
|
||||
// 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) {
|
||||
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,
|
||||
@@ -148,7 +154,48 @@ void ShardokGameController::DoAIThread() {
|
||||
}
|
||||
|
||||
// Phase 2: AI thinks (NO LOCK - this is the slow part)
|
||||
const auto results = aiClient->ChooseCommandIndex(settings, gsv, availableCommands);
|
||||
CommandChoiceResults results;
|
||||
|
||||
// Try remote AI first if configured and not in fallback mode
|
||||
if (remoteAIClient && !useLocalAIFallback) {
|
||||
try {
|
||||
// Get game state bytes and AI client info for remote call
|
||||
const auto gameStateBytes = engine->GetCurrentGameStateBytes();
|
||||
const bool isDefender =
|
||||
aiClient->GetPlayerId() == playerId &&
|
||||
std::ranges::any_of(engine->GetPlayerInfos(), [playerId](const auto &pi) {
|
||||
return pi.player_id() == playerId && pi.is_defender();
|
||||
});
|
||||
|
||||
// Calculate time budget (simplified - remote will recalculate if needed)
|
||||
const auto timeBudget = std::chrono::milliseconds(2000);
|
||||
|
||||
// Check if all players are AI
|
||||
const bool isAllAiBattle = std::ranges::all_of(
|
||||
engine->GetPlayerInfos(),
|
||||
[](const auto &pi) { return pi.is_ai(); });
|
||||
|
||||
results = remoteAIClient->ChooseCommandIndex(
|
||||
playerId,
|
||||
isDefender,
|
||||
isAllAiBattle,
|
||||
gameStateBytes,
|
||||
timeBudget);
|
||||
|
||||
printf("[DoAIThread] Remote AI chose command %zu for player %d\n",
|
||||
results.chosenIndex,
|
||||
playerId);
|
||||
} catch (const std::exception &e) {
|
||||
printf("[DoAIThread] Remote AI failed: %s, falling back to local AI\n", e.what());
|
||||
useLocalAIFallback = true;
|
||||
// Fall through to local AI below
|
||||
}
|
||||
}
|
||||
|
||||
// Use local AI if remote is not configured, failed, or in fallback mode
|
||||
if (!remoteAIClient || useLocalAIFallback) {
|
||||
results = aiClient->ChooseCommandIndex(settings, gsv, availableCommands);
|
||||
}
|
||||
|
||||
// Phase 3: Post the command (brief lock)
|
||||
{
|
||||
@@ -183,6 +230,12 @@ ShardokGameController::~ShardokGameController() {
|
||||
aiThread.join();
|
||||
}
|
||||
|
||||
void ShardokGameController::SetRemoteAIClient(std::unique_ptr<RemoteAIClient> client) {
|
||||
remoteAIClient = std::move(client);
|
||||
useLocalAIFallback = false; // Reset fallback when setting new client
|
||||
printf("[ShardokGameController] Remote AI client set for game %s\n", cachedGameId.c_str());
|
||||
}
|
||||
|
||||
void CheckFactionId(
|
||||
const unique_ptr<ShardokEngine> &engine,
|
||||
const PlayerId shardokPlayerId,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#ifndef ShardokGameController_hpp
|
||||
#define ShardokGameController_hpp
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
@@ -16,6 +17,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "GameUpdateReceiver.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/RemoteAIClient.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/UnitPlacementInfo.hpp"
|
||||
@@ -103,6 +105,10 @@ private:
|
||||
vector<shared_ptr<ShardokAIClient>> aiClients;
|
||||
std::thread aiThread;
|
||||
|
||||
// Remote AI offload support
|
||||
std::unique_ptr<RemoteAIClient> remoteAIClient;
|
||||
std::atomic<bool> useLocalAIFallback{false};
|
||||
|
||||
static auto MakeLogFilePath() -> string {
|
||||
const time_t timer = time(nullptr);
|
||||
char buf[255];
|
||||
@@ -169,6 +175,11 @@ public:
|
||||
|
||||
[[nodiscard]] auto GetLogFilePath() const -> string { return logFilePath; }
|
||||
|
||||
/// Set a remote AI client for offloading AI computation to a remote server.
|
||||
/// When set, AI decisions will be made by the remote server instead of locally.
|
||||
/// If the remote call fails, falls back to local AI for the remainder of the game.
|
||||
void SetRemoteAIClient(std::unique_ptr<RemoteAIClient> client);
|
||||
|
||||
/// Register a subscriber to receive streaming updates for this game.
|
||||
/// The subscriber will receive updates until it becomes inactive or is unregistered.
|
||||
void RegisterSubscriber(std::shared_ptr<StreamSubscriber> subscriber);
|
||||
|
||||
@@ -12,6 +12,7 @@ cc_library(
|
||||
deps = [
|
||||
":server_configuration",
|
||||
"//src/main/cpp/net/eagle0/common:tsv_parser",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:remote_ai_client",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:shardok_ai_client",
|
||||
"//src/main/cpp/net/eagle0/shardok/controller",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
@@ -34,6 +35,17 @@ cc_library(
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "token_auth",
|
||||
hdrs = ["TokenAuthInterceptor.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = ["//src/main/cpp/net/eagle0/shardok:__subpackages__"],
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/common:protobuf_warning_suppression",
|
||||
"@grpc//:grpc++",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "eagle_interface_grpc_server",
|
||||
srcs = ["EagleInterfaceGrpcServer.cpp"],
|
||||
@@ -42,9 +54,27 @@ cc_library(
|
||||
visibility = ["//src/main/cpp/net/eagle0/shardok:__subpackages__"],
|
||||
deps = [
|
||||
":games_manager",
|
||||
":token_auth",
|
||||
"//src/main/cpp/net/eagle0/common:unit_conversions",
|
||||
"//src/main/protobuf/net/eagle0/common:shardok_internal_interface_cc_grpc",
|
||||
"//src/main/protobuf/net/eagle0/common:victory_condition_cc_proto",
|
||||
"@grpc//:grpc++",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "ai_service_grpc_server",
|
||||
srcs = ["ShardokAIServiceImpl.cpp"],
|
||||
hdrs = ["ShardokAIServiceImpl.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = ["//src/main/cpp/net/eagle0/shardok:__subpackages__"],
|
||||
deps = [
|
||||
":token_auth",
|
||||
"//src/main/cpp/net/eagle0/common:protobuf_warning_suppression",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:shardok_ai_client",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
|
||||
"//src/main/protobuf/net/eagle0/common:shardok_internal_interface_cc_grpc",
|
||||
"@grpc//:grpc++",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -81,8 +81,17 @@ static auto FromInternalStatus(
|
||||
|
||||
const std::string &kShardokGameRequestExtension = *(new string(".e0gr"));
|
||||
|
||||
EagleInterfaceImpl::EagleInterfaceImpl(shared_ptr<ShardokGamesManager> manager)
|
||||
: gamesManager(std::move(manager)) {}
|
||||
EagleInterfaceImpl::EagleInterfaceImpl(
|
||||
shared_ptr<ShardokGamesManager> manager,
|
||||
std::string authToken)
|
||||
: gamesManager(std::move(manager)),
|
||||
tokenValidator_(std::move(authToken)) {
|
||||
if (tokenValidator_.IsEnabled()) {
|
||||
std::cout << "Token authentication enabled" << std::endl;
|
||||
} else {
|
||||
std::cout << "Token authentication disabled (no token configured)" << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
auto ConvertPlayerInfo(
|
||||
const google::protobuf::RepeatedPtrField<net::eagle0::common::PlayerSetupInfo> &allPis,
|
||||
@@ -188,9 +197,12 @@ void EagleInterfaceImpl::StartGame(const NewGameRequest &request) {
|
||||
}
|
||||
|
||||
auto EagleInterfaceImpl::PostCommand(
|
||||
ServerContext * /*context*/,
|
||||
ServerContext *context,
|
||||
const PostCommandRequest *request,
|
||||
GameStatusResponse *response) -> Status {
|
||||
// Validate auth token
|
||||
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
|
||||
|
||||
shared_ptr<ShardokGameController> controller;
|
||||
try {
|
||||
controller = ControllerForGame(request->game_id(), request->game_setup_info());
|
||||
@@ -229,9 +241,12 @@ auto EagleInterfaceImpl::PostCommand(
|
||||
}
|
||||
|
||||
auto EagleInterfaceImpl::PostPlacementCommands(
|
||||
ServerContext * /*context*/,
|
||||
ServerContext *context,
|
||||
const PlacementCommandsRequest *request,
|
||||
GameStatusResponse *response) -> Status {
|
||||
// Validate auth token
|
||||
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
|
||||
|
||||
shared_ptr<ShardokGameController> controller;
|
||||
try {
|
||||
controller = ControllerForGame(request->game_id(), request->game_setup_info());
|
||||
@@ -323,9 +338,12 @@ void EagleInterfaceImpl::PopulateGameStatusResponse(
|
||||
}
|
||||
|
||||
auto EagleInterfaceImpl::GetHexMap(
|
||||
ServerContext * /*context*/,
|
||||
ServerContext *context,
|
||||
const HexMapRequest *request,
|
||||
HexMapResponse *response) -> Status {
|
||||
// Validate auth token
|
||||
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
|
||||
|
||||
// TODO: return appropriate status code if a bad map name is sent
|
||||
*response->mutable_map() = LoadMap(request->map_name());
|
||||
|
||||
@@ -333,9 +351,12 @@ auto EagleInterfaceImpl::GetHexMap(
|
||||
}
|
||||
|
||||
auto EagleInterfaceImpl::GetHexMapNames(
|
||||
ServerContext * /*context*/,
|
||||
ServerContext *context,
|
||||
const HexMapNamesRequest * /*request*/,
|
||||
HexMapNamesResponse *response) -> Status {
|
||||
// Validate auth token
|
||||
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
|
||||
|
||||
for (const string &mapName : GetMapNames()) { response->add_map_names(mapName); }
|
||||
|
||||
return Status::OK;
|
||||
@@ -516,6 +537,9 @@ auto EagleInterfaceImpl::SubscribeToGame(
|
||||
ServerContext *context,
|
||||
const GameSubscriptionRequest *request,
|
||||
grpc::ServerWriter<GameStatusResponse> *writer) -> Status {
|
||||
// Validate auth token
|
||||
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
|
||||
|
||||
shared_ptr<ShardokGameController> controller;
|
||||
try {
|
||||
controller = ControllerForGame(request->game_id(), request->game_setup_info());
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "ShardokGamesManager.hpp"
|
||||
#include "src/main/cpp/net/eagle0/common/ProtobufWarningSuppression.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/server/ShardokGamesManager.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/server/TokenAuthInterceptor.hpp"
|
||||
#pragma GCC diagnostic push
|
||||
SUPPRESS_PROTOBUF_WARNINGS
|
||||
#include <grpc/grpc.h>
|
||||
@@ -46,6 +47,7 @@ using std::shared_ptr;
|
||||
class EagleInterfaceImpl final : public ShardokInternalInterface::Service {
|
||||
private:
|
||||
std::shared_ptr<ShardokGamesManager> gamesManager;
|
||||
TokenValidator tokenValidator_;
|
||||
|
||||
auto ControllerForGame(const GameId& gameId, const GameSetupInfo& setupInfo)
|
||||
-> std::shared_ptr<ShardokGameController>;
|
||||
@@ -57,7 +59,14 @@ private:
|
||||
GameStatusResponse* response);
|
||||
|
||||
public:
|
||||
explicit EagleInterfaceImpl(std::shared_ptr<ShardokGamesManager> manager);
|
||||
/**
|
||||
* Create the service.
|
||||
*
|
||||
* @param manager Games manager
|
||||
* @param authToken Optional auth token. If non-empty, all requests must include
|
||||
* "authorization: Bearer <token>" metadata.
|
||||
*/
|
||||
EagleInterfaceImpl(std::shared_ptr<ShardokGamesManager> manager, std::string authToken = "");
|
||||
|
||||
auto PostCommand(
|
||||
ServerContext* context,
|
||||
|
||||
@@ -24,6 +24,12 @@ const string ServerConfiguration::kEagleInterfaceGrpcAddress = "eagleInterfaceGr
|
||||
const string ServerConfiguration::kEagleGrpcAddress = "eagleGrpcAddress";
|
||||
const string ServerConfiguration::kSslCertPath = "sslCertPath";
|
||||
const string ServerConfiguration::kSslPrivateKeyPath = "sslPrivateKeyPath";
|
||||
const string ServerConfiguration::kAuthTokenPath = "authTokenPath";
|
||||
// Remote AI offload configuration
|
||||
const string ServerConfiguration::kRemoteAIAddress = "remoteAIAddress";
|
||||
const string ServerConfiguration::kRemoteAIAuthTokenPath = "remoteAIAuthTokenPath";
|
||||
const string ServerConfiguration::kRemoteAITimeoutMs = "remoteAITimeoutMs";
|
||||
const string ServerConfiguration::kAIServiceAddress = "aiServiceAddress";
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
using std::unordered_map;
|
||||
@@ -36,11 +42,28 @@ unordered_map<string, string> DefaultValues() {
|
||||
// to listen on all interfaces for container networking
|
||||
const char* eagleInterfaceAddr = getenv("SHARDOK_EAGLE_INTERFACE_ADDRESS");
|
||||
const char* shardokAddr = getenv("SHARDOK_GRPC_ADDRESS");
|
||||
// Remote AI offload configuration
|
||||
const char* remoteAIAddr = getenv("SHARDOK_REMOTE_AI_ADDRESS");
|
||||
const char* remoteAIAuthTokenPath = getenv("SHARDOK_REMOTE_AI_AUTH_TOKEN_PATH");
|
||||
const char* remoteAITimeoutMs = getenv("SHARDOK_REMOTE_AI_TIMEOUT_MS");
|
||||
const char* aiServiceAddr = getenv("SHARDOK_AI_SERVICE_ADDRESS");
|
||||
|
||||
return unordered_map<string, string>{
|
||||
unordered_map<string, string> defaults{
|
||||
{ServerConfiguration::kShardokGrpcAddress, shardokAddr ? shardokAddr : "localhost"},
|
||||
{ServerConfiguration::kEagleInterfaceGrpcAddress,
|
||||
eagleInterfaceAddr ? eagleInterfaceAddr : "localhost:40042"}};
|
||||
|
||||
// Only add remote AI config if environment variables are set
|
||||
if (remoteAIAddr) { defaults[ServerConfiguration::kRemoteAIAddress] = remoteAIAddr; }
|
||||
if (remoteAIAuthTokenPath) {
|
||||
defaults[ServerConfiguration::kRemoteAIAuthTokenPath] = remoteAIAuthTokenPath;
|
||||
}
|
||||
if (remoteAITimeoutMs) {
|
||||
defaults[ServerConfiguration::kRemoteAITimeoutMs] = remoteAITimeoutMs;
|
||||
}
|
||||
if (aiServiceAddr) { defaults[ServerConfiguration::kAIServiceAddress] = aiServiceAddr; }
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
ServerConfiguration::ServerConfiguration(const string& filePath) {
|
||||
|
||||
@@ -29,6 +29,13 @@ public:
|
||||
const static string kEagleGrpcAddress;
|
||||
const static string kSslCertPath;
|
||||
const static string kSslPrivateKeyPath;
|
||||
const static string kAuthTokenPath;
|
||||
|
||||
// Remote AI offload configuration
|
||||
const static string kRemoteAIAddress;
|
||||
const static string kRemoteAIAuthTokenPath;
|
||||
const static string kRemoteAITimeoutMs;
|
||||
const static string kAIServiceAddress;
|
||||
};
|
||||
|
||||
#endif /* ServerConfiguration_hpp */
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//
|
||||
// ShardokAIServiceImpl.cpp
|
||||
// shardok
|
||||
//
|
||||
// Created by Claude on 2025.
|
||||
// Copyright © 2025 none. All rights reserved.
|
||||
//
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/server/ShardokAIServiceImpl.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/common/mcts/abstract/MCTSTypes.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/GameStateHelpers.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
using grpc::ServerContext;
|
||||
using grpc::Status;
|
||||
using grpc::StatusCode;
|
||||
using net::eagle0::common::ChooseAICommandRequest;
|
||||
using net::eagle0::common::ChooseAICommandResponse;
|
||||
|
||||
ShardokAIServiceImpl::ShardokAIServiceImpl(GameSettingsSPtr gameSettings, std::string authToken)
|
||||
: tokenValidator_(std::move(authToken)),
|
||||
gameSettings_(std::move(gameSettings)) {
|
||||
std::cout << "[ShardokAIService] Initialized with auth "
|
||||
<< (tokenValidator_.IsEnabled() ? "enabled" : "disabled") << std::endl;
|
||||
}
|
||||
|
||||
auto ShardokAIServiceImpl::ChooseAICommand(
|
||||
ServerContext* context,
|
||||
const ChooseAICommandRequest* request,
|
||||
ChooseAICommandResponse* response) -> Status {
|
||||
// Validate auth token
|
||||
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
|
||||
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
|
||||
std::cout << "[ShardokAIService] ChooseAICommand for game " << request->game_id() << " player "
|
||||
<< request->player_id() << " (defender=" << request->is_defender()
|
||||
<< ", allAI=" << request->is_all_ai_battle()
|
||||
<< ", budget=" << request->time_budget_ms() << "ms"
|
||||
<< ", stateSize=" << request->game_state_bytes().size() << ")" << std::endl;
|
||||
|
||||
try {
|
||||
// Reconstruct game state from bytes
|
||||
const auto& stateBytes = request->game_state_bytes();
|
||||
byte_vector gameStateVec(stateBytes.begin(), stateBytes.end());
|
||||
|
||||
auto gameState = GameStateW::FromByteVector(std::move(gameStateVec));
|
||||
if (!gameState) {
|
||||
std::cerr << "[ShardokAIService] Failed to deserialize game state" << std::endl;
|
||||
return Status(StatusCode::INVALID_ARGUMENT, "Failed to deserialize game state");
|
||||
}
|
||||
|
||||
// Create engine from game state
|
||||
auto engine = std::make_unique<ShardokEngine>(gameSettings_, gameState);
|
||||
|
||||
// MCTS configuration (matches what's used in MakeAIClients)
|
||||
mcts::MCTSConfig mctsConfig;
|
||||
mctsConfig.backpropagationPolicy = mcts::MCTSBackpropagationPolicy::MINIMAX;
|
||||
mctsConfig.maxPlayerFlips = 1;
|
||||
mctsConfig.maxSimulationFlips = 1;
|
||||
mctsConfig.simulationPolicy = mcts::MCTSSimulationPolicy::WEIGHTED_HEURISTIC;
|
||||
|
||||
// Create AI client
|
||||
auto aiClient = std::make_unique<ShardokAIClient>(
|
||||
static_cast<PlayerId>(request->player_id()),
|
||||
request->is_defender(),
|
||||
request->is_all_ai_battle(),
|
||||
engine->GetCurrentGameState()->hex_map(),
|
||||
gameSettings_->GetGetter(),
|
||||
AIAlgorithmType::ITERATIVE_DEEPENING,
|
||||
ScoringCalculatorType::MCTS_OPTIMIZED,
|
||||
mctsConfig);
|
||||
|
||||
// Get available commands
|
||||
const auto availableCommands = engine->GetAvailableCommandsForAIPlayer(
|
||||
static_cast<PlayerId>(request->player_id()));
|
||||
|
||||
if (!availableCommands || availableCommands->empty()) {
|
||||
std::cerr << "[ShardokAIService] No commands available for player "
|
||||
<< request->player_id() << std::endl;
|
||||
return Status(StatusCode::FAILED_PRECONDITION, "No commands available for player");
|
||||
}
|
||||
|
||||
// Get game state view for AI
|
||||
const auto gsv = engine->GetGameStateView(static_cast<PlayerId>(request->player_id()));
|
||||
|
||||
// Run AI evaluation
|
||||
const auto results = aiClient->ChooseCommandIndex(gameSettings_, gsv, availableCommands);
|
||||
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - start);
|
||||
|
||||
std::cout << "[ShardokAIService] AI chose command " << results.chosenIndex << " for player "
|
||||
<< request->player_id() << " (depth=" << results.depthAchieved
|
||||
<< ", evaluated=" << results.commandCountEvaluated
|
||||
<< ", elapsed=" << elapsed.count() << "ms)" << std::endl;
|
||||
|
||||
// Populate response
|
||||
response->set_chosen_index(static_cast<int32_t>(results.chosenIndex));
|
||||
response->set_depth_achieved(results.depthAchieved);
|
||||
response->set_commands_evaluated(static_cast<int32_t>(results.commandCountEvaluated));
|
||||
response->set_elapsed_ms(static_cast<int32_t>(elapsed.count()));
|
||||
|
||||
return Status::OK;
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "[ShardokAIService] Exception during AI computation: " << e.what()
|
||||
<< std::endl;
|
||||
return Status(StatusCode::INTERNAL, std::string("AI computation failed: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// ShardokAIServiceImpl.hpp
|
||||
// shardok
|
||||
//
|
||||
// Created by Claude on 2025.
|
||||
// Copyright © 2025 none. All rights reserved.
|
||||
//
|
||||
|
||||
#ifndef ShardokAIServiceImpl_hpp
|
||||
#define ShardokAIServiceImpl_hpp
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/common/ProtobufWarningSuppression.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/server/TokenAuthInterceptor.hpp"
|
||||
#pragma GCC diagnostic push
|
||||
SUPPRESS_PROTOBUF_WARNINGS
|
||||
#include <grpcpp/grpcpp.h>
|
||||
|
||||
#include "src/main/protobuf/net/eagle0/common/shardok_internal_interface.grpc.pb.h"
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
namespace shardok {
|
||||
|
||||
/**
|
||||
* gRPC service implementation for remote AI computation.
|
||||
*
|
||||
* This service runs on Shardok-AI (Hetzner) and handles AI command selection
|
||||
* requests from Shardok-Primary (DigitalOcean).
|
||||
*
|
||||
* Each request is stateless: the full game state is sent with the request,
|
||||
* and only the chosen command index is returned.
|
||||
*/
|
||||
class ShardokAIServiceImpl final : public net::eagle0::common::ShardokAIService::Service {
|
||||
private:
|
||||
TokenValidator tokenValidator_;
|
||||
GameSettingsSPtr gameSettings_;
|
||||
|
||||
public:
|
||||
/**
|
||||
* Create the AI service.
|
||||
*
|
||||
* @param gameSettings Shared game settings (immutable, used for all requests)
|
||||
* @param authToken Optional auth token. If non-empty, all requests must include
|
||||
* "authorization: Bearer <token>" metadata.
|
||||
*/
|
||||
explicit ShardokAIServiceImpl(GameSettingsSPtr gameSettings, std::string authToken = "");
|
||||
|
||||
/**
|
||||
* Handle an AI command selection request.
|
||||
*
|
||||
* Reconstructs game state from bytes, runs AI evaluation, returns chosen index.
|
||||
*/
|
||||
auto ChooseAICommand(
|
||||
grpc::ServerContext* context,
|
||||
const net::eagle0::common::ChooseAICommandRequest* request,
|
||||
net::eagle0::common::ChooseAICommandResponse* response) -> grpc::Status override;
|
||||
};
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif /* ShardokAIServiceImpl_hpp */
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <utility>
|
||||
|
||||
#include "ServerConfiguration.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/RemoteAIClient.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/controller/ShardokGameController.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings_loader/SettingsLoader.hpp"
|
||||
@@ -30,7 +31,9 @@ auto SetUpController(
|
||||
int month,
|
||||
const vector<Unit> &units,
|
||||
const vector<PlayerInfoProto> &playerSetupInfos,
|
||||
const string &serializedRequest) -> shared_ptr<ShardokGameController>;
|
||||
const string &serializedRequest,
|
||||
const std::shared_ptr<RemoteAIClientFactory> &remoteAIClientFactory)
|
||||
-> shared_ptr<ShardokGameController>;
|
||||
|
||||
ShardokGamesManager::ShardokGamesManager(const std::vector<std::string> &extraSettings) {
|
||||
gameSettings = SettingsLoader::LoadSettings();
|
||||
@@ -46,6 +49,12 @@ ShardokGamesManager::ShardokGamesManager(const std::vector<std::string> &extraSe
|
||||
}
|
||||
}
|
||||
|
||||
void ShardokGamesManager::SetRemoteAIClientFactory(std::shared_ptr<RemoteAIClientFactory> factory) {
|
||||
remoteAIClientFactory = std::move(factory);
|
||||
std::cout << "[ShardokGamesManager] Remote AI client factory "
|
||||
<< (remoteAIClientFactory ? "enabled" : "disabled") << std::endl;
|
||||
}
|
||||
|
||||
auto ShardokGamesManager::GetController(const GameId &gameId)
|
||||
-> std::shared_ptr<ShardokGameController> {
|
||||
std::shared_lock<std::shared_mutex> lk(runningControllersLock);
|
||||
@@ -111,7 +120,8 @@ auto ShardokGamesManager::UnlockedCreateSpecifiedGame(
|
||||
month,
|
||||
units,
|
||||
playerInfos,
|
||||
serializedRequest);
|
||||
serializedRequest,
|
||||
remoteAIClientFactory);
|
||||
|
||||
return LockedCreateGame(controller, playerSetupInfos, serializedRequest);
|
||||
}
|
||||
@@ -137,7 +147,9 @@ auto SetUpController(
|
||||
const int month,
|
||||
const vector<Unit> &units,
|
||||
const vector<PlayerInfoProto> &playerInfos,
|
||||
const string &serializedRequest) -> shared_ptr<ShardokGameController> {
|
||||
const string &serializedRequest,
|
||||
const std::shared_ptr<RemoteAIClientFactory> &remoteAIClientFactory)
|
||||
-> shared_ptr<ShardokGameController> {
|
||||
auto unplacedUnits = std::vector<Unit>();
|
||||
|
||||
UnitId nextUnitId = 0;
|
||||
@@ -155,7 +167,16 @@ auto SetUpController(
|
||||
gameId,
|
||||
playerInfos);
|
||||
|
||||
return std::make_shared<ShardokGameController>(std::move(engine), mapName, serializedRequest);
|
||||
auto controller =
|
||||
std::make_shared<ShardokGameController>(std::move(engine), mapName, serializedRequest);
|
||||
|
||||
// Set up remote AI client if factory is available
|
||||
if (remoteAIClientFactory) {
|
||||
auto remoteClient = remoteAIClientFactory->CreateClient(gameId);
|
||||
if (remoteClient) { controller->SetRemoteAIClient(std::move(remoteClient)); }
|
||||
}
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/RemoteAIClient.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
#include "src/main/protobuf/net/eagle0/shardok/common/game_status.pb.h"
|
||||
@@ -81,6 +82,7 @@ public:
|
||||
class ShardokGamesManager {
|
||||
private:
|
||||
GameSettingsSPtr gameSettings;
|
||||
std::shared_ptr<RemoteAIClientFactory> remoteAIClientFactory;
|
||||
|
||||
std::shared_mutex runningControllersLock;
|
||||
gtl::flat_hash_map<GameId, std::shared_ptr<ShardokGameController>> runningControllers;
|
||||
@@ -93,6 +95,17 @@ private:
|
||||
public:
|
||||
explicit ShardokGamesManager(const std::vector<std::string> &extraSettings);
|
||||
|
||||
/**
|
||||
* Set the factory for creating remote AI clients.
|
||||
* When set, game controllers will use remote AI for AI computation.
|
||||
*/
|
||||
void SetRemoteAIClientFactory(std::shared_ptr<RemoteAIClientFactory> factory);
|
||||
|
||||
/**
|
||||
* Get the game settings used by this manager.
|
||||
*/
|
||||
[[nodiscard]] auto GetGameSettings() const -> GameSettingsSPtr { return gameSettings; }
|
||||
|
||||
auto GetController(const GameId &gameId) -> std::shared_ptr<ShardokGameController>;
|
||||
|
||||
auto UnlockedCreateSpecifiedGame(
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//
|
||||
// TokenAuthInterceptor.hpp
|
||||
// eagle0
|
||||
//
|
||||
// Token-based authentication for gRPC.
|
||||
// Validates Bearer tokens in the 'authorization' metadata header.
|
||||
//
|
||||
|
||||
#ifndef TokenAuthInterceptor_hpp
|
||||
#define TokenAuthInterceptor_hpp
|
||||
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/common/ProtobufWarningSuppression.hpp"
|
||||
#pragma GCC diagnostic push
|
||||
SUPPRESS_PROTOBUF_WARNINGS
|
||||
#include <grpcpp/server_context.h>
|
||||
#include <grpcpp/support/status.h>
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
namespace shardok {
|
||||
|
||||
/**
|
||||
* Validates Bearer tokens in gRPC request metadata.
|
||||
*
|
||||
* Usage:
|
||||
* TokenValidator validator(expectedToken);
|
||||
* if (!validator.Validate(context)) {
|
||||
* return grpc::Status(grpc::UNAUTHENTICATED, "Invalid token");
|
||||
* }
|
||||
*/
|
||||
class TokenValidator {
|
||||
public:
|
||||
/**
|
||||
* Create a validator with the expected token.
|
||||
* If expectedToken is empty, all requests are allowed (no auth required).
|
||||
*/
|
||||
explicit TokenValidator(std::string expectedToken) : expectedToken_(std::move(expectedToken)) {}
|
||||
|
||||
/**
|
||||
* Validate the authorization header in the server context.
|
||||
*
|
||||
* Expects: "authorization" metadata with value "Bearer <token>"
|
||||
*
|
||||
* @return true if valid (or no auth configured), false otherwise
|
||||
*/
|
||||
[[nodiscard]] auto Validate(grpc::ServerContext* context) const -> bool {
|
||||
// If no auth token is configured, allow all requests
|
||||
if (expectedToken_.empty()) { return true; }
|
||||
|
||||
const auto& metadata = context->client_metadata();
|
||||
auto it = metadata.find("authorization");
|
||||
if (it == metadata.end()) {
|
||||
std::cerr << "Auth failed: no authorization header" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string authHeader(it->second.begin(), it->second.end());
|
||||
std::string expectedHeader = "Bearer " + expectedToken_;
|
||||
|
||||
if (authHeader != expectedHeader) {
|
||||
std::cerr << "Auth failed: invalid token" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and return appropriate Status.
|
||||
*
|
||||
* @return std::nullopt if valid, Status(UNAUTHENTICATED) if invalid
|
||||
*/
|
||||
[[nodiscard]] auto ValidateOrStatus(grpc::ServerContext* context) const
|
||||
-> std::optional<grpc::Status> {
|
||||
if (Validate(context)) { return std::nullopt; }
|
||||
return grpc::Status(grpc::UNAUTHENTICATED, "Invalid or missing authentication token");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if authentication is enabled.
|
||||
*/
|
||||
[[nodiscard]] auto IsEnabled() const -> bool { return !expectedToken_.empty(); }
|
||||
|
||||
private:
|
||||
std::string expectedToken_;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read auth token from a file, stripping whitespace.
|
||||
* Returns empty string if file doesn't exist or is empty.
|
||||
*/
|
||||
inline auto ReadAuthTokenFromFile(const std::string& filePath) -> std::string {
|
||||
if (filePath.empty()) { return ""; }
|
||||
|
||||
std::ifstream file(filePath);
|
||||
if (!file.is_open()) {
|
||||
std::cerr << "Warning: Could not open auth token file: " << filePath << std::endl;
|
||||
return "";
|
||||
}
|
||||
|
||||
std::stringstream buffer;
|
||||
buffer << file.rdbuf();
|
||||
std::string token = buffer.str();
|
||||
|
||||
// Trim whitespace
|
||||
auto start = token.find_first_not_of(" \t\n\r");
|
||||
if (start == std::string::npos) { return ""; }
|
||||
auto end = token.find_last_not_of(" \t\n\r");
|
||||
return token.substr(start, end - start + 1);
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif /* TokenAuthInterceptor_hpp */
|
||||
@@ -7,7 +7,10 @@
|
||||
//
|
||||
|
||||
#include <cstddef>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <thread>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/common/ProtobufWarningSuppression.hpp"
|
||||
@@ -17,14 +20,18 @@ SUPPRESS_PROTOBUF_WARNINGS
|
||||
|
||||
#include <execinfo.h>
|
||||
#include <grpcpp/channel.h>
|
||||
#include <grpcpp/security/server_credentials.h>
|
||||
#include <signal.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
#include "server/EagleInterfaceGrpcServer.hpp"
|
||||
#include "server/ServerConfiguration.hpp"
|
||||
#include "server/ShardokGamesManager.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/RemoteAIClient.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/server/EagleInterfaceGrpcServer.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/server/ServerConfiguration.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/server/ShardokAIServiceImpl.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/server/ShardokGamesManager.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/server/TokenAuthInterceptor.hpp"
|
||||
|
||||
using ::GameStatePersister;
|
||||
using shardok::EagleInterfaceImpl;
|
||||
@@ -41,12 +48,22 @@ struct ServerThreadInfo {
|
||||
|
||||
auto StartInThread(grpc::ServerBuilder *serverBuilder) -> ServerThreadInfo;
|
||||
|
||||
auto CreateServerBuilder(const string &serverAddress) -> grpc::ServerBuilder *;
|
||||
auto CreateServerBuilder(const string &serverAddress, const string &certPath, const string &keyPath)
|
||||
-> grpc::ServerBuilder *;
|
||||
|
||||
auto CreateEagleInterfaceService(
|
||||
const std::shared_ptr<ServerConfiguration> &config,
|
||||
std::shared_ptr<ShardokGamesManager> shardokGamesManager) -> ServerThreadInfo;
|
||||
|
||||
auto CreateAIService(
|
||||
const std::shared_ptr<ServerConfiguration> &config,
|
||||
shardok::GameSettingsSPtr gameSettings) -> std::optional<ServerThreadInfo>;
|
||||
|
||||
auto SetupRemoteAIClient(const std::shared_ptr<ServerConfiguration> &config)
|
||||
-> std::shared_ptr<shardok::RemoteAIClientFactory>;
|
||||
|
||||
auto ReadFileContents(const string &path) -> string;
|
||||
|
||||
void handler(const int sig) {
|
||||
void *array[10];
|
||||
size_t size;
|
||||
@@ -77,16 +94,69 @@ auto main(const int argc, char **argv) -> int {
|
||||
|
||||
const auto shardokGamesManager = std::make_shared<ShardokGamesManager>(extraSettings);
|
||||
|
||||
ServerThreadInfo eagleInterfaceInfo = CreateEagleInterfaceService(config, shardokGamesManager);
|
||||
// Set up remote AI client if configured (for Shardok-Primary mode)
|
||||
auto remoteAIFactory = SetupRemoteAIClient(config);
|
||||
if (remoteAIFactory) { shardokGamesManager->SetRemoteAIClientFactory(remoteAIFactory); }
|
||||
|
||||
eagleInterfaceInfo.thread.join();
|
||||
// Start AI service if configured (for Shardok-AI mode)
|
||||
std::optional<ServerThreadInfo> aiServiceInfo =
|
||||
CreateAIService(config, shardokGamesManager->GetGameSettings());
|
||||
|
||||
// Start Eagle interface service (may be disabled in AI-only mode)
|
||||
const string eagleInterfaceAddress =
|
||||
config->stringForKey(ServerConfiguration::kEagleInterfaceGrpcAddress);
|
||||
std::optional<ServerThreadInfo> eagleInterfaceInfo;
|
||||
if (!eagleInterfaceAddress.empty()) {
|
||||
eagleInterfaceInfo = CreateEagleInterfaceService(config, shardokGamesManager);
|
||||
} else {
|
||||
std::cout << "Eagle interface disabled (no address configured)" << std::endl;
|
||||
}
|
||||
|
||||
// Wait for services to complete
|
||||
if (eagleInterfaceInfo) { eagleInterfaceInfo->thread.join(); }
|
||||
if (aiServiceInfo) { aiServiceInfo->thread.join(); }
|
||||
}
|
||||
|
||||
auto CreateServerBuilder(const string &serverAddress) -> grpc::ServerBuilder * {
|
||||
auto ReadFileContents(const string &path) -> string {
|
||||
std::ifstream file(path);
|
||||
if (!file.is_open()) { return ""; }
|
||||
std::stringstream buffer;
|
||||
buffer << file.rdbuf();
|
||||
return buffer.str();
|
||||
}
|
||||
|
||||
auto CreateServerBuilder(const string &serverAddress, const string &certPath, const string &keyPath)
|
||||
-> grpc::ServerBuilder * {
|
||||
auto *serverBuilder = new grpc::ServerBuilder;
|
||||
|
||||
const auto credentials = grpc::InsecureServerCredentials();
|
||||
std::cout << "Creating insecure connection" << std::endl;
|
||||
std::shared_ptr<grpc::ServerCredentials> credentials;
|
||||
|
||||
// Check if TLS is configured
|
||||
if (!certPath.empty() && !keyPath.empty()) {
|
||||
const string certContents = ReadFileContents(certPath);
|
||||
const string keyContents = ReadFileContents(keyPath);
|
||||
|
||||
if (certContents.empty() || keyContents.empty()) {
|
||||
std::cerr << "Error: Could not read TLS certificate or key file" << std::endl;
|
||||
std::cerr << " cert path: " << certPath << std::endl;
|
||||
std::cerr << " key path: " << keyPath << std::endl;
|
||||
std::cerr << "Falling back to insecure connection" << std::endl;
|
||||
credentials = grpc::InsecureServerCredentials();
|
||||
} else {
|
||||
grpc::SslServerCredentialsOptions::PemKeyCertPair keyCert;
|
||||
keyCert.private_key = keyContents;
|
||||
keyCert.cert_chain = certContents;
|
||||
|
||||
grpc::SslServerCredentialsOptions sslOpts;
|
||||
sslOpts.pem_key_cert_pairs.push_back(keyCert);
|
||||
|
||||
credentials = grpc::SslServerCredentials(sslOpts);
|
||||
std::cout << "TLS enabled with certificate from: " << certPath << std::endl;
|
||||
}
|
||||
} else {
|
||||
credentials = grpc::InsecureServerCredentials();
|
||||
std::cout << "Creating insecure connection (no TLS configured)" << std::endl;
|
||||
}
|
||||
|
||||
serverBuilder->AddListeningPort(serverAddress, credentials);
|
||||
return serverBuilder;
|
||||
@@ -98,9 +168,19 @@ auto CreateEagleInterfaceService(
|
||||
const string eagleInterfaceServerAddress =
|
||||
config->stringForKey(ServerConfiguration::kEagleInterfaceGrpcAddress);
|
||||
|
||||
auto *eagleInterfaceServerBuilder = CreateServerBuilder(eagleInterfaceServerAddress);
|
||||
// TLS configuration
|
||||
const string certPath = config->stringForKey(ServerConfiguration::kSslCertPath);
|
||||
const string keyPath = config->stringForKey(ServerConfiguration::kSslPrivateKeyPath);
|
||||
|
||||
const auto eagleInterfaceService = std::make_shared<EagleInterfaceImpl>(shardokGamesManager);
|
||||
auto *eagleInterfaceServerBuilder =
|
||||
CreateServerBuilder(eagleInterfaceServerAddress, certPath, keyPath);
|
||||
|
||||
// Auth token configuration
|
||||
const string authTokenPath = config->stringForKey(ServerConfiguration::kAuthTokenPath);
|
||||
const string authToken = shardok::ReadAuthTokenFromFile(authTokenPath);
|
||||
|
||||
const auto eagleInterfaceService =
|
||||
std::make_shared<EagleInterfaceImpl>(shardokGamesManager, authToken);
|
||||
eagleInterfaceServerBuilder->RegisterService(eagleInterfaceService.get());
|
||||
|
||||
ServerThreadInfo eagleInterfaceThreadInfo = StartInThread(eagleInterfaceServerBuilder);
|
||||
@@ -135,3 +215,61 @@ auto StartInThread(grpc::ServerBuilder *serverBuilder) -> ServerThreadInfo {
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
auto SetupRemoteAIClient(const std::shared_ptr<ServerConfiguration> &config)
|
||||
-> std::shared_ptr<shardok::RemoteAIClientFactory> {
|
||||
const string remoteAIAddress = config->stringForKey(ServerConfiguration::kRemoteAIAddress);
|
||||
|
||||
if (remoteAIAddress.empty()) {
|
||||
std::cout << "Remote AI disabled (no address configured)" << std::endl;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Read auth token from file
|
||||
const string authTokenPath = config->stringForKey(ServerConfiguration::kRemoteAIAuthTokenPath);
|
||||
const string authToken = shardok::ReadAuthTokenFromFile(authTokenPath);
|
||||
|
||||
// Parse timeout (default 10 seconds)
|
||||
const string timeoutStr = config->stringForKey(ServerConfiguration::kRemoteAITimeoutMs);
|
||||
auto timeout = std::chrono::milliseconds(10000);
|
||||
if (!timeoutStr.empty()) {
|
||||
try {
|
||||
timeout = std::chrono::milliseconds(std::stoi(timeoutStr));
|
||||
} catch (...) {
|
||||
std::cerr << "Invalid remote AI timeout value: " << timeoutStr << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
std::cout << "Remote AI enabled: " << remoteAIAddress << " (timeout=" << timeout.count() << "ms"
|
||||
<< ", auth=" << (authToken.empty() ? "disabled" : "enabled") << ")" << std::endl;
|
||||
|
||||
return std::make_shared<shardok::RemoteAIClientFactory>(remoteAIAddress, authToken, timeout);
|
||||
}
|
||||
|
||||
auto CreateAIService(
|
||||
const std::shared_ptr<ServerConfiguration> &config,
|
||||
shardok::GameSettingsSPtr gameSettings) -> std::optional<ServerThreadInfo> {
|
||||
const string aiServiceAddress = config->stringForKey(ServerConfiguration::kAIServiceAddress);
|
||||
|
||||
if (aiServiceAddress.empty()) { return std::nullopt; }
|
||||
|
||||
// TLS configuration
|
||||
const string certPath = config->stringForKey(ServerConfiguration::kSslCertPath);
|
||||
const string keyPath = config->stringForKey(ServerConfiguration::kSslPrivateKeyPath);
|
||||
|
||||
auto *serverBuilder = CreateServerBuilder(aiServiceAddress, certPath, keyPath);
|
||||
|
||||
// Auth token configuration (same as Eagle interface)
|
||||
const string authTokenPath = config->stringForKey(ServerConfiguration::kAuthTokenPath);
|
||||
const string authToken = shardok::ReadAuthTokenFromFile(authTokenPath);
|
||||
|
||||
auto aiService = std::make_shared<shardok::ShardokAIServiceImpl>(gameSettings, authToken);
|
||||
serverBuilder->RegisterService(aiService.get());
|
||||
|
||||
ServerThreadInfo info = StartInThread(serverBuilder);
|
||||
info.service = aiService;
|
||||
|
||||
std::cout << "AI service listening on " << aiServiceAddress << std::endl;
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
<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" />
|
||||
@@ -125,6 +126,7 @@
|
||||
<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" />
|
||||
@@ -146,6 +148,7 @@
|
||||
<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" />
|
||||
@@ -153,6 +156,7 @@
|
||||
<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" />
|
||||
@@ -258,6 +262,7 @@
|
||||
<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" />
|
||||
@@ -363,6 +368,7 @@
|
||||
<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" />
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 683e069fbc3074e35ac6fc3881a03f4a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,192 @@
|
||||
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.
|
||||
/// Routes OAuth requests (GetOAuthUrl, CheckOAuthStatus, RefreshToken) to the Go auth service.
|
||||
/// Routes user requests (SetDisplayName, GetCurrentUser, Logout) to Eagle.
|
||||
/// </summary>
|
||||
public class AuthClient : IDisposable {
|
||||
private const int PollIntervalMs = 2000; // Poll every 2 seconds
|
||||
private const int PollTimeoutMs = 300000; // 5 minute timeout
|
||||
|
||||
private readonly GrpcAuthClient _authServiceClient; // Go auth service
|
||||
private readonly GrpcAuthClient _eagleClient; // Eagle server
|
||||
private readonly GrpcChannel _authServiceChannel;
|
||||
private readonly GrpcChannel _eagleChannel;
|
||||
|
||||
/// <summary>
|
||||
/// Create auth client with separate channels for auth service and Eagle.
|
||||
/// </summary>
|
||||
/// <param name="authServiceUrl">Go auth service URL, e.g.
|
||||
/// "https://prod.eagle0.net:40033"</param> <param name="eagleUrl">Eagle server URL, e.g.
|
||||
/// "https://prod.eagle0.net:40032"</param>
|
||||
public AuthClient(string authServiceUrl, string eagleUrl) {
|
||||
// Auth service channel (no JWT needed for OAuth flow)
|
||||
_authServiceChannel = GrpcChannel.ForAddress(
|
||||
authServiceUrl,
|
||||
new GrpcChannelOptions {
|
||||
HttpHandler = new YetAnotherHttpHandler { Http2Only = true },
|
||||
DisposeHttpClient = true
|
||||
});
|
||||
_authServiceClient = new GrpcAuthClient(_authServiceChannel);
|
||||
|
||||
// Eagle channel with JWT interceptor for authenticated requests
|
||||
_eagleChannel = GrpcChannel.ForAddress(
|
||||
eagleUrl,
|
||||
new GrpcChannelOptions {
|
||||
HttpHandler = new YetAnotherHttpHandler { Http2Only = true },
|
||||
DisposeHttpClient = true
|
||||
});
|
||||
CallInvoker eagleInvoker = _eagleChannel.Intercept(new JwtAuthInterceptor());
|
||||
_eagleClient = new GrpcAuthClient(eagleInvoker);
|
||||
|
||||
Debug.Log($"[AuthClient] Configured: authService={authServiceUrl}, eagle={eagleUrl}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get OAuth URL to open in system browser.
|
||||
/// Returns both the URL and the state token for polling.
|
||||
/// Routed to Go auth service.
|
||||
/// </summary>
|
||||
/// <param name="provider">Discord or Google</param>
|
||||
/// <returns>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 _authServiceClient.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.
|
||||
/// Routed to Go auth service.
|
||||
/// </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 _authServiceClient.CheckOAuthStatusAsync(request);
|
||||
|
||||
switch (response.Status) {
|
||||
case OAuthStatus.Success:
|
||||
Debug.Log(
|
||||
$"[AuthClient] OAuth successful, user={response.User?.DisplayName}, isNew={response.IsNewUser}");
|
||||
return response;
|
||||
|
||||
case OAuthStatus.Failed:
|
||||
throw new Exception($"OAuth failed: {response.ErrorMessage}");
|
||||
|
||||
case OAuthStatus.Expired:
|
||||
throw new Exception("OAuth session expired. Please try again.");
|
||||
|
||||
case OAuthStatus.Pending:
|
||||
// Check timeout
|
||||
if ((DateTime.UtcNow - startTime).TotalMilliseconds > PollTimeoutMs) {
|
||||
throw new TimeoutException("OAuth login timed out. Please try again.");
|
||||
}
|
||||
// Wait before polling again
|
||||
await Task.Delay(PollIntervalMs);
|
||||
break;
|
||||
|
||||
default: throw new Exception($"Unknown OAuth status: {response.Status}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh access token using stored refresh token.
|
||||
/// Routed to Go auth service.
|
||||
/// </summary>
|
||||
/// <returns>New access token and expiry, or null if refresh failed</returns>
|
||||
public async Task<RefreshTokenResponse> RefreshTokenAsync() {
|
||||
var refreshToken = TokenStorage.RefreshToken;
|
||||
if (string.IsNullOrEmpty(refreshToken)) {
|
||||
throw new InvalidOperationException("No refresh token available");
|
||||
}
|
||||
|
||||
var request = new RefreshTokenRequest { RefreshToken = refreshToken };
|
||||
|
||||
var response = await _authServiceClient.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.
|
||||
/// Routed to Eagle.
|
||||
/// </summary>
|
||||
public async Task<SetDisplayNameResponse> SetDisplayNameAsync(string displayName) {
|
||||
var request = new SetDisplayNameRequest { DisplayName = displayName };
|
||||
var response = await _eagleClient.SetDisplayNameAsync(request);
|
||||
|
||||
if (response.Success) {
|
||||
TokenStorage.UpdateDisplayName(displayName);
|
||||
// Update the access token with the new one that has updated displayName claim
|
||||
if (!string.IsNullOrEmpty(response.AccessToken)) {
|
||||
// Token expires in 7 days from now
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddDays(7).ToUnixTimeSeconds();
|
||||
TokenStorage.UpdateAccessToken(response.AccessToken, expiresAt);
|
||||
Debug.Log($"[AuthClient] Display name set to: {displayName}, token updated");
|
||||
} else {
|
||||
Debug.Log($"[AuthClient] Display name set to: {displayName}");
|
||||
}
|
||||
} else {
|
||||
Debug.LogWarning(
|
||||
$"[AuthClient] Failed to set display name: {response.ErrorMessage}");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current user info. Validates the stored JWT.
|
||||
/// Routed to Eagle.
|
||||
/// </summary>
|
||||
public async Task<GetCurrentUserResponse> GetCurrentUserAsync() {
|
||||
var response = await _eagleClient.GetCurrentUserAsync(new GetCurrentUserRequest());
|
||||
Debug.Log($"[AuthClient] Current user: {response.User?.DisplayName}");
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logout - invalidates refresh token on server.
|
||||
/// Routed to Eagle.
|
||||
/// </summary>
|
||||
public async Task LogoutAsync() {
|
||||
try {
|
||||
await _eagleClient.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() {
|
||||
_authServiceChannel?.Dispose();
|
||||
_eagleChannel?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4ba94126c2d2145b8875612eaf5bc371
|
||||
@@ -0,0 +1,75 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8528d8ee967e042ecb2a5e8285c425e0
|
||||
@@ -0,0 +1,193 @@
|
||||
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 _currentAuthServiceUrl;
|
||||
private string _currentEagleUrl;
|
||||
|
||||
// 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);
|
||||
|
||||
// Initialize TokenStorage cache from PlayerPrefs on main thread
|
||||
// This allows background threads to access tokens safely
|
||||
TokenStorage.InitializeCache();
|
||||
|
||||
// AuthClient is created lazily when SetServerUrls is called
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the server URLs for OAuth and game requests. Call this before any OAuth operations.
|
||||
/// </summary>
|
||||
/// <param name="authServiceUrl">Go auth service URL, e.g.
|
||||
/// "https://prod.eagle0.net:40033"</param> <param name="eagleUrl">Eagle server URL, e.g.
|
||||
/// "https://prod.eagle0.net:40032"</param>
|
||||
public void SetServerUrls(string authServiceUrl, string eagleUrl) {
|
||||
if (_currentAuthServiceUrl == authServiceUrl && _currentEagleUrl == eagleUrl &&
|
||||
_authClient != null) {
|
||||
return; // Already configured for these URLs
|
||||
}
|
||||
|
||||
_authClient?.Dispose();
|
||||
_currentAuthServiceUrl = authServiceUrl;
|
||||
_currentEagleUrl = eagleUrl;
|
||||
_authClient = new AuthClient(authServiceUrl, eagleUrl);
|
||||
Debug.Log($"[OAuthManager] Configured: authService={authServiceUrl}, eagle={eagleUrl}");
|
||||
}
|
||||
|
||||
private void OnDestroy() {
|
||||
_authClient?.Dispose();
|
||||
if (Instance == this) { Instance = null; }
|
||||
}
|
||||
|
||||
private void EnsureConfigured() {
|
||||
if (_authClient == null) {
|
||||
throw new InvalidOperationException(
|
||||
"OAuthManager not configured. Call SetServerUrls() 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 SetServerUrls() 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0e9d5b0cb9f26477eaf26e8a09c41707
|
||||
@@ -0,0 +1,176 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Auth {
|
||||
/// <summary>
|
||||
/// Secure storage for OAuth tokens using Unity's PlayerPrefs.
|
||||
/// Values are cached in memory to allow access from background threads.
|
||||
/// 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";
|
||||
|
||||
// In-memory cache for thread-safe access from background threads
|
||||
// PlayerPrefs can only be accessed from the main thread
|
||||
private static string _cachedAccessToken;
|
||||
private static string _cachedRefreshToken;
|
||||
private static long _cachedExpiresAt;
|
||||
private static string _cachedUserId;
|
||||
private static string _cachedDisplayName;
|
||||
private static bool _cacheInitialized = false;
|
||||
|
||||
/// <summary>
|
||||
/// Initialize cache from PlayerPrefs. Must be called from main thread.
|
||||
/// Call this early in app startup (e.g., in Awake or Start).
|
||||
/// </summary>
|
||||
public static void InitializeCache() {
|
||||
_cachedAccessToken = PlayerPrefs.GetString(AccessTokenKey, null);
|
||||
_cachedRefreshToken = PlayerPrefs.GetString(RefreshTokenKey, null);
|
||||
_cachedExpiresAt =
|
||||
long.TryParse(PlayerPrefs.GetString(ExpiresAtKey, "0"), out var val) ? val : 0;
|
||||
_cachedUserId = PlayerPrefs.GetString(UserIdKey, null);
|
||||
_cachedDisplayName = PlayerPrefs.GetString(DisplayNameKey, null);
|
||||
_cacheInitialized = true;
|
||||
|
||||
Debug.Log(
|
||||
$"[TokenStorage] Cache initialized, hasToken={!string.IsNullOrEmpty(_cachedAccessToken)}");
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!_cacheInitialized) {
|
||||
Debug.LogWarning(
|
||||
"[TokenStorage] Cache not initialized, returning cached value anyway");
|
||||
}
|
||||
return _cachedAccessToken;
|
||||
}
|
||||
private
|
||||
set {
|
||||
_cachedAccessToken = value;
|
||||
PlayerPrefs.SetString(AccessTokenKey, value ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
public static string RefreshToken {
|
||||
get => _cachedRefreshToken;
|
||||
private
|
||||
set {
|
||||
_cachedRefreshToken = value;
|
||||
PlayerPrefs.SetString(RefreshTokenKey, value ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
public static long ExpiresAt {
|
||||
get => _cachedExpiresAt;
|
||||
private
|
||||
set {
|
||||
_cachedExpiresAt = value;
|
||||
PlayerPrefs.SetString(ExpiresAtKey, value.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
public static string UserId {
|
||||
get => _cachedUserId;
|
||||
private
|
||||
set {
|
||||
_cachedUserId = value;
|
||||
PlayerPrefs.SetString(UserIdKey, value ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
public static string DisplayName {
|
||||
get => _cachedDisplayName;
|
||||
private
|
||||
set {
|
||||
_cachedDisplayName = value;
|
||||
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() {
|
||||
_cachedAccessToken = null;
|
||||
_cachedRefreshToken = null;
|
||||
_cachedExpiresAt = 0;
|
||||
_cachedUserId = null;
|
||||
_cachedDisplayName = null;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4b1f130cec656466893fa04ba789d34f
|
||||
+321
-13
@@ -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,20 +105,23 @@ 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_enableWordWrapping: 1
|
||||
m_TextWrappingMode: 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
|
||||
@@ -185,9 +188,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}
|
||||
@@ -261,20 +264,23 @@ 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_enableWordWrapping: 1
|
||||
m_TextWrappingMode: 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
|
||||
@@ -344,10 +350,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}
|
||||
@@ -425,6 +431,7 @@ MonoBehaviour:
|
||||
atime6: 0
|
||||
atime7: 0
|
||||
m_Mode: 0
|
||||
m_ColorSpace: -1
|
||||
m_NumColorKeys: 2
|
||||
m_NumAlphaKeys: 2
|
||||
_gradientType: 0
|
||||
@@ -564,9 +571,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}
|
||||
@@ -640,20 +647,23 @@ 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_enableWordWrapping: 1
|
||||
m_TextWrappingMode: 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
|
||||
@@ -701,13 +711,15 @@ 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}
|
||||
@@ -796,31 +808,295 @@ MonoBehaviour:
|
||||
playerCountField: {fileID: 8350902200036365361}
|
||||
joinButton: {fileID: 437043893514773017}
|
||||
buttonText: {fileID: 2170549136187147757}
|
||||
--- !u!114 &2580770409874757745
|
||||
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
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 8906549728612534621}
|
||||
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_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 700
|
||||
m_MinWidth: 50
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 700
|
||||
m_PreferredWidth: 50
|
||||
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,
|
||||
@@ -833,6 +1109,11 @@ 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
|
||||
@@ -959,6 +1240,13 @@ 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:
|
||||
@@ -984,3 +1272,23 @@ 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,8 +16,10 @@ 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;
|
||||
@@ -38,6 +40,8 @@ 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;
|
||||
|
||||
+317
-13
@@ -7,15 +7,18 @@ 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 {
|
||||
@@ -82,6 +85,23 @@ 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;
|
||||
|
||||
@@ -90,6 +110,11 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
public GameObject customBattlePanel;
|
||||
public ErrorHandler errorHandler;
|
||||
|
||||
[Header("Lobby Controls")]
|
||||
public Button logoutButton;
|
||||
public TextMeshProUGUI lobbyEnvironmentText;
|
||||
public TextMeshProUGUI lobbyUserText;
|
||||
|
||||
public GameObject runningGamesListArea;
|
||||
public GameObject runningGamesListItemPrefab;
|
||||
|
||||
@@ -98,6 +123,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
public GameObject waitingGamesListItemPrefab;
|
||||
public GameObject createGameListItemPrefab;
|
||||
|
||||
public DropGameConfirmationPanel dropGameConfirmationPanel;
|
||||
|
||||
public Canvas connectionCanvas;
|
||||
public Canvas eagleCanvas;
|
||||
public Canvas shardokCanvas;
|
||||
@@ -112,6 +139,8 @@ 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;
|
||||
|
||||
@@ -125,12 +154,33 @@ 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 (Eagle server).
|
||||
/// Remote servers use HTTPS, could be extended for local HTTP.
|
||||
/// </summary>
|
||||
private string GetEagleUrl() { return "https://" + GetUrlFromEnvironment(); }
|
||||
|
||||
/// <summary>
|
||||
/// Get full URL with scheme for the Go auth service.
|
||||
/// Auth service runs on port 40033.
|
||||
/// </summary>
|
||||
private string GetAuthServiceUrl() { return "https://" + GetUrlFromEnvironment() + ":40033"; }
|
||||
|
||||
public void ReceiveLobbyUpdate(LobbyResponse lobbyResponse) {
|
||||
_handleLobbyResponse(lobbyResponse);
|
||||
}
|
||||
@@ -166,8 +216,214 @@ 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 auth service and Eagle URLs
|
||||
OAuthManager.Instance.SetServerUrls(GetAuthServiceUrl(), GetEagleUrl());
|
||||
|
||||
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 auth service and Eagle URLs (user may have changed dropdown)
|
||||
OAuthManager.Instance.SetServerUrls(GetAuthServiceUrl(), GetEagleUrl());
|
||||
|
||||
if (oauthStatusText != null) {
|
||||
oauthStatusText.text = $"Opening browser for {provider} login...";
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -262,6 +518,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
_persistentClientConnection = null;
|
||||
eagleConnection?.Dispose();
|
||||
eagleConnection = null;
|
||||
|
||||
_connectedEnvironmentIndex = -1;
|
||||
}
|
||||
|
||||
public void OnResolutionChanged(int dropdownIndex) {
|
||||
@@ -289,6 +547,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
connectionPanel.gameObject.SetActive(false);
|
||||
gameSelectionPanel.gameObject.SetActive(true);
|
||||
|
||||
// Update lobby status displays
|
||||
UpdateLobbyStatusDisplays();
|
||||
|
||||
// Set up running games table
|
||||
foreach (Transform row in runningGamesListArea.transform) { Destroy(row.gameObject); }
|
||||
foreach (var runningGame in lobbyResponse.RunningGames) {
|
||||
@@ -297,10 +558,10 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
runningGamesListArea.transform,
|
||||
true);
|
||||
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
|
||||
listItem.GetComponent<RunningGameItem>().SetRunningGame(
|
||||
runningGame.GameId,
|
||||
runningGame.Leader);
|
||||
listItem.GetComponent<RunningGameItem>().GoCallback = this.SelectEagleGame;
|
||||
var runningGameItem = listItem.GetComponent<RunningGameItem>();
|
||||
runningGameItem.SetRunningGame(runningGame.GameId, runningGame.Leader);
|
||||
runningGameItem.GoCallback = this.SelectEagleGame;
|
||||
runningGameItem.DropCallback = this.DropGame;
|
||||
}
|
||||
|
||||
// Set up available/waiting games table
|
||||
@@ -336,14 +597,16 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
availableGamesListArea.transform,
|
||||
true);
|
||||
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
|
||||
listItem.GetComponent<AvailableGameItem>().SetAvailableNewGame(
|
||||
var availableGameItem = listItem.GetComponent<AvailableGameItem>();
|
||||
availableGameItem.SetAvailableNewGame(
|
||||
availableGame.GameId,
|
||||
string.Format(
|
||||
"{0} / {1}",
|
||||
availableGame.CurrentHumanPlayerCount,
|
||||
availableGame.MaxHumanPlayerCount),
|
||||
availableGame.AvailableLeaders.ToList());
|
||||
listItem.GetComponent<AvailableGameItem>().JoinCallback = this.JoinGame;
|
||||
availableGameItem.JoinCallback = this.JoinGame;
|
||||
availableGameItem.DropCallback = this.DropGame;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -357,7 +620,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
}
|
||||
|
||||
private void _createConnection() {
|
||||
PlayerPrefs.SetInt(EnvironmentKey, environmentDropdown.value);
|
||||
_connectedEnvironmentIndex = environmentDropdown.value;
|
||||
PlayerPrefs.SetInt(EnvironmentKey, _connectedEnvironmentIndex);
|
||||
PlayerPrefs.SetString(NameKey, nameField.text);
|
||||
PlayerPrefs.SetString(PasswordKey, passwordField.text);
|
||||
|
||||
@@ -371,7 +635,26 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
string url = GetUrlFromEnvironment();
|
||||
eagleConnection = new EagleConnection(nameField.text, passwordField.text, url);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
_persistentClientConnection = new PersistentClientConnection(
|
||||
eagleConnection.EagleGrpcClient,
|
||||
eagleConnection.credentials,
|
||||
@@ -379,10 +662,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -440,6 +719,18 @@ 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);
|
||||
}
|
||||
@@ -516,6 +807,18 @@ 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);
|
||||
@@ -533,7 +836,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
eagleCanvas.GetComponent<EagleGameController>().SetUpGame(
|
||||
gameInfo.GameId,
|
||||
gameInfo.FactionId,
|
||||
_persistentClientConnection);
|
||||
_persistentClientConnection,
|
||||
ConnectedEnvironmentName);
|
||||
connectionCanvas.enabled = false;
|
||||
connectionCanvas.gameObject.SetActive(false);
|
||||
eagleCanvas.gameObject.SetActive(true);
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
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
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 558d4c7321daa4fe18dc4759a130d919
|
||||
+304
-9
@@ -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,20 +105,23 @@ 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_enableWordWrapping: 1
|
||||
m_TextWrappingMode: 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
|
||||
@@ -185,9 +188,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}
|
||||
@@ -261,20 +264,23 @@ 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_enableWordWrapping: 1
|
||||
m_TextWrappingMode: 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
|
||||
@@ -312,6 +318,216 @@ 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
|
||||
@@ -340,9 +556,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}
|
||||
@@ -416,20 +632,23 @@ 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_enableWordWrapping: 1
|
||||
m_TextWrappingMode: 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
|
||||
@@ -479,10 +698,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}
|
||||
@@ -560,6 +779,7 @@ MonoBehaviour:
|
||||
atime6: 0
|
||||
atime7: 0
|
||||
m_Mode: 0
|
||||
m_ColorSpace: -1
|
||||
m_NumColorKeys: 2
|
||||
m_NumAlphaKeys: 2
|
||||
_gradientType: 0
|
||||
@@ -702,12 +922,14 @@ 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}
|
||||
@@ -793,7 +1015,8 @@ MonoBehaviour:
|
||||
item: {fileID: 5643565463360785033}
|
||||
gameIdField: {fileID: 8058295588038032232}
|
||||
leaderField: {fileID: 3311450659768657245}
|
||||
goButton: {fileID: 0}
|
||||
goButton: {fileID: 145214844678457394}
|
||||
dropButton: {fileID: 4081637582368108930}
|
||||
--- !u!114 &2208043217657811503
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -814,3 +1037,75 @@ 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,8 +10,10 @@ 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;
|
||||
|
||||
@@ -20,6 +22,8 @@ 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;
|
||||
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
-88
@@ -1,88 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b853582537dc740aabe996b607191fe8
|
||||
TextureImporter:
|
||||
fileIDToRecycleName: {}
|
||||
externalObjects: {}
|
||||
serializedVersion: 9
|
||||
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
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: -1
|
||||
aniso: -1
|
||||
mipBias: -100
|
||||
wrapU: -1
|
||||
wrapV: -1
|
||||
wrapW: -1
|
||||
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
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 2
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
spritePackingTag:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+3
@@ -148,6 +148,9 @@ namespace eagle {
|
||||
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;
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ 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;
|
||||
@@ -64,6 +68,15 @@ 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; }
|
||||
|
||||
@@ -165,5 +178,20 @@ 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.
+15
-21
@@ -1,9 +1,9 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aa2fcd8d8f12d4218aa9ee793adfe434
|
||||
guid: 1fb1f6deb68f9475281c9f7c427a7024
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 12
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 1
|
||||
@@ -20,11 +20,12 @@ TextureImporter:
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMasterTextureLimit: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
@@ -62,10 +63,11 @@ TextureImporter:
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 1
|
||||
cookieLightType: 1
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 3
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
@@ -75,9 +77,10 @@ TextureImporter:
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
@@ -87,24 +90,14 @@ TextureImporter:
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Server
|
||||
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:
|
||||
@@ -114,10 +107,11 @@ TextureImporter:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
spritePackingTag:
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -243,7 +243,8 @@ namespace eagle {
|
||||
public void SetUpGame(
|
||||
long gameId,
|
||||
int? playerId,
|
||||
PersistentClientConnection persistentClientConnection) {
|
||||
PersistentClientConnection persistentClientConnection,
|
||||
string environmentName = null) {
|
||||
ModelUpdater = new GameModelUpdater(
|
||||
gameId,
|
||||
playerId,
|
||||
@@ -269,9 +270,12 @@ namespace eagle {
|
||||
|
||||
ModelUpdater.ErrorHandler = errorHandler;
|
||||
|
||||
// Set up game state provider for connection status UI
|
||||
// Set up game state provider and environment for connection status UI
|
||||
var statusUI = connectionStatusLabel.GetComponent<ConnectionStatusUI>();
|
||||
if (statusUI != null) { statusUI.SetGameStateProvider(ModelUpdater); }
|
||||
if (statusUI != null) {
|
||||
statusUI.SetGameStateProvider(ModelUpdater);
|
||||
statusUI.SetEnvironment(environmentName);
|
||||
}
|
||||
|
||||
// Fire-and-forget - subscription is awaited internally and failures are logged
|
||||
MainQueue.Q.EnqueueForNextUpdate(
|
||||
|
||||
@@ -514,13 +514,22 @@ 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 not in ShardokGameModels, create models and mark for
|
||||
// resync. This ensures fresh clients get Shardok state for ongoing battles.
|
||||
// For any outstanding battles in the new state, create models and mark for resync.
|
||||
bool needsResubscribe = false;
|
||||
foreach (var battle in _currentModel.ShardokBattles) {
|
||||
if (!_currentModel.ShardokGameModels.ContainsKey(battle.ShardokGameId)) {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e661753a1e43a4f2da81b76457cf55c8
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
LFS
BIN
Binary file not shown.
+29
-35
@@ -1,9 +1,9 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a98ea09ca5b31411688f7dd794365af8
|
||||
guid: 8d524cd6fa08d47c99b2462c2686b8d0
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 10
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 1
|
||||
@@ -20,9 +20,12 @@ TextureImporter:
|
||||
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
|
||||
@@ -31,13 +34,13 @@ TextureImporter:
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: -1
|
||||
aniso: -1
|
||||
mipBias: -100
|
||||
wrapU: -1
|
||||
wrapV: -1
|
||||
wrapW: -1
|
||||
nPOTScale: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 0
|
||||
wrapV: 0
|
||||
wrapW: 0
|
||||
nPOTScale: 1
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 0
|
||||
@@ -54,11 +57,17 @@ TextureImporter:
|
||||
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: 2
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
@@ -68,8 +77,10 @@ TextureImporter:
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
- serializedVersion: 2
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
@@ -79,33 +90,14 @@ TextureImporter:
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
- serializedVersion: 2
|
||||
buildTarget: iPhone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
- serializedVersion: 2
|
||||
buildTarget: Android
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
@@ -115,9 +107,11 @@ TextureImporter:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spritePackingTag:
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
LFS
BIN
Binary file not shown.
+29
-35
@@ -1,9 +1,9 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c38f017a9228a44819a59ece3ccb2009
|
||||
guid: 1e69ea88b87d34a48bbb45a1fbad81fc
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 10
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 1
|
||||
@@ -20,9 +20,12 @@ TextureImporter:
|
||||
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
|
||||
@@ -31,12 +34,12 @@ TextureImporter:
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: -1
|
||||
aniso: -1
|
||||
mipBias: -100
|
||||
wrapU: -1
|
||||
wrapV: -1
|
||||
wrapW: -1
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 0
|
||||
wrapV: 0
|
||||
wrapW: 0
|
||||
nPOTScale: 1
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
@@ -49,16 +52,22 @@ TextureImporter:
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 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: 2
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
@@ -68,8 +77,10 @@ TextureImporter:
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
- serializedVersion: 2
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
@@ -79,33 +90,14 @@ TextureImporter:
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
- serializedVersion: 2
|
||||
buildTarget: iPhone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
- serializedVersion: 2
|
||||
buildTarget: Android
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
@@ -115,9 +107,11 @@ TextureImporter:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spritePackingTag:
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 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.
+38
-11
@@ -1,9 +1,9 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fc00df0d402d141999c4b8bf71901287
|
||||
guid: 1850a7332b6e84e1a9b69f5f5b4cf482
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 10
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 1
|
||||
@@ -20,9 +20,12 @@ TextureImporter:
|
||||
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
|
||||
@@ -31,12 +34,12 @@ TextureImporter:
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: -1
|
||||
aniso: -1
|
||||
mipBias: -100
|
||||
wrapU: -1
|
||||
wrapV: -1
|
||||
wrapW: -1
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 0
|
||||
wrapV: 0
|
||||
wrapW: 0
|
||||
nPOTScale: 1
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
@@ -54,11 +57,17 @@ TextureImporter:
|
||||
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: 2
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
@@ -68,11 +77,27 @@ TextureImporter:
|
||||
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:
|
||||
@@ -82,9 +107,11 @@ TextureImporter:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spritePackingTag:
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+45
-16
@@ -981,54 +981,69 @@ namespace eagle {
|
||||
HandleGameUpdate(current.GameUpdate, receivedTime);
|
||||
break;
|
||||
|
||||
case UpdateStreamResponse.ResponseDetailsOneofCase.LobbyResponse:
|
||||
if (_lobbySubscriber != null) {
|
||||
case UpdateStreamResponse.ResponseDetailsOneofCase.LobbyResponse: {
|
||||
// Capture subscriber reference to avoid race condition - if
|
||||
// subscriber changes or is cleared during dispose, we deliver to
|
||||
// whoever was subscribed when the message arrived
|
||||
var lobbySubscriber = _lobbySubscriber;
|
||||
if (lobbySubscriber != null) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
_lobbySubscriber.ReceiveLobbyUpdate(current.LobbyResponse);
|
||||
lobbySubscriber.ReceiveLobbyUpdate(current.LobbyResponse);
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case UpdateStreamResponse.ResponseDetailsOneofCase
|
||||
.PregeneratedTextResponse:
|
||||
if (_lobbySubscriber != null) {
|
||||
.PregeneratedTextResponse: {
|
||||
var lobbySubscriber = _lobbySubscriber;
|
||||
if (lobbySubscriber != null) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
_lobbySubscriber.ReceivePregeneratedTextUpdate(
|
||||
lobbySubscriber.ReceivePregeneratedTextUpdate(
|
||||
current.PregeneratedTextResponse);
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case UpdateStreamResponse.ResponseDetailsOneofCase.JoinGameResponse:
|
||||
if (_lobbySubscriber != null) {
|
||||
case UpdateStreamResponse.ResponseDetailsOneofCase.JoinGameResponse: {
|
||||
var lobbySubscriber = _lobbySubscriber;
|
||||
if (lobbySubscriber != null) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
_lobbySubscriber.HandleJoinGameResponse(
|
||||
lobbySubscriber.HandleJoinGameResponse(
|
||||
current.JoinGameResponse);
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case UpdateStreamResponse.ResponseDetailsOneofCase.CreateGameResponse:
|
||||
if (_lobbySubscriber != null) {
|
||||
case UpdateStreamResponse.ResponseDetailsOneofCase.CreateGameResponse: {
|
||||
var lobbySubscriber = _lobbySubscriber;
|
||||
if (lobbySubscriber != null) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
_lobbySubscriber.HandleCreateGameResponse(
|
||||
lobbySubscriber.HandleCreateGameResponse(
|
||||
current.CreateGameResponse);
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
case UpdateStreamResponse.ResponseDetailsOneofCase.CustomBattleResponse:
|
||||
if (_lobbySubscriber != null) {
|
||||
}
|
||||
|
||||
case UpdateStreamResponse.ResponseDetailsOneofCase
|
||||
.CustomBattleResponse: {
|
||||
var lobbySubscriber = _lobbySubscriber;
|
||||
if (lobbySubscriber != null) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
_lobbySubscriber.HandleCustomBattleResponse(
|
||||
lobbySubscriber.HandleCustomBattleResponse(
|
||||
current.CustomBattleResponse);
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case UpdateStreamResponse.ResponseDetailsOneofCase.HexMapResponse:
|
||||
var hexMapResponse = current.HexMapResponse;
|
||||
@@ -1072,7 +1087,21 @@ namespace eagle {
|
||||
var tokenCancelled = _currentThreadToken.IsCancellationRequested;
|
||||
LogFlow($"HandleStreamingCall ENDED normally: sc_null={scNull} token_cancelled={tokenCancelled}");
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
"How did we get here? This is not my beautiful wife!");
|
||||
$"Stream ended normally: sc_null={scNull} token_cancelled={tokenCancelled}");
|
||||
|
||||
// Only schedule reconnect if:
|
||||
// 1. App is not shutting down (main token not cancelled)
|
||||
// 2. Thread token was NOT cancelled (if it was, something else like
|
||||
// SyncMismatch already disposed the call and scheduled reconnect)
|
||||
if (!_cancellationToken.IsCancellationRequested && !tokenCancelled) {
|
||||
lock (this) {
|
||||
_lastDisconnect = DateTime.UtcNow;
|
||||
MarkAllShardokGamesForResync();
|
||||
CancelAllPendingSubscriptionAcks();
|
||||
}
|
||||
LogConnectionEvent("disconnect", "StreamEndedNormally");
|
||||
ScheduleReconnect("StreamEndedNormally");
|
||||
}
|
||||
} catch (RpcException e) {
|
||||
lock (this) {
|
||||
_lastDisconnect = DateTime.UtcNow;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Auth;
|
||||
using Cysharp.Net.Http;
|
||||
using eagle;
|
||||
using Grpc.Core;
|
||||
@@ -57,6 +58,25 @@ 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 } };
|
||||
@@ -68,6 +88,26 @@ 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
BIN
Binary file not shown.
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 87 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
@@ -13,9 +13,6 @@ public class MainQueue : MonoBehaviour {
|
||||
// 8ms leaves room for rendering within a 16ms (60fps) frame budget
|
||||
private const long MaxMillisecondsPerFrame = 8;
|
||||
|
||||
// Track queue depth for logging
|
||||
private int _lastLoggedQueueDepth = 0;
|
||||
|
||||
private MainQueue() {}
|
||||
|
||||
void Awake() {
|
||||
@@ -38,14 +35,6 @@ public class MainQueue : MonoBehaviour {
|
||||
return;
|
||||
}
|
||||
|
||||
// Log when queue has built up (e.g., after resuming from background)
|
||||
if (queueDepthBefore > 100 && queueDepthBefore != _lastLoggedQueueDepth) {
|
||||
Debug.Log($"[MainQueue] Processing backlog: {queueDepthBefore} actions queued");
|
||||
_lastLoggedQueueDepth = queueDepthBefore;
|
||||
} else if (queueDepthBefore <= 100) {
|
||||
_lastLoggedQueueDepth = 0;
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
Action possibleAction;
|
||||
do {
|
||||
|
||||
@@ -413,6 +413,7 @@ public class HexGrid : MonoBehaviour {
|
||||
}
|
||||
|
||||
public void SetUnitInfoLabels(int cellIndex, string mainValue, string secondaryValue = null) {
|
||||
if (cells == null) return;
|
||||
cells[cellIndex].UpperUnitInfoLabel.text = mainValue;
|
||||
cells[cellIndex].LowerUnitInfoLabel.text = secondaryValue;
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -25,10 +25,9 @@ namespace common {
|
||||
// Client that doesn't follow redirects, so we can retry each hop independently
|
||||
private readonly HttpClient _noRedirectClient;
|
||||
|
||||
// Auth header to send only to eagle0.net (not to S3 signed URLs)
|
||||
private readonly System.Net.Http.Headers.AuthenticationHeaderValue _authHeader;
|
||||
|
||||
private static readonly Uri BaseUri = new("https://eagle0.net/assets/headshots/");
|
||||
// Public CDN URL for headshots (no auth required)
|
||||
private static readonly Uri BaseUri =
|
||||
new("https://eagle0-headshots.sfo3.cdn.digitaloceanspaces.com/");
|
||||
|
||||
private const int MaxRetryAttempts = 5;
|
||||
private const int MaxRedirectHops = 5;
|
||||
@@ -46,6 +45,10 @@ 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) {
|
||||
@@ -69,9 +72,6 @@ namespace common {
|
||||
};
|
||||
_noRedirectClient = new HttpClient(
|
||||
handler) { Timeout = TimeSpan.FromSeconds(RequestTimeoutSeconds) };
|
||||
// Store auth header to add per-request only for eagle0.net
|
||||
// (sending it to S3 signed URLs causes HTTP 400)
|
||||
_authHeader = httpClient.DefaultRequestHeaders.Authorization;
|
||||
|
||||
// Start periodic retry timer
|
||||
_periodicRetryTimer = new Timer(
|
||||
@@ -112,32 +112,45 @@ namespace common {
|
||||
}
|
||||
|
||||
private async Task<bool> FetchRemote(string path) {
|
||||
Uri currentUri = new Uri(BaseUri, path);
|
||||
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (result.RedirectUri != null) {
|
||||
// Follow the redirect
|
||||
currentUri = result.RedirectUri;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Failed and not a redirect
|
||||
MarkFailed(path);
|
||||
return false;
|
||||
_inFlightPaths.Add(path);
|
||||
}
|
||||
|
||||
// Too many redirects
|
||||
Debug.Log($"ResourceFetcher: Too many redirects for {path}");
|
||||
MarkFailed(path);
|
||||
return false;
|
||||
try {
|
||||
Uri currentUri = new Uri(BaseUri, path);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Too many redirects
|
||||
Debug.Log($"ResourceFetcher: Too many redirects for {path}");
|
||||
MarkFailed(path);
|
||||
return false;
|
||||
} finally {
|
||||
lock (_inFlightLock) { _inFlightPaths.Remove(path); }
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch a single hop with retries. Returns content, redirect, or failure.
|
||||
@@ -146,10 +159,6 @@ namespace common {
|
||||
try {
|
||||
var request =
|
||||
new HttpRequestMessage { Method = HttpMethod.Get, RequestUri = uri };
|
||||
// Only send auth header to eagle0.net, not to S3 signed URLs
|
||||
if (_authHeader != null && uri.Host.EndsWith("eagle0.net")) {
|
||||
request.Headers.Authorization = _authHeader;
|
||||
}
|
||||
var response = await _noRedirectClient.SendAsync(request);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
@@ -190,9 +199,9 @@ namespace common {
|
||||
if (attempt < MaxRetryAttempts - 1) {
|
||||
await Task.Delay(RetryDelaysMs[attempt]);
|
||||
}
|
||||
} catch (TaskCanceledException e) {
|
||||
} catch (OperationCanceledException e) {
|
||||
Debug.Log(
|
||||
$"ResourceFetcher: hop {hopIndex} timeout for {path} (attempt {attempt + 1}/{MaxRetryAttempts}): {e.Message}");
|
||||
$"ResourceFetcher: hop {hopIndex} canceled for {path} (attempt {attempt + 1}/{MaxRetryAttempts}): {e.Message}");
|
||||
if (attempt < MaxRetryAttempts - 1) {
|
||||
await Task.Delay(RetryDelaysMs[attempt]);
|
||||
}
|
||||
@@ -300,10 +309,13 @@ namespace common {
|
||||
public void Prefetch(IEnumerable<string> paths) {
|
||||
List<string> toFetch;
|
||||
lock (_failedPathsLock) {
|
||||
toFetch = paths.Where(path => !string.IsNullOrEmpty(path) &&
|
||||
!File.Exists(FullLocalPath(path)) &&
|
||||
!_failedPaths.Contains(path))
|
||||
.ToList();
|
||||
lock (_inFlightLock) {
|
||||
toFetch = paths.Where(path => !string.IsNullOrEmpty(path) &&
|
||||
!File.Exists(FullLocalPath(path)) &&
|
||||
!_failedPaths.Contains(path) &&
|
||||
!_inFlightPaths.Contains(path))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var path in toFetch) { FetchRemote(path); }
|
||||
|
||||
@@ -253,5 +253,8 @@
|
||||
<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>
|
||||
|
||||
@@ -11,8 +11,10 @@ go_library(
|
||||
visibility = ["//visibility:private"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:api_go_proto", # keep
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/admin:admin_go_proto",
|
||||
"@org_golang_google_grpc//:grpc",
|
||||
"@org_golang_google_grpc//credentials/insecure",
|
||||
"@org_golang_google_grpc//metadata",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -21,6 +23,10 @@ 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
|
||||
@@ -31,4 +37,8 @@ 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
@@ -308,6 +308,14 @@ nav .brand {
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions-cell button {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.settings-note {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
@@ -324,3 +332,290 @@ nav .brand {
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Upload section */
|
||||
.upload-section {
|
||||
margin-bottom: 1.5rem;
|
||||
position: relative;
|
||||
z-index: auto;
|
||||
}
|
||||
|
||||
.upload-section summary {
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--pico-card-background-color);
|
||||
border: 1px solid var(--pico-muted-border-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
color: var(--pico-color);
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.upload-section summary:hover {
|
||||
background: var(--pico-secondary-hover-background);
|
||||
}
|
||||
|
||||
.upload-section .upload-form {
|
||||
padding: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
background: var(--pico-card-background-color);
|
||||
border: 1px solid var(--pico-muted-border-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
}
|
||||
|
||||
.upload-section .upload-form p {
|
||||
font-size: 0.9rem;
|
||||
color: var(--pico-muted-color);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.upload-section .upload-controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.upload-section .upload-controls input[type="file"] {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upload-section .upload-controls button {
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
width: auto !important;
|
||||
min-width: auto;
|
||||
padding: 0.5rem 1rem;
|
||||
position: static;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<tr class="action-detail-row">
|
||||
<td colspan="3">
|
||||
<td colspan="6">
|
||||
<div class="action-detail">
|
||||
<div class="action-detail-header">
|
||||
<strong>Action #{{.Index}}</strong> - {{.ActionType}} (Round {{.RoundId}})
|
||||
|
||||
@@ -10,29 +10,174 @@
|
||||
<span class="status-badge {{if eq .Game.RunStatus "Running"}}running{{else}}finished{{end}}">
|
||||
{{.Game.RunStatus}}
|
||||
</span>
|
||||
<a href="/games/{{.Game.GameID}}/download" class="btn-secondary" style="margin-left: 1rem;">Download Save</a>
|
||||
</div>
|
||||
|
||||
<article>
|
||||
<header>
|
||||
<strong>Round {{.Game.CurrentRound}}</strong> • {{.Game.ActionCount}} total actions
|
||||
</header>
|
||||
<div class="players">
|
||||
{{range .Game.Players}}
|
||||
<span class="player-badge {{if .IsHuman}}human{{else}}ai{{end}}">
|
||||
{{.LeaderName}} - {{.FactionName}}{{if .IsHuman}} ({{.UserName}}){{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</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: 80px">#</th>
|
||||
<th style="width: 60px">#</th>
|
||||
<th>Action Type</th>
|
||||
<th style="width: 120px">Round</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"
|
||||
@@ -40,7 +185,7 @@
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<tr>
|
||||
<td colspan="3" class="htmx-indicator">Loading history...</td>
|
||||
<td colspan="6" class="htmx-indicator">Loading history...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<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>
|
||||
@@ -4,6 +4,17 @@
|
||||
<span>{{len .Games}} game{{if ne (len .Games) 1}}s{{end}}</span>
|
||||
</div>
|
||||
|
||||
<details class="upload-section">
|
||||
<summary>Upload Game Save</summary>
|
||||
<form action="/games/upload" method="POST" enctype="multipart/form-data" class="upload-form">
|
||||
<p>Upload a game save zip file (e.g., game_abc123.zip). The game ID is extracted from the filename.</p>
|
||||
<div class="upload-controls">
|
||||
<input type="file" name="gamefile" accept=".zip" required>
|
||||
<button type="submit" class="btn-small">Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{{if .Games}}
|
||||
{{range .Games}}
|
||||
<article class="game-card">
|
||||
|
||||
@@ -1,23 +1,58 @@
|
||||
{{range .Entries}}
|
||||
<tr class="action-row"
|
||||
hx-get="/games/{{$.GameID}}/action/{{.Index}}"
|
||||
hx-trigger="click"
|
||||
hx-target="this"
|
||||
hx-swap="afterend">
|
||||
<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="3" class="htmx-indicator">Loading more...</td>
|
||||
<td colspan="6" class="htmx-indicator">Loading more...</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{if and (not .Entries) (not .HasMore)}}
|
||||
<tr>
|
||||
<td colspan="3" class="empty-state">No actions recorded yet.</td>
|
||||
<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>
|
||||
|
||||
@@ -11,14 +11,59 @@
|
||||
<body>
|
||||
<nav>
|
||||
<ul>
|
||||
<li class="brand">Eagle Admin</li>
|
||||
<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="/users">Users</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>
|
||||
<li><a href="/logout" class="logout-link">Logout</a></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>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
{{define "content"}}
|
||||
<div class="login-container">
|
||||
<article class="login-card">
|
||||
<header>
|
||||
<h2>Admin Login</h2>
|
||||
<p>Sign in with your admin account to manage users.</p>
|
||||
</header>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<div class="login-buttons">
|
||||
<a href="/login/start?provider=discord" class="login-btn discord">
|
||||
<svg width="24" height="24" viewBox="0 0 71 55" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M60.1045 4.8978C55.5792 2.8214 50.7265 1.2916 45.6527 0.41542C45.5603 0.39851 45.468 0.440769 45.4204 0.525289C44.7963 1.6353 44.105 3.0834 43.6209 4.2216C38.1637 3.4046 32.7345 3.4046 27.3892 4.2216C26.905 3.0581 26.1886 1.6353 25.5617 0.525289C25.5141 0.443589 25.4218 0.40133 25.3294 0.41542C20.2584 1.2888 15.4057 2.8186 10.8776 4.8978C10.8384 4.9147 10.8048 4.9429 10.7825 4.9795C1.57795 18.7309 -0.943561 32.1443 0.293408 45.3914C0.299005 45.4562 0.335386 45.5182 0.385761 45.5576C6.45866 50.0174 12.3413 52.7249 18.1147 54.5195C18.2071 54.5477 18.305 54.5139 18.3638 54.4378C19.7295 52.5728 20.9469 50.6063 21.9907 48.5383C22.0523 48.4172 21.9935 48.2735 21.8676 48.2256C19.9366 47.4931 18.0979 46.6 16.3292 45.5858C16.1893 45.5041 16.1781 45.304 16.3068 45.2082C16.679 44.9293 17.0513 44.6391 17.4067 44.3461C17.471 44.2926 17.5606 44.2813 17.6362 44.3151C29.2558 49.6202 41.8354 49.6202 53.3179 44.3151C53.3## 44.2785 53.4831 44.2898 53.5502 44.3433C53.9057 44.6363 54.2779 44.9293 54.6529 45.2082C54.7816 45.304 54.7732 45.5041 54.6333 45.5858C52.8646 46.6197 51.0259 47.4931 49.0921 48.2228C48.9662 48.2707 48.9102 48.4172 48.9718 48.5383C50.0385 50.6034 51.2## 52.5699 52.5765 54.435C52.6324 54.5139 52.7331 54.5477 52.8256 54.5195C58.6268 52.7249 64.5095 50.0174 70.5824 45.5576C70.6355 45.5182 70.6691 45.459 70.6747 45.3942C72.1971 29.9792 68.1756 16.6886 60.1998 4.9823C60.1802 4.9429 60.1466 4.9147 60.1045 4.8978ZM23.7259 37.3253C20.2276 37.3253 17.3451 34.1136 17.3451 30.1693C17.3451 26.225 20.1717 23.0133 23.7259 23.0133C27.308 23.0133 30.1626 26.2532 30.1066 30.1693C30.1066 34.1136 27.28 37.3253 23.7259 37.3253ZM47.3178 37.3253C43.8196 37.3253 40.9371 34.1136 40.9371 30.1693C40.9371 26.225 43.7636 23.0133 47.3178 23.0133C50.8999 23.0133 53.7545 26.2532 53.6986 30.1693C53.6986 34.1136 50.8999 37.3253 47.3178 37.3253Z" fill="currentColor"/>
|
||||
</svg>
|
||||
Sign in with Discord
|
||||
</a>
|
||||
<a href="/login/start?provider=google" class="login-btn google">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p class="note">After signing in with your OAuth provider, return here and go to <a href="/login/complete">/login/complete</a> to finish the login process.</p>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-card header {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.login-card header h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-card header p {
|
||||
color: var(--pico-muted-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.login-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: transform 0.1s, box-shadow 0.1s;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.login-btn.discord {
|
||||
background-color: #5865F2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.login-btn.discord:hover {
|
||||
background-color: #4752C4;
|
||||
}
|
||||
|
||||
.login-btn.google {
|
||||
background-color: white;
|
||||
color: #333;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.login-btn.google:hover {
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
|
||||
.login-card footer {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: 0.85rem;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user