mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 12:35:41 +00:00
Compare commits
225
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12d1e48b07 | ||
|
|
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 | ||
|
|
580273174b | ||
|
|
b1fc38343d | ||
|
|
8a981710a8 | ||
|
|
bb111463f1 | ||
|
|
800ac22ff6 | ||
|
|
152a3b2388 | ||
|
|
39e0b9bbf8 | ||
|
|
df711b26eb | ||
|
|
29c7ad7c58 | ||
|
|
89bc355047 | ||
|
|
9c7c929b0d | ||
|
|
8d444c0153 | ||
|
|
85439d7000 | ||
|
|
7dd9010a74 | ||
|
|
cf2caaa992 | ||
|
|
18697f0a38 | ||
|
|
d9f2c1c216 | ||
|
|
91e5798478 | ||
|
|
980b42c94b | ||
|
|
180c1e9429 | ||
|
|
da29d394c5 | ||
|
|
3b1075069f | ||
|
|
85c8f9912b | ||
|
|
097870cfd0 | ||
|
|
fa02919503 | ||
|
|
4f8668974b | ||
|
|
3b0a85425c | ||
|
|
3f7b6e7d3c | ||
|
|
5072bd6f57 | ||
|
|
334c63d1de | ||
|
|
6767e5d838 | ||
|
|
caa863dbbf | ||
|
|
fc291c795e | ||
|
|
7081210aa5 | ||
|
|
3bff280712 | ||
|
|
78b8b83f79 | ||
|
|
28fdcc4672 | ||
|
|
53314e7cb6 | ||
|
|
d4a5f48103 | ||
|
|
d6d1868fcb | ||
|
|
8c9d38c546 | ||
|
|
139494d937 | ||
|
|
5c52dd9006 | ||
|
|
d473e5a5d6 | ||
|
|
e9b980e6d0 | ||
|
|
8ba4268832 | ||
|
|
3a033ddf19 | ||
|
|
61196d05e7 | ||
|
|
e5a71bd3b4 | ||
|
|
e545d9c616 | ||
|
|
17e5135915 | ||
|
|
5c9623717d | ||
|
|
2ed1c5769e | ||
|
|
7a3b382623 | ||
|
|
d1e4ba4efe | ||
|
|
03c26b01c9 | ||
|
|
830c524c53 | ||
|
|
c348085439 | ||
|
|
b0f5f05362 | ||
|
|
92b41c9b36 | ||
|
|
5c83fa479d | ||
|
|
831b72b382 | ||
|
|
d3c1542eaa | ||
|
|
c7b5e2db0d | ||
|
|
9ac664659f | ||
|
|
4c37d33682 | ||
|
|
71c7d700a5 | ||
|
|
ab485919ed | ||
|
|
8725ccfb34 | ||
|
|
73372d82bf | ||
|
|
f21839df81 | ||
|
|
fc1d5aa7ad | ||
|
|
c7cacb4915 | ||
|
|
d26a2322e3 | ||
|
|
c9d252093a | ||
|
|
7c31faaee0 | ||
|
|
3fab4b94f1 | ||
|
|
0ff8a3a8e1 | ||
|
|
ea70bbb2ac | ||
|
|
8e14f4cdbb | ||
|
|
6ffbbe242e | ||
|
|
84108b995a | ||
|
|
e4adac2eed | ||
|
|
3b83abe4c6 | ||
|
|
355c06d342 | ||
|
|
dfd1da8b8a | ||
|
|
ded08187b2 | ||
|
|
8747682842 | ||
|
|
ac5fa9a495 | ||
|
|
d65e486f36 | ||
|
|
9b719cafa8 | ||
|
|
87218d7a72 | ||
|
|
f7a0d562fe | ||
|
|
01ffbb8234 | ||
|
|
aa4f58c0c4 | ||
|
|
61c2d92b01 | ||
|
|
08dedebf7d | ||
|
|
c673f57179 | ||
|
|
29ec88c6e2 | ||
|
|
d50de9da3f | ||
|
|
9340500b22 | ||
|
|
1433f3d29c | ||
|
|
331702bf3c | ||
|
|
fbb04270a5 | ||
|
|
b8599d9088 | ||
|
|
d7e29eeb89 | ||
|
|
6f6c205f11 | ||
|
|
15e5d82504 | ||
|
|
b7ff76a38e | ||
|
|
e0d5a9f2ec | ||
|
|
bf02de3190 | ||
|
|
cbc14e5131 | ||
|
|
9974f32ebb | ||
|
|
ee6189a9d7 | ||
|
|
e0304125b8 | ||
|
|
11eac42e30 | ||
|
|
d0a63f1c61 | ||
|
|
97df984189 | ||
|
|
6c771df84f | ||
|
|
7e40420fe1 | ||
|
|
2ed6ad3c4c | ||
|
|
844e2b0407 | ||
|
|
1617867c60 | ||
|
|
7c49746c37 | ||
|
|
711c6606bb | ||
|
|
e142da7c57 | ||
|
|
c8906b1c04 | ||
|
|
1c8f328c58 | ||
|
|
7ebca60863 | ||
|
|
ce22b59a8a | ||
|
|
1fe062baaa | ||
|
|
6b2ab53574 | ||
|
|
6208d2cf10 | ||
|
|
e105461692 | ||
|
|
2e03352dea | ||
|
|
8c19a93f3c | ||
|
|
9b2bce6537 | ||
|
|
ee4914dcc8 | ||
|
|
1e019c533a | ||
|
|
3d092e580f | ||
|
|
85e530c5a4 | ||
|
|
486960a6aa | ||
|
|
1cb2dd7b6a | ||
|
|
5483c732cc | ||
|
|
6402a8c283 | ||
|
|
eb762e1bae | ||
|
|
be31464e99 | ||
|
|
e6f9d4e4ac | ||
|
|
4d3b2ddb36 | ||
|
|
6edb4de0dc | ||
|
|
72a0f84105 | ||
|
|
f73798ae6e | ||
|
|
19691682e0 | ||
|
|
0e51bece68 | ||
|
|
a89f740b3b | ||
|
|
7959da0a5a | ||
|
|
bc119b2aab | ||
|
|
25c7788254 | ||
|
|
1246f8bcf6 | ||
|
|
f3e44fb9cf | ||
|
|
50aa61b77c | ||
|
|
facfcf9ac9 | ||
|
|
4c21368f96 | ||
|
|
7eccd69a01 |
@@ -26,8 +26,8 @@ common --host_cxxopt="--std=c++23"
|
||||
common --javacopt="-Xlint:-options"
|
||||
|
||||
# suppress warnings due to https://developer.apple.com/forums/thread/733317
|
||||
common --linkopt=-Wl
|
||||
common:macos --linkopt=-Wl,-no_warn_duplicate_libraries
|
||||
# Use host_linkopt for macOS-specific flags to avoid passing them to Linux cross-compilation
|
||||
common:macos --host_linkopt=-Wl,-no_warn_duplicate_libraries
|
||||
|
||||
# Fix Xcode version caching issue - avoids need for `bazel clean --expunge` after Xcode updates
|
||||
common:macos --repo_env=DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
@@ -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
|
||||
|
||||
@@ -6,4 +6,7 @@
|
||||
*.bytes filter=lfs diff=lfs merge=lfs -text
|
||||
*.psd filter=lfs diff=lfs merge=lfs -text
|
||||
*.ttf filter=lfs diff=lfs merge=lfs -text
|
||||
# Exclude pre-existing font files that were committed as blobs (not LFS pointers)
|
||||
src/main/csharp/**/GUI[[:space:]]Pro[[:space:]]Kit*/**/*.ttf !filter !diff !merge
|
||||
src/main/csharp/**/Modern[[:space:]]UI[[:space:]]Pack/**/*.ttf !filter !diff !merge
|
||||
*.herodata filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Build Linux Sysroot
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Sysroot version (e.g., v2, v3)'
|
||||
required: true
|
||||
default: 'v2'
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-sysroot:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build sysroot
|
||||
run: ./tools/sysroot/build_sysroot.sh
|
||||
|
||||
- name: Upload sysroot artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ubuntu-noble-sysroot
|
||||
path: tools/sysroot/output/
|
||||
|
||||
- name: Install AWS CLI
|
||||
run: |
|
||||
if ! command -v aws &> /dev/null; then
|
||||
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
|
||||
unzip -q awscliv2.zip
|
||||
sudo ./aws/install
|
||||
fi
|
||||
|
||||
- name: Upload to DigitalOcean Spaces
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: |
|
||||
# Upload sysroot tarball to DO Spaces (using eagle0-windows bucket, same as other workflows)
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
# Upload sha256 file
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256 \
|
||||
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
echo ""
|
||||
echo "=== Sysroot uploaded ==="
|
||||
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
|
||||
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)"
|
||||
echo ""
|
||||
echo "Update MODULE.bazel with:"
|
||||
echo "sysroot("
|
||||
echo " name = \"linux_sysroot\","
|
||||
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)\","
|
||||
echo " urls = [\"https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
|
||||
echo ")"
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Deploy OAuth Relay Worker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'cloudflare/oauth-relay/**'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy to Cloudflare Workers
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy Worker
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
workingDirectory: cloudflare/oauth-relay
|
||||
@@ -0,0 +1,545 @@
|
||||
name: Docker Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/main/cpp/**'
|
||||
- 'src/main/go/**'
|
||||
- 'src/main/scala/**'
|
||||
- 'src/main/protobuf/**'
|
||||
- 'src/main/resources/**'
|
||||
- 'ci/BUILD.bazel'
|
||||
- 'MODULE.bazel'
|
||||
- 'docker-compose.prod.yml'
|
||||
- 'nginx/**'
|
||||
- '.github/workflows/docker_build.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_images:
|
||||
description: 'Push images to container registry'
|
||||
required: true
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-eagle:
|
||||
runs-on: self-hosted
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-eagle.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Eagle Docker image
|
||||
id: build-eagle
|
||||
run: |
|
||||
set -ex
|
||||
bazel build --platforms=//:linux_x86_64 //ci:eagle_server_image
|
||||
|
||||
# Save the resolved path before any other bazel command changes bazel-bin symlink
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
# Also set for current directory in case Bazel uses different home
|
||||
mkdir -p .docker
|
||||
cp ~/.docker/config.json .docker/
|
||||
|
||||
- name: Push Eagle image to DO registry
|
||||
id: push-eagle
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DOCKER_CONFIG: ${{ github.workspace }}/.docker
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Use cross-compiled image path from build step
|
||||
EAGLE_IMAGE="${{ steps.build-eagle.outputs.image_path }}"
|
||||
echo "Using Eagle image: $EAGLE_IMAGE"
|
||||
|
||||
if [ -z "$EAGLE_IMAGE" ] || [ ! -d "$EAGLE_IMAGE" ]; then
|
||||
echo "ERROR: Eagle image not found at: $EAGLE_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Debug: show OCI layout contents
|
||||
echo "=== OCI Layout Contents ==="
|
||||
cat "$EAGLE_IMAGE/index.json"
|
||||
echo ""
|
||||
echo "=== Blobs ==="
|
||||
ls -la "$EAGLE_IMAGE/blobs/sha256/" | head -20
|
||||
|
||||
# Verify OCI layout consistency before pushing
|
||||
echo "=== Verifying OCI layout consistency ==="
|
||||
for digest in $(cat "$EAGLE_IMAGE/index.json" | grep -o '"sha256:[^"]*"' | tr -d '"'); do
|
||||
blob_path="$EAGLE_IMAGE/blobs/${digest/://}"
|
||||
if [ ! -f "$blob_path" ]; then
|
||||
echo "ERROR: Blob not found: $blob_path"
|
||||
exit 1
|
||||
fi
|
||||
actual_digest="sha256:$(shasum -a 256 "$blob_path" | cut -d' ' -f1)"
|
||||
if [ "$digest" != "$actual_digest" ]; then
|
||||
echo "ERROR: Digest mismatch for $blob_path"
|
||||
echo " Index says: $digest"
|
||||
echo " Actual: $actual_digest"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Verified: $digest"
|
||||
done
|
||||
|
||||
# Build the push target to get crane in runfiles
|
||||
bazel build //ci:eagle_server_push
|
||||
|
||||
# Use crane directly for push (avoids OCI->Docker digest mismatch)
|
||||
CRANE="bazel-bin/ci/push_eagle_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
|
||||
echo "Using crane: $CRANE"
|
||||
|
||||
# Push with SHA tag
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
IMAGE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
|
||||
echo "Pushing eagle image: $IMAGE_TAG"
|
||||
$CRANE push "$EAGLE_IMAGE" "$IMAGE_TAG"
|
||||
|
||||
# Verify push by checking what's in the registry
|
||||
echo "=== Verifying push ==="
|
||||
$CRANE manifest "$IMAGE_TAG" | head -50
|
||||
PUSHED_DIGEST=$($CRANE digest "$IMAGE_TAG")
|
||||
echo "Registry reports digest: $PUSHED_DIGEST"
|
||||
|
||||
# Output the full image tag for deploy step
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also update :latest for convenience (but deploy won't use it)
|
||||
echo "Copying to :latest tag"
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
|
||||
|
||||
build-shardok:
|
||||
runs-on: self-hosted
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Shardok binary (cross-compile for Linux)
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Step 1: Build JUST the binary with cross-compilation
|
||||
# We need --extra_toolchains to force the Linux toolchain to be used
|
||||
# because toolchains_llvm registers with dev_dependency=True
|
||||
echo "=== Building shardok-server binary for linux-x86_64 ==="
|
||||
bazel build \
|
||||
--platforms=//:linux_x86_64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux//:all \
|
||||
//src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
# Step 2: Check the binary directly from bazel-bin
|
||||
# bazel-bin is a symlink that points to the correct output directory
|
||||
LINUX_BIN="bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server"
|
||||
echo "=== Checking binary at: $LINUX_BIN ==="
|
||||
|
||||
if [ ! -f "$LINUX_BIN" ]; then
|
||||
echo "ERROR: Binary not found at $LINUX_BIN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Debug: show what bazel-bin points to
|
||||
echo "bazel-bin symlink target: $(readlink bazel-bin || echo 'not a symlink')"
|
||||
|
||||
# Step 3: Verify it's ELF (Linux) not Mach-O (macOS)
|
||||
echo "=== Verifying binary format ==="
|
||||
MAGIC=$(head -c 4 "$LINUX_BIN" | xxd -p)
|
||||
echo "Binary magic bytes: $MAGIC"
|
||||
|
||||
if [ "$MAGIC" = "7f454c46" ]; then
|
||||
echo "SUCCESS: Binary is ELF format (Linux)"
|
||||
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
|
||||
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
|
||||
echo ""
|
||||
echo "Debug info:"
|
||||
echo "- bazel-bin points to: $(readlink bazel-bin)"
|
||||
file "$LINUX_BIN" || true
|
||||
exit 1
|
||||
else
|
||||
echo "WARNING: Unknown binary format: $MAGIC"
|
||||
file "$LINUX_BIN" || true
|
||||
fi
|
||||
|
||||
- name: Build Shardok Docker image
|
||||
id: build-shardok
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Build the OCI image with cross-compilation flags
|
||||
bazel build \
|
||||
--platforms=//:linux_x86_64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux//:all \
|
||||
//ci:shardok_server_image
|
||||
|
||||
# The image is output to bazel-bin which is a symlink.
|
||||
# Resolve it now before any other bazel commands change where it points.
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/shardok_server_image)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
# Verify the binary inside the tar layer is ELF
|
||||
echo "=== Verifying binary in image tar ==="
|
||||
BINARY_TAR="bazel-bin/ci/shardok_binary_layer.tar"
|
||||
if [ -f "$BINARY_TAR" ]; then
|
||||
echo "Checking binary in $BINARY_TAR"
|
||||
# Extract just the first 4 bytes of the binary from the tar
|
||||
MAGIC=$(tar -xOf "$BINARY_TAR" app/shardok-server 2>/dev/null | head -c 4 | xxd -p)
|
||||
echo "Binary magic in tar: $MAGIC"
|
||||
if [ "$MAGIC" = "7f454c46" ]; then
|
||||
echo "SUCCESS: Binary in tar is ELF format (Linux)"
|
||||
else
|
||||
echo "ERROR: Binary in tar is NOT ELF format!"
|
||||
echo "This means pkg_tar is packaging the wrong binary."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "WARNING: Could not find $BINARY_TAR"
|
||||
fi
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
# Also set for current directory in case Bazel uses different home
|
||||
mkdir -p .docker
|
||||
cp ~/.docker/config.json .docker/
|
||||
|
||||
- name: Push Shardok image to DO registry
|
||||
id: push-shardok
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DOCKER_CONFIG: ${{ github.workspace }}/.docker
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Use cross-compiled image path from build step
|
||||
CROSS_IMAGE="${{ steps.build-shardok.outputs.image_path }}"
|
||||
echo "Using cross-compiled image: $CROSS_IMAGE"
|
||||
|
||||
if [ -z "$CROSS_IMAGE" ] || [ ! -d "$CROSS_IMAGE" ]; then
|
||||
echo "ERROR: Cross-compiled image not found at: $CROSS_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get crane from Eagle push target (which doesn't need cross-compilation)
|
||||
# This gives us a macOS crane binary we can actually run.
|
||||
# We can't build shardok_server_push with platform flags because it would
|
||||
# download a Linux crane that can't run on macOS.
|
||||
bazel build //ci:eagle_server_push
|
||||
|
||||
# Find the Darwin crane binary (may be a symlink)
|
||||
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
|
||||
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
|
||||
if [ -z "$CRANE" ]; then
|
||||
# Fallback to any crane
|
||||
CRANE=$(find "$RUNFILES" -name crane 2>/dev/null | head -1)
|
||||
fi
|
||||
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
|
||||
echo "ERROR: crane not found. Listing runfiles:"
|
||||
find "$RUNFILES" -name crane 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Using crane: $CRANE"
|
||||
|
||||
# Push the cross-compiled image with SHA tag
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
IMAGE_TAG="registry.digitalocean.com/eagle0/shardok-server:${GIT_SHA}"
|
||||
echo "Pushing shardok image: $IMAGE_TAG"
|
||||
$CRANE push "$CROSS_IMAGE" "$IMAGE_TAG"
|
||||
|
||||
# Output the full image tag for deploy step
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also update :latest for convenience (but deploy won't use it)
|
||||
echo "Copying to :latest tag"
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/shardok-server:latest"
|
||||
|
||||
build-admin:
|
||||
runs-on: self-hosted
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-admin.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Admin Server Docker image
|
||||
id: build-admin
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Build admin server image (Go binary has explicit goos/goarch in BUILD.bazel)
|
||||
bazel build //ci:admin_server_image
|
||||
|
||||
# Save the resolved path before any other bazel command changes bazel-bin symlink
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/admin_server_image)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
mkdir -p .docker
|
||||
cp ~/.docker/config.json .docker/
|
||||
|
||||
- name: Push Admin image to DO registry
|
||||
id: push-admin
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DOCKER_CONFIG: ${{ github.workspace }}/.docker
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
ADMIN_IMAGE="${{ steps.build-admin.outputs.image_path }}"
|
||||
echo "Using Admin image: $ADMIN_IMAGE"
|
||||
|
||||
if [ -z "$ADMIN_IMAGE" ] || [ ! -d "$ADMIN_IMAGE" ]; then
|
||||
echo "ERROR: Admin image not found at: $ADMIN_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the push target to get crane in runfiles
|
||||
bazel build //ci:admin_server_push
|
||||
|
||||
# Use crane directly for push
|
||||
CRANE="bazel-bin/ci/push_admin_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
|
||||
echo "Using crane: $CRANE"
|
||||
|
||||
# Push with SHA tag
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
IMAGE_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
|
||||
echo "Pushing admin image: $IMAGE_TAG"
|
||||
$CRANE push "$ADMIN_IMAGE" "$IMAGE_TAG"
|
||||
|
||||
# Output the full image tag for deploy step
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also update :latest for convenience
|
||||
echo "Copying to :latest tag"
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
|
||||
|
||||
build-jfr-sidecar:
|
||||
runs-on: self-hosted
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-jfr-sidecar.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build JFR Sidecar Docker image
|
||||
id: build-jfr-sidecar
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Build JFR sidecar image (Go binary has explicit goos/goarch in BUILD.bazel)
|
||||
bazel build //ci:jfr_sidecar_image
|
||||
|
||||
# Save the resolved path before any other bazel command changes bazel-bin symlink
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/jfr_sidecar_image)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
mkdir -p .docker
|
||||
cp ~/.docker/config.json .docker/
|
||||
|
||||
- name: Push JFR Sidecar image to DO registry
|
||||
id: push-jfr-sidecar
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DOCKER_CONFIG: ${{ github.workspace }}/.docker
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
JFR_IMAGE="${{ steps.build-jfr-sidecar.outputs.image_path }}"
|
||||
echo "Using JFR Sidecar image: $JFR_IMAGE"
|
||||
|
||||
if [ -z "$JFR_IMAGE" ] || [ ! -d "$JFR_IMAGE" ]; then
|
||||
echo "ERROR: JFR Sidecar image not found at: $JFR_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the push target to get crane in runfiles
|
||||
bazel build //ci:jfr_sidecar_push
|
||||
|
||||
# Use crane directly for push
|
||||
CRANE="bazel-bin/ci/push_jfr_sidecar_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
|
||||
echo "Using crane: $CRANE"
|
||||
|
||||
# Push with SHA tag
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
IMAGE_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
|
||||
echo "Pushing JFR sidecar image: $IMAGE_TAG"
|
||||
$CRANE push "$JFR_IMAGE" "$IMAGE_TAG"
|
||||
|
||||
# Output the full image tag for deploy step
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also update :latest for convenience
|
||||
echo "Copying to :latest tag"
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-eagle, build-shardok, build-admin, build-jfr-sidecar]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
env:
|
||||
EAGLE_IMAGE: ${{ needs.build-eagle.outputs.image_tag }}
|
||||
SHARDOK_IMAGE: ${{ needs.build-shardok.outputs.image_tag }}
|
||||
ADMIN_IMAGE: ${{ needs.build-admin.outputs.image_tag }}
|
||||
JFR_SIDECAR_IMAGE: ${{ needs.build-jfr-sidecar.outputs.image_tag }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
|
||||
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
|
||||
DO_SPACES_ACCESS_KEY: ${{ secrets.DO_SPACES_ACCESS_KEY }}
|
||||
DO_SPACES_SECRET_KEY: ${{ secrets.DO_SPACES_SECRET_KEY }}
|
||||
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
|
||||
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
|
||||
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Copy config files to droplet
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.DO_DROPLET_IP }}
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
source: "docker-compose.prod.yml,nginx/nginx.conf"
|
||||
target: "/opt/eagle0"
|
||||
|
||||
- name: Deploy to production droplet
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.DO_DROPLET_IP }}
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
script_stop: true
|
||||
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,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
|
||||
script: |
|
||||
set -x
|
||||
cd /opt/eagle0
|
||||
|
||||
# Write env vars to .env file for docker-compose
|
||||
rm -f .env 2>/dev/null || true
|
||||
cat > .env << EOF
|
||||
EAGLE_IMAGE=${EAGLE_IMAGE}
|
||||
SHARDOK_IMAGE=${SHARDOK_IMAGE}
|
||||
ADMIN_IMAGE=${ADMIN_IMAGE}
|
||||
JFR_SIDECAR_IMAGE=${JFR_SIDECAR_IMAGE}
|
||||
OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
GPT_MODEL_NAME=${GPT_MODEL_NAME:-gpt-4o}
|
||||
EAGLE_ENABLE_S3=${EAGLE_ENABLE_S3:-false}
|
||||
DO_SPACES_ACCESS_KEY=${DO_SPACES_ACCESS_KEY:-}
|
||||
DO_SPACES_SECRET_KEY=${DO_SPACES_SECRET_KEY:-}
|
||||
JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}
|
||||
DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}
|
||||
DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}
|
||||
EOF
|
||||
chmod 600 .env
|
||||
|
||||
# Login to registry
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
|
||||
# Use exact image tags passed from build jobs (no :latest fallback)
|
||||
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE, $ADMIN_IMAGE, $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
|
||||
echo "Installing crane..."
|
||||
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
|
||||
chmod +x crane
|
||||
|
||||
# crane uses Docker config for auth
|
||||
echo "Pulling Eagle image with crane..."
|
||||
./crane pull "${EAGLE_IMAGE}" eagle.tar || { echo "ERROR: Failed to pull eagle image"; exit 1; }
|
||||
echo "Loading Eagle image into Docker..."
|
||||
docker load -i eagle.tar
|
||||
rm eagle.tar
|
||||
|
||||
echo "Pulling Shardok image with crane..."
|
||||
./crane pull "${SHARDOK_IMAGE}" shardok.tar || { echo "ERROR: Failed to pull shardok image"; exit 1; }
|
||||
echo "Loading Shardok image into Docker..."
|
||||
docker load -i shardok.tar
|
||||
rm shardok.tar
|
||||
|
||||
echo "Pulling Admin image with crane..."
|
||||
./crane pull "${ADMIN_IMAGE}" admin.tar || { echo "ERROR: Failed to pull admin image"; exit 1; }
|
||||
echo "Loading Admin image into Docker..."
|
||||
docker load -i admin.tar
|
||||
rm admin.tar
|
||||
|
||||
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
|
||||
docker pull nginx:alpine || true
|
||||
docker pull certbot/certbot || true
|
||||
|
||||
echo "All images pulled successfully"
|
||||
|
||||
# Force recreate containers to ensure new image is used
|
||||
docker compose -f docker-compose.prod.yml up -d --force-recreate --remove-orphans
|
||||
|
||||
# Restart nginx to pick up new container IPs
|
||||
# (nginx caches DNS at startup, so it needs restart after eagle/shardok)
|
||||
docker compose -f docker-compose.prod.yml restart nginx
|
||||
|
||||
# Wait for health checks
|
||||
sleep 10
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
|
||||
# Verify containers are using correct images
|
||||
echo "=== Verifying container image tags ==="
|
||||
docker compose -f docker-compose.prod.yml images
|
||||
|
||||
# Cleanup old images
|
||||
docker image prune -f
|
||||
@@ -52,14 +52,14 @@ jobs:
|
||||
- name: Persist Library/
|
||||
run: ./ci/github_actions/persist_library.sh
|
||||
- name: Deploy Windows unity
|
||||
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "/tmp/eagle0/eagle0WIN" "/tmp/unity_manifest.txt"
|
||||
|
||||
- name: Update unified manifest
|
||||
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
|
||||
@@ -3,6 +3,15 @@ load("@io_bazel_rules_go//go:def.bzl", "nogo")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
# Platform for cross-compiling to Linux x86_64
|
||||
platform(
|
||||
name = "linux_x86_64",
|
||||
constraint_values = [
|
||||
"@platforms//os:linux",
|
||||
"@platforms//cpu:x86_64",
|
||||
],
|
||||
)
|
||||
|
||||
gazelle(name = "gazelle")
|
||||
|
||||
# gazelle:proto file
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
# Deproto Migration Plan
|
||||
|
||||
This document tracks the migration from protobuf types to native Scala models inside the Eagle game engine.
|
||||
|
||||
## Architectural Decisions
|
||||
|
||||
1. **Keep proto for persistence**: Yes - protobuf is used for persisting game state
|
||||
2. **Keep proto for Shardok communication**: Yes - protobuf is used for Eagle-Shardok gRPC communication
|
||||
3. **Use Scala views inside the library**: Yes - use native Scala types like `ProvinceView`, `FactionView`, `HeroView`, etc. within the library code
|
||||
|
||||
## 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:
|
||||
|
||||
- **Protoless utilities**: `FactionUtils`, `HeroUtils`, `ProvinceUtils`, `ProvinceDistances`, etc.
|
||||
- **Proto-dependent utilities**: `LegacyFactionUtils`, `LegacyHeroUtils`, `LegacyProvinceUtils`, `LegacyProvinceDistances`, etc.
|
||||
|
||||
When migrating a file:
|
||||
1. Create a `Legacy*` version containing the proto-dependent methods
|
||||
2. Keep the original file name for protoless methods
|
||||
3. Update callers to use the appropriate version based on their context
|
||||
|
||||
## Migration Status
|
||||
|
||||
### Fully Protoless (no proto imports)
|
||||
|
||||
**Utilities:**
|
||||
- [x] `FactionUtils` - has protoless `ownedNeighbors` method
|
||||
- [x] `ProvinceDistances` - split into protoless + `LegacyProvinceDistances`
|
||||
- [x] `SwornBrotherChooser` - fully protoless (removed `bestChoiceProto`)
|
||||
|
||||
**Command Selectors (all use native GameState):**
|
||||
- [x] `AllianceOfferCommandSelector`
|
||||
- [x] `AlmsCommandSelector`
|
||||
- [x] `AttackCommandChooser`
|
||||
- [x] `ExpandCommandSelector`
|
||||
- [x] `HeroGiftCommandSelector`
|
||||
- [x] `ImproveCommandSelector`
|
||||
- [x] `MarchTowardProvinceCommandChooser` - in AI folder, uses native GameState (callers convert)
|
||||
- [x] `OrganizeCommandSelector`
|
||||
- [x] `RansomOfferHelpers`
|
||||
- [x] `SeekMoreLeadersCommandChooser` - in AI folder, uses native GameState
|
||||
- [x] `TruceOfferCommandSelector`
|
||||
- [x] `TrustForDiplomacy`
|
||||
|
||||
**Quest Command Selectors (all protoless):**
|
||||
- [x] `AllianceQuestCommandChooser`
|
||||
- [x] `AlmsAcrossRealmQuestCommandChooser`
|
||||
- [x] `AlmsToProvinceQuestCommandChooser`
|
||||
- [x] `DismissSpecificVassalCommandChooser`
|
||||
- [x] `GiveToHeroesAcrossRealmQuestCommandChooser`
|
||||
- [x] `GiveToHeroesInProvinceQuestCommandChooser`
|
||||
- [x] `ImproveQuestCommandChooser`
|
||||
- [x] `QuestCommandChooser`
|
||||
- [x] `TruceCountQuestCommandChooser`
|
||||
- [x] `TruceWithFactionQuestCommandChooser`
|
||||
|
||||
### Fully Protoless
|
||||
|
||||
- [x] `AIClientUtils` - has protoless overloads (`takenHeroIdsForMarchTowardFocus`, `mostPowerfulHeroes`)
|
||||
- [x] `ProvinceGoldSurplusCalculator` - fully protoless (callers use converters)
|
||||
- [x] `HeroSelector` - fully protoless (removed dead `minimallyFatiguedHeroesProto`)
|
||||
|
||||
### Blocked (still uses proto GameState)
|
||||
|
||||
- [ ] `CommandChoiceHelpers` - main target, uses proto GameState extensively
|
||||
- Depends on many Legacy* utils
|
||||
- Central hub called by many command selectors
|
||||
- [ ] `AttackDecisionCommandChooser` - uses proto GameState, converts internally
|
||||
- [ ] `CommandChooser` - trait uses proto GameState in signature
|
||||
- [ ] `FulfillQuestsCommandSelector` - takes proto, converts to native immediately
|
||||
- Called by `MidGameAIClient` which uses proto GameState
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Phase 1: CommandChoiceHelpers Migration
|
||||
|
||||
The main blocker is `CommandChoiceHelpers.scala` which uses proto `GameState` extensively. Strategy:
|
||||
|
||||
1. **Add Scala overloads** to `CommandChoiceHelpers` methods that currently take proto GameState
|
||||
2. **Update internal helpers** to use Scala types where possible
|
||||
3. **Migrate callers incrementally** - command selectors that are already protoless can switch to Scala overloads
|
||||
|
||||
### Phase 2: CommandChooser Trait
|
||||
|
||||
Once CommandChoiceHelpers is protoless:
|
||||
|
||||
1. Add Scala `GameState` overload to `CommandChooser.choose()` method
|
||||
2. Update implementations (`AttackDecisionCommandChooser`, etc.) to use Scala internally
|
||||
3. Eventually deprecate proto overloads
|
||||
|
||||
### Phase 3: MidGameAIClient
|
||||
|
||||
The top-level AI client still uses proto GameState. Once lower layers are protoless:
|
||||
|
||||
1. Convert `MidGameAIClient` to use Scala GameState internally
|
||||
2. Only convert at the boundary when receiving from/sending to gRPC
|
||||
|
||||
## Key Files
|
||||
|
||||
### Protoless Model Types
|
||||
|
||||
- `src/main/scala/net/eagle0/eagle/model/state/game_state/GameState.scala` - native Scala GameState
|
||||
- `src/main/scala/net/eagle0/eagle/model/state/province/ProvinceView.scala` - province view type
|
||||
- `src/main/scala/net/eagle0/eagle/model/state/faction/FactionView.scala` - faction view type
|
||||
- `src/main/scala/net/eagle0/eagle/model/state/hero/HeroView.scala` - hero view type
|
||||
|
||||
### Proto Converters
|
||||
|
||||
- `src/main/scala/net/eagle0/eagle/model/proto_converters/game_state/` - converts between proto and Scala types
|
||||
|
||||
## Notes
|
||||
|
||||
- The AI client code (`src/main/scala/net/eagle0/eagle/ai/`) currently uses proto types extensively
|
||||
- `PerformUnaffiliatedHeroesAction` already uses protoless `GameState`
|
||||
- Migration should proceed incrementally: utilities first, then higher-level selectors/choosers
|
||||
+83
-18
@@ -26,50 +26,68 @@ scala_config = use_extension(
|
||||
"@rules_scala//scala/extensions:config.bzl",
|
||||
"scala_config",
|
||||
)
|
||||
|
||||
scala_config.settings(scala_version = SCALA_VERSION)
|
||||
|
||||
scala_deps = use_extension(
|
||||
"@rules_scala//scala/extensions:deps.bzl",
|
||||
"scala_deps",
|
||||
)
|
||||
|
||||
scala_deps.scala()
|
||||
|
||||
scala_deps.scalatest()
|
||||
|
||||
scala_deps.scala_proto()
|
||||
|
||||
#
|
||||
# Language Support - C++
|
||||
#
|
||||
|
||||
bazel_dep(name = "toolchains_llvm", version = "1.4.0")
|
||||
bazel_dep(name = "toolchains_llvm", version = "1.6.0")
|
||||
|
||||
llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm")
|
||||
|
||||
# Native toolchain (macOS -> macOS, Linux -> Linux)
|
||||
llvm.toolchain(
|
||||
name = "llvm_toolchain",
|
||||
llvm_version = "20.1.2",
|
||||
)
|
||||
|
||||
use_repo(llvm, "llvm_toolchain")
|
||||
# Cross-compilation toolchain (macOS -> Linux x86_64)
|
||||
# Uses the same LLVM distribution but with a Linux sysroot
|
||||
llvm.toolchain(
|
||||
name = "llvm_toolchain_linux",
|
||||
llvm_version = "20.1.2",
|
||||
)
|
||||
|
||||
# Linux sysroot for cross-compilation (Chromium's Debian sysroot)
|
||||
llvm.sysroot(
|
||||
name = "llvm_toolchain_linux",
|
||||
label = "@linux_sysroot//sysroot",
|
||||
targets = ["linux-x86_64"],
|
||||
)
|
||||
|
||||
use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux")
|
||||
|
||||
# Download the Linux sysroot (Ubuntu 24.04 Noble for C++23 support)
|
||||
# Built by: .github/workflows/build_sysroot.yml
|
||||
# To rebuild: Run the "Build Linux Sysroot" workflow with a new version, then update sha256 and URL
|
||||
sysroot = use_repo_rule("@toolchains_llvm//toolchain:sysroot.bzl", "sysroot")
|
||||
sysroot(
|
||||
name = "linux_sysroot",
|
||||
sha256 = "a06475004fe8003ae7ccb4fe1d5511feb9b27cce4a8826eb1dfd686ed83f3dba",
|
||||
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
|
||||
)
|
||||
|
||||
#
|
||||
# Language Support - Go
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_go", repo_name = "io_bazel_rules_go", version = "0.56.1")
|
||||
bazel_dep(name = "gazelle", repo_name = "bazel_gazelle", version = "0.45.0")
|
||||
bazel_dep(name = "rules_go", version = "0.56.1", repo_name = "io_bazel_rules_go")
|
||||
bazel_dep(name = "gazelle", version = "0.45.0", repo_name = "bazel_gazelle")
|
||||
|
||||
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
|
||||
|
||||
go_sdk.download(version = "1.23.3")
|
||||
|
||||
go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps")
|
||||
|
||||
go_deps.from_file(go_mod = "//:go.mod")
|
||||
|
||||
use_repo(
|
||||
go_deps,
|
||||
"com_github_aws_aws_sdk_go_v2",
|
||||
@@ -84,15 +102,15 @@ use_repo(
|
||||
# Platform Support - Apple/iOS
|
||||
#
|
||||
|
||||
bazel_dep(name = "apple_support", repo_name = "build_bazel_apple_support", version = "1.21.1")
|
||||
bazel_dep(name = "rules_apple", repo_name = "build_bazel_rules_apple", version = "3.16.1")
|
||||
bazel_dep(name = "rules_swift", repo_name = "build_bazel_rules_swift", version = "2.3.1")
|
||||
bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_apple_support")
|
||||
bazel_dep(name = "rules_apple", version = "3.16.1", repo_name = "build_bazel_rules_apple")
|
||||
bazel_dep(name = "rules_swift", version = "2.3.1", repo_name = "build_bazel_rules_swift")
|
||||
|
||||
#
|
||||
# Protocol Buffers & RPC
|
||||
#
|
||||
|
||||
bazel_dep(name = "protobuf", repo_name = "com_google_protobuf", version = "29.2")
|
||||
bazel_dep(name = "protobuf", version = "29.2", repo_name = "com_google_protobuf")
|
||||
bazel_dep(name = "grpc", version = "1.71.0")
|
||||
bazel_dep(name = "grpc-java", version = "1.71.0")
|
||||
bazel_dep(name = "flatbuffers", version = "25.2.10")
|
||||
@@ -103,6 +121,40 @@ bazel_dep(name = "flatbuffers", version = "25.2.10")
|
||||
|
||||
bazel_dep(name = "googletest", version = "1.17.0")
|
||||
|
||||
#
|
||||
# Container Images (OCI)
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_oci", version = "2.2.6")
|
||||
bazel_dep(name = "aspect_bazel_lib", version = "2.16.0")
|
||||
|
||||
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
|
||||
|
||||
# Base image for Eagle (Java 17 JDK - includes jcmd for JFR dumps)
|
||||
oci.pull(
|
||||
name = "eclipse_temurin_17",
|
||||
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"],
|
||||
tag = "24.04",
|
||||
)
|
||||
|
||||
# Base image for Admin Server (Alpine for lightweight Go binary)
|
||||
oci.pull(
|
||||
name = "alpine_linux",
|
||||
image = "docker.io/library/alpine",
|
||||
platforms = ["linux/amd64"],
|
||||
tag = "3.21",
|
||||
)
|
||||
use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64")
|
||||
|
||||
#
|
||||
# Java/Scala Dependencies
|
||||
#
|
||||
@@ -110,7 +162,6 @@ bazel_dep(name = "googletest", version = "1.17.0")
|
||||
bazel_dep(name = "rules_jvm_external", version = "6.3")
|
||||
|
||||
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
|
||||
|
||||
maven.install(
|
||||
artifacts = [
|
||||
# Netty
|
||||
@@ -162,9 +213,12 @@ maven.install(
|
||||
"org.reactivestreams:reactive-streams:1.0.4",
|
||||
"javax.xml.bind:jaxb-api:2.3.1",
|
||||
|
||||
# OkHttp (for SSE with read timeout support)
|
||||
# OkHttp (for SSE with read timeout support, OAuth HTTP calls)
|
||||
"com.squareup.okhttp3:okhttp:4.12.0",
|
||||
"com.squareup.okhttp3:okhttp-sse:4.12.0",
|
||||
|
||||
# JWT (for OAuth token handling)
|
||||
"com.nimbusds:nimbus-jose-jwt:9.37.3",
|
||||
],
|
||||
duplicate_version_warning = "error",
|
||||
fail_if_repin_required = True,
|
||||
@@ -173,7 +227,6 @@ maven.install(
|
||||
"https://repo1.maven.org/maven2",
|
||||
],
|
||||
)
|
||||
|
||||
use_repo(maven, "maven", "unpinned_maven")
|
||||
|
||||
#
|
||||
@@ -181,6 +234,7 @@ use_repo(maven, "maven", "unpinned_maven")
|
||||
#
|
||||
|
||||
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
|
||||
http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
|
||||
|
||||
# GTL (for parallel_hashmap)
|
||||
GTL_VERSION = "1.2.0"
|
||||
@@ -209,6 +263,16 @@ http_archive(
|
||||
],
|
||||
)
|
||||
|
||||
# Busybox static binary for Docker health checks (provides nc, wget, etc.)
|
||||
# https://busybox.net/downloads/binaries/
|
||||
http_file(
|
||||
name = "busybox_x86_64",
|
||||
sha256 = "6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348",
|
||||
urls = ["https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox"],
|
||||
downloaded_file_path = "busybox",
|
||||
executable = True,
|
||||
)
|
||||
|
||||
#
|
||||
# Toolchain Registration
|
||||
#
|
||||
@@ -221,5 +285,6 @@ register_toolchains(
|
||||
# Set dev_dependency so we can turn this off for swift MacOS builds
|
||||
register_toolchains(
|
||||
"@llvm_toolchain//:all",
|
||||
"@llvm_toolchain_linux//:all",
|
||||
dev_dependency = True,
|
||||
)
|
||||
|
||||
Generated
+308
-88
@@ -26,7 +26,11 @@
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/1.38.0/MODULE.bazel": "6307fec451ba9962c1c969eb516ebfe1e46528f7fa92e1c9ac8646bef4cdaa3f",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/1.40.3/MODULE.bazel": "668e6bcb4d957fc0e284316dba546b705c8d43c857f87119619ee83c4555b859",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.11.0/MODULE.bazel": "cb1ba9f9999ed0bc08600c221f532c1ddd8d217686b32ba7d45b0713b5131452",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.11.0/source.json": "92494d5aa43b96665397dd13ee16023097470fa85e276b93674d62a244de47ee",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.16.0/MODULE.bazel": "852f9ebbda017572a7c113a2434592dd3b2f55cd9a0faea3d4be5a09a59e4900",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/source.json": "ffab9254c65ba945f8369297ad97ca0dec213d3adc6e07877e23a48624a8b456",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.2/MODULE.bazel": "780d1a6522b28f5edb7ea09630748720721dfe27690d65a2d33aa7509de77e07",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.7/MODULE.bazel": "491f8681205e31bb57892d67442ce448cda4f472a8e6b3dc062865e29a64f89c",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.8.1/MODULE.bazel": "812d2dd42f65dca362152101fbec418029cc8fd34cbad1a2fde905383d705838",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.9.3/MODULE.bazel": "66baf724dbae7aff4787bf2245cc188d50cb08e07789769730151c0943587c14",
|
||||
@@ -51,8 +55,11 @@
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.27.0/source.json": "ed8cf0ef05c858dce3661689d0a2b110ff398e63994e178e4f1f7555a8067fed",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.38.0/MODULE.bazel": "f9b8a9c890ebd216b4049fd12a31d3c2602e3403c7af636b04fbbd7453edc9c9",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.38.0/source.json": "31ba776c122b54a2885e23651642e32f087a87bf025465f8040751894b571277",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
|
||||
@@ -69,7 +76,8 @@
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/source.json": "7ebaefba0b03efe59cac88ed5bbc67bcf59a3eff33af937345ede2a38b2d368a",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb",
|
||||
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/MODULE.bazel": "fd1f9432ca04c947e91b500df69ce7c5b6dbfe1bc45ab1820338205dae3383a6",
|
||||
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/source.json": "5d68545f224904745a3cabd35aea6bc2b6cc5a78b7f49f3f69660eab2eeeb273",
|
||||
"https://bcr.bazel.build/modules/boringssl/0.0.0-20211025-d4f1ab9/MODULE.bazel": "6ee6353f8b1a701fe2178e1d925034294971350b6d3ac37e67e5a7d463267834",
|
||||
@@ -98,6 +106,8 @@
|
||||
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20250128-4de3c74/source.json": "028519164a2e24563f4b43d810fdedc702daed90e71e7042d45ba82ad807b46f",
|
||||
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/MODULE.bazel": "dab15cafe8512d2c4a8daa44c2d7968c5c79f01e220d40076cdc260bf58605e2",
|
||||
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/source.json": "7eae7ea3eb913b9802426e4d5df11d6c6072a3573a548f8cabf1e965f5cca4d0",
|
||||
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8",
|
||||
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/source.json": "fa7b512dfcb5eafd90ce3959cf42a2a6fe96144ebbb4b3b3928054895f2afac2",
|
||||
"https://bcr.bazel.build/modules/gazelle/0.27.0/MODULE.bazel": "3446abd608295de6d90b4a8a118ed64a9ce11dcb3dda2dc3290a22056bd20996",
|
||||
"https://bcr.bazel.build/modules/gazelle/0.30.0/MODULE.bazel": "f888a1effe338491f35f0e0e85003b47bb9d8295ccba73c37e07702d8d31c65b",
|
||||
"https://bcr.bazel.build/modules/gazelle/0.32.0/MODULE.bazel": "b499f58a5d0d3537f3cf5b76d8ada18242f64ec474d8391247438bf04f58c7b8",
|
||||
@@ -137,6 +147,10 @@
|
||||
"https://bcr.bazel.build/modules/grpc/1.70.1/MODULE.bazel": "b800cd8e3e7555c1e61cba2e02d3a2fcf0e91f66e800db286d965d3b7a6a721a",
|
||||
"https://bcr.bazel.build/modules/grpc/1.71.0/MODULE.bazel": "7fcab2c05530373f1a442c362b17740dd0c75b6a2a975eec8f5bf4c70a37928a",
|
||||
"https://bcr.bazel.build/modules/grpc/1.71.0/source.json": "60ef8c4c72c8280ae94c05b4f38bf67785acb25477ab8dbac096a9604449ff90",
|
||||
"https://bcr.bazel.build/modules/helly25_bzl/0.3.1/MODULE.bazel": "3a4be20f6fc13be32ad44643b8252ef5af09eee936f1d943cd4fd7867fa92826",
|
||||
"https://bcr.bazel.build/modules/helly25_bzl/0.3.1/source.json": "b129ab1828492de2c163785bbeb4065c166de52d932524b4317beb5b7f917994",
|
||||
"https://bcr.bazel.build/modules/jq.bzl/0.1.0/MODULE.bazel": "2ce69b1af49952cd4121a9c3055faa679e748ce774c7f1fda9657f936cae902f",
|
||||
"https://bcr.bazel.build/modules/jq.bzl/0.1.0/source.json": "746bf13cac0860f091df5e4911d0c593971cd8796b5ad4e809b2f8e133eee3d5",
|
||||
"https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075",
|
||||
"https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d",
|
||||
"https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902",
|
||||
@@ -161,6 +175,7 @@
|
||||
"https://bcr.bazel.build/modules/opentelemetry-proto/1.5.0/source.json": "046b721ce203e88cdaad44d7dd17a86b7200eab9388b663b234e72e13ff7b143",
|
||||
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/MODULE.bazel": "b3925269f63561b8b880ae7cf62ccf81f6ece55b62cd791eda9925147ae116ec",
|
||||
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/source.json": "da1cb1add160f5e5074b7272e9db6fd8f1b3336c15032cd0a653af9d2f484aed",
|
||||
"https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92",
|
||||
"https://bcr.bazel.build/modules/package_metadata/0.0.5/MODULE.bazel": "ef4f9439e3270fdd6b9fd4dbc3d2f29d13888e44c529a1b243f7a31dfbc2e8e4",
|
||||
"https://bcr.bazel.build/modules/package_metadata/0.0.5/source.json": "2326db2f6592578177751c3e1f74786b79382cd6008834c9d01ec865b9126a85",
|
||||
"https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5",
|
||||
@@ -225,12 +240,14 @@
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.0.17/source.json": "4db99b3f55c90ab28d14552aa0632533e3e8e5e9aea0f5c24ac0014282c2a7c5",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.0.2/MODULE.bazel": "6915987c90970493ab97393024c156ea8fb9f3bea953b2f3ec05c34f19b5695c",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.0.5/MODULE.bazel": "be41f87587998fe8890cd82ea4e848ed8eb799e053c224f78f3ff7fe1a1d9b74",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.0.6/MODULE.bazel": "abf360251023dfe3efcef65ab9d56beefa8394d4176dd29529750e1c57eaa33f",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.2.14/source.json": "55d0a4587c5592fad350f6e698530f4faf0e7dd15e69d43f8d87e220c78bea54",
|
||||
"https://bcr.bazel.build/modules/rules_foreign_cc/0.10.1/MODULE.bazel": "b9527010e5fef060af92b6724edb3691970a5b1f76f74b21d39f7d433641be60",
|
||||
"https://bcr.bazel.build/modules/rules_foreign_cc/0.10.1/source.json": "9300e71df0cdde0952f10afff1401fa664e9fc5d9ae6204660ba1b158d90d6a6",
|
||||
"https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6",
|
||||
@@ -289,6 +306,8 @@
|
||||
"https://bcr.bazel.build/modules/rules_nodejs/6.3.0/MODULE.bazel": "45345e4aba35dd6e4701c1eebf5a4e67af4ed708def9ebcdc6027585b34ee52d",
|
||||
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/MODULE.bazel": "b66eadebd10f1f1b25f52f95ab5213a57e82c37c3f656fcd9a57ad04d2264ce7",
|
||||
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/source.json": "45bd343155bdfed2543f0e39b80ff3f6840efc31975da4b5795797f4c94147ad",
|
||||
"https://bcr.bazel.build/modules/rules_oci/2.2.6/MODULE.bazel": "2ba6ddd679269e00aeffe9ca04faa2d0ca4129650982c9246d0d459fe2da47d9",
|
||||
"https://bcr.bazel.build/modules/rules_oci/2.2.6/source.json": "94e7decb8f95d9465b0bbea71c65064cd16083be1350c7468f131818641dc4a5",
|
||||
"https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc",
|
||||
"https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff",
|
||||
"https://bcr.bazel.build/modules/rules_pkg/1.1.0/MODULE.bazel": "9db8031e71b6ef32d1846106e10dd0ee2deac042bd9a2de22b4761b0c3036453",
|
||||
@@ -321,7 +340,8 @@
|
||||
"https://bcr.bazel.build/modules/rules_scala/7.1.1/source.json": "5038cb231d4020c5965c920681cf961a7bf137b40315025e40f3a7b6a0ac1f0f",
|
||||
"https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c",
|
||||
"https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b",
|
||||
"https://bcr.bazel.build/modules/rules_shell/0.3.0/source.json": "c55ed591aa5009401ddf80ded9762ac32c358d2517ee7820be981e2de9756cf3",
|
||||
"https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592",
|
||||
"https://bcr.bazel.build/modules/rules_shell/0.4.1/source.json": "4757bd277fe1567763991c4425b483477bb82e35e777a56fd846eb5cceda324a",
|
||||
"https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca",
|
||||
"https://bcr.bazel.build/modules/rules_swift/1.18.0/MODULE.bazel": "a6aba73625d0dc64c7b4a1e831549b6e375fbddb9d2dde9d80c9de6ec45b24c9",
|
||||
"https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046",
|
||||
@@ -339,14 +359,19 @@
|
||||
"https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216",
|
||||
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91",
|
||||
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/source.json": "32bd87e5f4d7acc57c5b2ff7c325ae3061d5e242c0c4c214ae87e0f1c13e54cb",
|
||||
"https://bcr.bazel.build/modules/toolchains_llvm/1.4.0/MODULE.bazel": "05239402b7374293359c2f22806f420b75aa5d6f4b15a2eaa809a2c214d58b31",
|
||||
"https://bcr.bazel.build/modules/toolchains_llvm/1.4.0/source.json": "229a516d282b17a82be54c6e3ae220a1b750fb55a8495567e5c7a9d09423f3e2",
|
||||
"https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468",
|
||||
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/MODULE.bazel": "a3584b4edcfafcabd9b0ef9819808f05b372957bbdff41601429d5fd0aac2e7c",
|
||||
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/source.json": "4a620381df075a16cb3a7ed57bd1d05f7480222394c64a20fa51bdb636fda658",
|
||||
"https://bcr.bazel.build/modules/toolchains_llvm/1.6.0/MODULE.bazel": "39603859cafb1c6830160fcd6370552e836790e6abb2bfb8d13bff53c0c10a64",
|
||||
"https://bcr.bazel.build/modules/toolchains_llvm/1.6.0/source.json": "6bd3ef95a288dd2bb1582eca332af850c9a5428a23bb92cb1c57c2dfe6cb7369",
|
||||
"https://bcr.bazel.build/modules/upb/0.0.0-20211020-160625a/MODULE.bazel": "6cced416be2dc5b9c05efd5b997049ba795e5e4e6fafbe1624f4587767638928",
|
||||
"https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43",
|
||||
"https://bcr.bazel.build/modules/upb/0.0.0-20230516-61a97ef/MODULE.bazel": "c0df5e35ad55e264160417fd0875932ee3c9dda63d9fccace35ac62f45e1b6f9",
|
||||
"https://bcr.bazel.build/modules/upb/0.0.0-20230907-e7430e6/MODULE.bazel": "3a7dedadf70346e678dc059dbe44d05cbf3ab17f1ce43a1c7a42edc7cbf93fd9",
|
||||
"https://bcr.bazel.build/modules/xds/0.0.0-20240423-555b57e/MODULE.bazel": "cea509976a77e34131411684ef05a1d6ad194dd71a8d5816643bc5b0af16dc0f",
|
||||
"https://bcr.bazel.build/modules/xds/0.0.0-20240423-555b57e/source.json": "7227e1fcad55f3f3cab1a08691ecd753cb29cc6380a47bc650851be9f9ad6d20",
|
||||
"https://bcr.bazel.build/modules/yq.bzl/0.1.1/MODULE.bazel": "9039681f9bcb8958ee2c87ffc74bdafba9f4369096a2b5634b88abc0eaefa072",
|
||||
"https://bcr.bazel.build/modules/yq.bzl/0.1.1/source.json": "2d2bad780a9f2b9195a4a370314d2c17ae95eaa745cefc2e12fbc49759b15aa3",
|
||||
"https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0",
|
||||
"https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27",
|
||||
"https://bcr.bazel.build/modules/zlib/1.2.13/MODULE.bazel": "aa6deb1b83c18ffecd940c4119aff9567cd0a671d7bba756741cb2ef043a29d5",
|
||||
@@ -388,7 +413,7 @@
|
||||
},
|
||||
"@@aspect_rules_esbuild~//esbuild:extensions.bzl%esbuild": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "8iOqbPY5ve3DvjzaI1mJZ8XTiJypN2PeWvcKOvmZLy8=",
|
||||
"bzlTransitiveDigest": "8jv3p0xDR/oitFeH8y0+Y5xlyrUbfsTRlc9TSwYkwl8=",
|
||||
"usagesDigest": "iDVoyPxUeADmfK8ssoyG3Ehq1bj6p7A43LpEiE266os=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
@@ -1265,6 +1290,280 @@
|
||||
"recordedRepoMappingEntries": []
|
||||
}
|
||||
},
|
||||
"@@rules_oci~//oci:extensions.bzl%oci": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
|
||||
"usagesDigest": "uqsqMpE+yaVuTByR2rIA2v1Vkm1JwwuN6nbXOo1W9G0=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
"generatedRepoSpecs": {
|
||||
"eclipse_temurin_17_linux_amd64": {
|
||||
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
|
||||
"ruleClassName": "oci_pull",
|
||||
"attributes": {
|
||||
"www_authenticate_challenges": {},
|
||||
"scheme": "https",
|
||||
"registry": "index.docker.io",
|
||||
"repository": "library/eclipse-temurin",
|
||||
"identifier": "17-jdk",
|
||||
"platform": "linux/amd64",
|
||||
"target_name": "eclipse_temurin_17_linux_amd64",
|
||||
"bazel_tags": []
|
||||
}
|
||||
},
|
||||
"eclipse_temurin_17": {
|
||||
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
|
||||
"ruleClassName": "oci_alias",
|
||||
"attributes": {
|
||||
"target_name": "eclipse_temurin_17",
|
||||
"www_authenticate_challenges": {},
|
||||
"scheme": "https",
|
||||
"registry": "index.docker.io",
|
||||
"repository": "library/eclipse-temurin",
|
||||
"identifier": "17-jdk",
|
||||
"platforms": {
|
||||
"@@platforms//cpu:x86_64": "@eclipse_temurin_17_linux_amd64"
|
||||
},
|
||||
"bzlmod_repository": "eclipse_temurin_17",
|
||||
"reproducible": true
|
||||
}
|
||||
},
|
||||
"ubuntu_24_04_linux_amd64": {
|
||||
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
|
||||
"ruleClassName": "oci_pull",
|
||||
"attributes": {
|
||||
"www_authenticate_challenges": {},
|
||||
"scheme": "https",
|
||||
"registry": "index.docker.io",
|
||||
"repository": "library/ubuntu",
|
||||
"identifier": "24.04",
|
||||
"platform": "linux/amd64",
|
||||
"target_name": "ubuntu_24_04_linux_amd64",
|
||||
"bazel_tags": []
|
||||
}
|
||||
},
|
||||
"ubuntu_24_04": {
|
||||
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
|
||||
"ruleClassName": "oci_alias",
|
||||
"attributes": {
|
||||
"target_name": "ubuntu_24_04",
|
||||
"www_authenticate_challenges": {},
|
||||
"scheme": "https",
|
||||
"registry": "index.docker.io",
|
||||
"repository": "library/ubuntu",
|
||||
"identifier": "24.04",
|
||||
"platforms": {
|
||||
"@@platforms//cpu:x86_64": "@ubuntu_24_04_linux_amd64"
|
||||
},
|
||||
"bzlmod_repository": "ubuntu_24_04",
|
||||
"reproducible": true
|
||||
}
|
||||
},
|
||||
"alpine_linux_linux_amd64": {
|
||||
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
|
||||
"ruleClassName": "oci_pull",
|
||||
"attributes": {
|
||||
"www_authenticate_challenges": {},
|
||||
"scheme": "https",
|
||||
"registry": "index.docker.io",
|
||||
"repository": "library/alpine",
|
||||
"identifier": "3.21",
|
||||
"platform": "linux/amd64",
|
||||
"target_name": "alpine_linux_linux_amd64",
|
||||
"bazel_tags": []
|
||||
}
|
||||
},
|
||||
"alpine_linux": {
|
||||
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
|
||||
"ruleClassName": "oci_alias",
|
||||
"attributes": {
|
||||
"target_name": "alpine_linux",
|
||||
"www_authenticate_challenges": {},
|
||||
"scheme": "https",
|
||||
"registry": "index.docker.io",
|
||||
"repository": "library/alpine",
|
||||
"identifier": "3.21",
|
||||
"platforms": {
|
||||
"@@platforms//cpu:x86_64": "@alpine_linux_linux_amd64"
|
||||
},
|
||||
"bzlmod_repository": "alpine_linux",
|
||||
"reproducible": true
|
||||
}
|
||||
},
|
||||
"oci_crane_darwin_amd64": {
|
||||
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
|
||||
"ruleClassName": "crane_repositories",
|
||||
"attributes": {
|
||||
"platform": "darwin_amd64",
|
||||
"crane_version": "v0.18.0"
|
||||
}
|
||||
},
|
||||
"oci_crane_darwin_arm64": {
|
||||
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
|
||||
"ruleClassName": "crane_repositories",
|
||||
"attributes": {
|
||||
"platform": "darwin_arm64",
|
||||
"crane_version": "v0.18.0"
|
||||
}
|
||||
},
|
||||
"oci_crane_linux_arm64": {
|
||||
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
|
||||
"ruleClassName": "crane_repositories",
|
||||
"attributes": {
|
||||
"platform": "linux_arm64",
|
||||
"crane_version": "v0.18.0"
|
||||
}
|
||||
},
|
||||
"oci_crane_linux_armv6": {
|
||||
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
|
||||
"ruleClassName": "crane_repositories",
|
||||
"attributes": {
|
||||
"platform": "linux_armv6",
|
||||
"crane_version": "v0.18.0"
|
||||
}
|
||||
},
|
||||
"oci_crane_linux_i386": {
|
||||
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
|
||||
"ruleClassName": "crane_repositories",
|
||||
"attributes": {
|
||||
"platform": "linux_i386",
|
||||
"crane_version": "v0.18.0"
|
||||
}
|
||||
},
|
||||
"oci_crane_linux_s390x": {
|
||||
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
|
||||
"ruleClassName": "crane_repositories",
|
||||
"attributes": {
|
||||
"platform": "linux_s390x",
|
||||
"crane_version": "v0.18.0"
|
||||
}
|
||||
},
|
||||
"oci_crane_linux_amd64": {
|
||||
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
|
||||
"ruleClassName": "crane_repositories",
|
||||
"attributes": {
|
||||
"platform": "linux_amd64",
|
||||
"crane_version": "v0.18.0"
|
||||
}
|
||||
},
|
||||
"oci_crane_windows_armv6": {
|
||||
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
|
||||
"ruleClassName": "crane_repositories",
|
||||
"attributes": {
|
||||
"platform": "windows_armv6",
|
||||
"crane_version": "v0.18.0"
|
||||
}
|
||||
},
|
||||
"oci_crane_windows_amd64": {
|
||||
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
|
||||
"ruleClassName": "crane_repositories",
|
||||
"attributes": {
|
||||
"platform": "windows_amd64",
|
||||
"crane_version": "v0.18.0"
|
||||
}
|
||||
},
|
||||
"oci_crane_toolchains": {
|
||||
"bzlFile": "@@rules_oci~//oci/private:toolchains_repo.bzl",
|
||||
"ruleClassName": "toolchains_repo",
|
||||
"attributes": {
|
||||
"toolchain_type": "@rules_oci//oci:crane_toolchain_type",
|
||||
"toolchain": "@oci_crane_{platform}//:crane_toolchain"
|
||||
}
|
||||
},
|
||||
"oci_regctl_darwin_amd64": {
|
||||
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
|
||||
"ruleClassName": "regctl_repositories",
|
||||
"attributes": {
|
||||
"platform": "darwin_amd64"
|
||||
}
|
||||
},
|
||||
"oci_regctl_darwin_arm64": {
|
||||
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
|
||||
"ruleClassName": "regctl_repositories",
|
||||
"attributes": {
|
||||
"platform": "darwin_arm64"
|
||||
}
|
||||
},
|
||||
"oci_regctl_linux_arm64": {
|
||||
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
|
||||
"ruleClassName": "regctl_repositories",
|
||||
"attributes": {
|
||||
"platform": "linux_arm64"
|
||||
}
|
||||
},
|
||||
"oci_regctl_linux_s390x": {
|
||||
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
|
||||
"ruleClassName": "regctl_repositories",
|
||||
"attributes": {
|
||||
"platform": "linux_s390x"
|
||||
}
|
||||
},
|
||||
"oci_regctl_linux_amd64": {
|
||||
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
|
||||
"ruleClassName": "regctl_repositories",
|
||||
"attributes": {
|
||||
"platform": "linux_amd64"
|
||||
}
|
||||
},
|
||||
"oci_regctl_windows_amd64": {
|
||||
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
|
||||
"ruleClassName": "regctl_repositories",
|
||||
"attributes": {
|
||||
"platform": "windows_amd64"
|
||||
}
|
||||
},
|
||||
"oci_regctl_toolchains": {
|
||||
"bzlFile": "@@rules_oci~//oci/private:toolchains_repo.bzl",
|
||||
"ruleClassName": "toolchains_repo",
|
||||
"attributes": {
|
||||
"toolchain_type": "@rules_oci//oci:regctl_toolchain_type",
|
||||
"toolchain": "@oci_regctl_{platform}//:regctl_toolchain"
|
||||
}
|
||||
}
|
||||
},
|
||||
"moduleExtensionMetadata": {
|
||||
"explicitRootModuleDirectDeps": [
|
||||
"eclipse_temurin_17",
|
||||
"eclipse_temurin_17_linux_amd64",
|
||||
"ubuntu_24_04",
|
||||
"ubuntu_24_04_linux_amd64",
|
||||
"alpine_linux",
|
||||
"alpine_linux_linux_amd64"
|
||||
],
|
||||
"explicitRootModuleDirectDevDeps": [],
|
||||
"useAllRepos": "NO",
|
||||
"reproducible": false
|
||||
},
|
||||
"recordedRepoMappingEntries": [
|
||||
[
|
||||
"aspect_bazel_lib~",
|
||||
"bazel_tools",
|
||||
"bazel_tools"
|
||||
],
|
||||
[
|
||||
"bazel_features~",
|
||||
"bazel_tools",
|
||||
"bazel_tools"
|
||||
],
|
||||
[
|
||||
"rules_oci~",
|
||||
"aspect_bazel_lib",
|
||||
"aspect_bazel_lib~"
|
||||
],
|
||||
[
|
||||
"rules_oci~",
|
||||
"bazel_features",
|
||||
"bazel_features~"
|
||||
],
|
||||
[
|
||||
"rules_oci~",
|
||||
"bazel_skylib",
|
||||
"bazel_skylib~"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"@@rules_scala~//scala/extensions:config.bzl%scala_config": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "TdBxhkZTM7VU6teIFS+KoonKU7wmb5BL7leCWWx7yX8=",
|
||||
@@ -1293,7 +1592,7 @@
|
||||
},
|
||||
"@@rules_scala~//scala/extensions:deps.bzl%scala_deps": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "F2PMm61fmZ/IE+VSw1rigJ71hBDD7k3vqyYR1/GgXeA=",
|
||||
"bzlTransitiveDigest": "5SDZrXQHW6tI/VEw+La2OPOK4ZWm0LGTxnChXOXBCag=",
|
||||
"usagesDigest": "kwo8oolISmSSITnit4b4S0vBiUtHlHK0WLDUwScxmOg=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
@@ -4904,85 +5203,6 @@
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"@@toolchains_llvm~//toolchain/extensions:llvm.bzl%llvm": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "afRF0aFOIUrkYl3o040WQ606ep1qciEXzjnAxT3Kek8=",
|
||||
"usagesDigest": "sYVuhiCAQehFTnGTv0bNtTBR4WorebpWBNxF0mRusyw=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
"generatedRepoSpecs": {
|
||||
"llvm_toolchain_llvm": {
|
||||
"bzlFile": "@@toolchains_llvm~//toolchain:rules.bzl",
|
||||
"ruleClassName": "llvm",
|
||||
"attributes": {
|
||||
"alternative_llvm_sources": [],
|
||||
"auth_patterns": {},
|
||||
"distribution": "auto",
|
||||
"exec_arch": "",
|
||||
"exec_os": "",
|
||||
"libclang_rt": {},
|
||||
"llvm_mirror": "",
|
||||
"llvm_version": "20.1.2",
|
||||
"llvm_versions": {},
|
||||
"netrc": "",
|
||||
"sha256": {},
|
||||
"strip_prefix": {},
|
||||
"urls": {}
|
||||
}
|
||||
},
|
||||
"llvm_toolchain": {
|
||||
"bzlFile": "@@toolchains_llvm~//toolchain:rules.bzl",
|
||||
"ruleClassName": "toolchain",
|
||||
"attributes": {
|
||||
"absolute_paths": false,
|
||||
"archive_flags": {},
|
||||
"compile_flags": {},
|
||||
"conly_flags": {},
|
||||
"coverage_compile_flags": {},
|
||||
"coverage_link_flags": {},
|
||||
"cxx_builtin_include_directories": {},
|
||||
"cxx_flags": {},
|
||||
"cxx_standard": {},
|
||||
"dbg_compile_flags": {},
|
||||
"exec_arch": "",
|
||||
"exec_os": "",
|
||||
"extra_exec_compatible_with": {},
|
||||
"extra_target_compatible_with": {},
|
||||
"link_flags": {},
|
||||
"link_libs": {},
|
||||
"llvm_versions": {
|
||||
"": "20.1.2"
|
||||
},
|
||||
"opt_compile_flags": {},
|
||||
"opt_link_flags": {},
|
||||
"stdlib": {},
|
||||
"target_settings": {},
|
||||
"unfiltered_compile_flags": {},
|
||||
"toolchain_roots": {},
|
||||
"sysroot": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"recordedRepoMappingEntries": [
|
||||
[
|
||||
"toolchains_llvm~",
|
||||
"bazel_skylib",
|
||||
"bazel_skylib~"
|
||||
],
|
||||
[
|
||||
"toolchains_llvm~",
|
||||
"bazel_tools",
|
||||
"bazel_tools"
|
||||
],
|
||||
[
|
||||
"toolchains_llvm~",
|
||||
"toolchains_llvm",
|
||||
"toolchains_llvm~"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load", "oci_push")
|
||||
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
|
||||
|
||||
#
|
||||
# Shared utilities layer (busybox for nc, wget, etc.)
|
||||
#
|
||||
|
||||
pkg_tar(
|
||||
name = "busybox_layer",
|
||||
srcs = ["@busybox_x86_64//file"],
|
||||
package_dir = "/usr/local/bin",
|
||||
remap_paths = {
|
||||
"file/busybox": "busybox",
|
||||
},
|
||||
symlinks = {
|
||||
"/usr/local/bin/nc": "busybox",
|
||||
},
|
||||
)
|
||||
|
||||
#
|
||||
# Eagle Server Docker Image
|
||||
#
|
||||
# Build: bazel build //ci:eagle_server_image
|
||||
# Load: bazel run //ci:eagle_server_load
|
||||
# Push: bazel run //ci:eagle_server_push
|
||||
#
|
||||
|
||||
# Package the deploy JAR
|
||||
pkg_tar(
|
||||
name = "eagle_server_jar_layer",
|
||||
srcs = ["//src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
# Package the game resources needed at runtime
|
||||
pkg_tar(
|
||||
name = "eagle_resources_layer",
|
||||
srcs = [
|
||||
"//src/main/resources/net/eagle0/eagle:beasts",
|
||||
"//src/main/resources/net/eagle0/eagle:game_parameters",
|
||||
"//src/main/resources/net/eagle0/eagle:headshots",
|
||||
"//src/main/resources/net/eagle0/eagle:heroes",
|
||||
"//src/main/resources/net/eagle0/eagle:province_map",
|
||||
"//src/main/resources/net/eagle0/eagle:settings",
|
||||
],
|
||||
package_dir = "/app/resources",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "eagle_server_image",
|
||||
base = "@eclipse_temurin_17_linux_amd64",
|
||||
entrypoint = [
|
||||
"java",
|
||||
"-Xmx2g",
|
||||
"-XX:+UseG1GC",
|
||||
# JFR profiling support
|
||||
"-XX:+UnlockDiagnosticVMOptions",
|
||||
"-XX:+DebugNonSafepoints", # Required for JFR to see through inlined methods
|
||||
"-XX:FlightRecorderOptions=stackdepth=256",
|
||||
"-jar",
|
||||
"/app/eagle_server_deploy.jar",
|
||||
],
|
||||
env = {
|
||||
"JAVA_OPTS": "-Xmx2g -XX:+UseG1GC",
|
||||
},
|
||||
exposed_ports = ["40032/tcp"],
|
||||
tars = [
|
||||
":busybox_layer",
|
||||
":eagle_server_jar_layer",
|
||||
":eagle_resources_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally: bazel run //ci:eagle_server_load
|
||||
oci_load(
|
||||
name = "eagle_server_load",
|
||||
image = ":eagle_server_image",
|
||||
repo_tags = ["eagle0/eagle-server:latest"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry
|
||||
# Note: No remote_tags here - DigitalOcean converts OCI to Docker format,
|
||||
# changing the digest and breaking oci_push's tag-by-digest logic.
|
||||
# Tagging is handled in the CI workflow using crane copy/tag.
|
||||
oci_push(
|
||||
name = "eagle_server_push",
|
||||
image = ":eagle_server_image",
|
||||
repository = "registry.digitalocean.com/eagle0/eagle-server",
|
||||
)
|
||||
|
||||
#
|
||||
# Shardok Server Docker Image
|
||||
#
|
||||
# Build: bazel build //ci:shardok_server_image
|
||||
# Load: bazel run //ci:shardok_server_load
|
||||
# Push: bazel run //ci:shardok_server_push
|
||||
#
|
||||
|
||||
# Package the Shardok binary
|
||||
pkg_tar(
|
||||
name = "shardok_binary_layer",
|
||||
srcs = ["//src/main/cpp/net/eagle0/shardok:shardok-server"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
# Package the Shardok resources (battalion types, settings)
|
||||
pkg_tar(
|
||||
name = "shardok_resources_layer",
|
||||
srcs = [
|
||||
"//src/main/resources/net/eagle0/shardok:battalion_types",
|
||||
"//src/main/resources/net/eagle0/shardok:settings",
|
||||
],
|
||||
package_dir = "/app/resources",
|
||||
)
|
||||
|
||||
# Package the converted maps
|
||||
pkg_tar(
|
||||
name = "shardok_maps_layer",
|
||||
srcs = ["//src/main/resources/net/eagle0/shardok/maps"],
|
||||
package_dir = "/app/resources/maps",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "shardok_server_image",
|
||||
base = "@ubuntu_24_04_linux_amd64",
|
||||
entrypoint = ["/app/shardok-server"],
|
||||
exposed_ports = [
|
||||
"40042/tcp",
|
||||
"40052/tcp",
|
||||
],
|
||||
tars = [
|
||||
":busybox_layer",
|
||||
":shardok_binary_layer",
|
||||
":shardok_resources_layer",
|
||||
":shardok_maps_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally: bazel run //ci:shardok_server_load
|
||||
oci_load(
|
||||
name = "shardok_server_load",
|
||||
image = ":shardok_server_image",
|
||||
repo_tags = ["eagle0/shardok-server:latest"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry
|
||||
# Note: No remote_tags here - DigitalOcean converts OCI to Docker format,
|
||||
# changing the digest and breaking oci_push's tag-by-digest logic.
|
||||
# Tagging is handled in the CI workflow using crane copy/tag.
|
||||
oci_push(
|
||||
name = "shardok_server_push",
|
||||
image = ":shardok_server_image",
|
||||
repository = "registry.digitalocean.com/eagle0/shardok-server",
|
||||
)
|
||||
|
||||
#
|
||||
# Admin Server Docker Image (Go)
|
||||
#
|
||||
# Build: bazel build //ci:admin_server_image
|
||||
# Load: bazel run //ci:admin_server_load
|
||||
# Push: bazel run //ci:admin_server_push
|
||||
#
|
||||
|
||||
# Package the Go admin binary (explicit Linux x86_64 target)
|
||||
pkg_tar(
|
||||
name = "admin_binary_layer",
|
||||
srcs = ["//src/main/go/net/eagle0/admin_server:admin_server_linux_amd64"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "admin_server_image",
|
||||
base = "@alpine_linux_linux_amd64",
|
||||
entrypoint = ["/app/admin_server_linux_amd64"],
|
||||
exposed_ports = ["8080/tcp"],
|
||||
tars = [
|
||||
":busybox_layer",
|
||||
":admin_binary_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally: bazel run //ci:admin_server_load
|
||||
oci_load(
|
||||
name = "admin_server_load",
|
||||
image = ":admin_server_image",
|
||||
repo_tags = ["eagle0/admin-server:latest"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry
|
||||
oci_push(
|
||||
name = "admin_server_push",
|
||||
image = ":admin_server_image",
|
||||
repository = "registry.digitalocean.com/eagle0/admin-server",
|
||||
)
|
||||
|
||||
#
|
||||
# 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",
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,46 @@
|
||||
# OAuth Relay Worker
|
||||
|
||||
Cloudflare Worker that relays OAuth callbacks to the eagle0:// custom URL scheme.
|
||||
|
||||
## Why?
|
||||
|
||||
Discord (and some other OAuth providers) don't support custom URL schemes as redirect URIs. This worker acts as a relay:
|
||||
|
||||
1. Discord redirects to `https://eagle0-oauth-relay.<account>.workers.dev/oauth/callback?code=xxx&state=yyy`
|
||||
2. Worker responds with 302 redirect to `eagle0://auth/callback?code=xxx&state=yyy`
|
||||
3. OS opens the Eagle0 app via deep link
|
||||
|
||||
## Deploy
|
||||
|
||||
1. Install wrangler: `npm install -g wrangler`
|
||||
2. Login: `wrangler login`
|
||||
3. Deploy: `wrangler deploy`
|
||||
|
||||
The worker will be available at `https://eagle0-oauth-relay.<your-account>.workers.dev`
|
||||
|
||||
## Test Locally
|
||||
|
||||
```bash
|
||||
wrangler dev
|
||||
# Then in another terminal:
|
||||
curl -I "http://localhost:8787/oauth/callback?code=test&state=abc"
|
||||
```
|
||||
|
||||
## Test Production
|
||||
|
||||
```bash
|
||||
curl -I "https://eagle0-oauth-relay.<your-account>.workers.dev/oauth/callback?code=test&state=abc"
|
||||
```
|
||||
|
||||
Should return:
|
||||
```
|
||||
HTTP/2 302
|
||||
location: eagle0://auth/callback?code=test&state=abc
|
||||
```
|
||||
|
||||
## OAuth Provider Configuration
|
||||
|
||||
In Discord Developer Portal / Google Cloud Console, set the redirect URI to:
|
||||
```
|
||||
https://eagle0-oauth-relay.<your-account>.workers.dev/oauth/callback
|
||||
```
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* OAuth Relay Worker
|
||||
*
|
||||
* Receives OAuth callbacks from providers (Discord, Google) and redirects
|
||||
* to the eagle0:// custom URL scheme for the native app to handle.
|
||||
*
|
||||
* Input: GET /oauth/callback?code=xxx&state=yyy
|
||||
* Output: 302 Redirect to eagle0://auth/callback?code=xxx&state=yyy
|
||||
*/
|
||||
|
||||
export default {
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Only handle /oauth/callback path
|
||||
if (url.pathname !== '/oauth/callback') {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
// Build the deep link URL
|
||||
const deepLink = new URL('eagle0://auth/callback');
|
||||
|
||||
// Forward all query parameters
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
const error = url.searchParams.get('error');
|
||||
const errorDescription = url.searchParams.get('error_description');
|
||||
|
||||
if (code) deepLink.searchParams.set('code', code);
|
||||
if (state) deepLink.searchParams.set('state', state);
|
||||
if (error) deepLink.searchParams.set('error', error);
|
||||
if (errorDescription) deepLink.searchParams.set('error_description', errorDescription);
|
||||
|
||||
console.log(`OAuth relay: redirecting to ${deepLink.toString()}`);
|
||||
|
||||
return Response.redirect(deepLink.toString(), 302);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
name = "eagle0-oauth-relay"
|
||||
main = "worker.js"
|
||||
compatibility_date = "2024-01-01"
|
||||
workers_dev = true
|
||||
@@ -0,0 +1,154 @@
|
||||
# Docker Compose for production deployment
|
||||
#
|
||||
# Local testing:
|
||||
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load
|
||||
# Run: docker compose -f docker-compose.prod.yml up
|
||||
#
|
||||
# Production deployment:
|
||||
# Run: docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
services:
|
||||
eagle:
|
||||
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
|
||||
container_name: eagle-server
|
||||
command:
|
||||
- "--gpt-model-name"
|
||||
- "${GPT_MODEL_NAME:-gpt-5.1}"
|
||||
- "--shardok-interface-remote-address"
|
||||
- "shardok:40042"
|
||||
ports:
|
||||
- "40032:40032"
|
||||
environment:
|
||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
|
||||
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
|
||||
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
|
||||
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
|
||||
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
|
||||
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
|
||||
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
|
||||
volumes:
|
||||
- ./saves:/app/saves
|
||||
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
|
||||
- jvm-tmp:/tmp # Shared with jfr-sidecar for JVM attach socket files
|
||||
depends_on:
|
||||
- shardok
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "5"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z localhost 40032 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
shardok:
|
||||
image: ${SHARDOK_IMAGE:-registry.digitalocean.com/eagle0/shardok-server:latest}
|
||||
container_name: shardok-server
|
||||
mem_limit: 1g
|
||||
memswap_limit: 1g # Prevent swap, OOM-kill cleanly instead
|
||||
ports:
|
||||
- "40042:40042"
|
||||
- "40052:40052"
|
||||
environment:
|
||||
SHARDOK_RESOURCES_PATH: "/app/resources"
|
||||
SHARDOK_MAPS_PATH: "/app/resources/maps"
|
||||
SHARDOK_EAGLE_INTERFACE_ADDRESS: "0.0.0.0:40042"
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "5"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z localhost 40042 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: nginx
|
||||
ports:
|
||||
- "443:443"
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./certbot/conf:/etc/letsencrypt:ro
|
||||
- ./certbot/www:/var/www/certbot:ro
|
||||
- ./auth:/etc/nginx/auth:ro
|
||||
depends_on:
|
||||
- eagle
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
admin:
|
||||
image: ${ADMIN_IMAGE:-registry.digitalocean.com/eagle0/admin-server:latest}
|
||||
container_name: admin-server
|
||||
command:
|
||||
- "--eagle-addr"
|
||||
- "eagle:40032"
|
||||
- "--jfr-sidecar-addr"
|
||||
- "jfr-sidecar:8081"
|
||||
- "--http-port"
|
||||
- "8080"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- eagle
|
||||
- jfr-sidecar
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
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
|
||||
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;'"
|
||||
|
||||
volumes:
|
||||
jvm-tmp:
|
||||
# Shared /tmp for JVM attach socket files between Eagle and jfr-sidecar
|
||||
@@ -0,0 +1,471 @@
|
||||
# Admin Server Enhancement Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines enhancements to the Go admin server (`src/main/go/net/eagle0/admin_server/`) to provide a proper web UI for game administration.
|
||||
|
||||
### Current State
|
||||
|
||||
The admin server provides a full web UI with htmx interactivity:
|
||||
- `GET /` - Redirect to games list
|
||||
- `GET /games` - Game list page (HTML)
|
||||
- `GET /games/{id}` - Game detail with action history
|
||||
- `GET /games/{id}/history` - History rows (htmx partial, infinite scroll)
|
||||
- `GET /games/{id}/action/{index}` - Action detail (htmx partial)
|
||||
- `POST /games/{id}/rewind` - Rewind game to target action
|
||||
- `GET /settings` - Settings list with live search
|
||||
- `POST /settings/update` - Update setting value
|
||||
- `GET /health` - Health check (JSON)
|
||||
- `GET /api/games` - JSON API for programmatic access
|
||||
- `GET /api/games/{id}/history` - JSON API for history
|
||||
|
||||
### Goals
|
||||
|
||||
1. **Web UI**: Replace raw JSON with an interactive HTML interface
|
||||
2. **Settings Management**: View and modify the 275+ game settings at runtime
|
||||
3. **Game Rewind**: Restore a game to a previous action count
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Technology Choice: Go Templates + htmx
|
||||
|
||||
**Rationale:**
|
||||
- Single binary deployment (no separate frontend build)
|
||||
- htmx provides interactivity without JavaScript framework complexity
|
||||
- Familiar HTML/CSS, minimal learning curve
|
||||
- Excellent for admin tools where SEO and bundle size don't matter
|
||||
|
||||
**Alternatives Considered:**
|
||||
- React/Vue SPA: Adds build complexity, separate deployment artifact
|
||||
- Server-side only: Less interactive, full page reloads
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
src/main/go/net/eagle0/admin_server/
|
||||
├── admin_server.go # Main entry point, HTTP routes
|
||||
├── handlers/
|
||||
│ ├── games.go # Game list and detail handlers
|
||||
│ ├── settings.go # Settings list and update handlers
|
||||
│ └── rewind.go # Game rewind handlers
|
||||
├── templates/
|
||||
│ ├── layout.html # Base layout with nav, htmx includes
|
||||
│ ├── games/
|
||||
│ │ ├── list.html # Game list page
|
||||
│ │ ├── detail.html # Single game view with history
|
||||
│ │ └── history.html # Partial for history table (htmx)
|
||||
│ ├── settings/
|
||||
│ │ ├── list.html # Settings list with search/filter
|
||||
│ │ └── edit.html # Inline edit partial (htmx)
|
||||
│ └── rewind/
|
||||
│ └── confirm.html # Rewind confirmation modal
|
||||
├── static/
|
||||
│ ├── style.css # Minimal CSS (Pico CSS or similar)
|
||||
│ └── htmx.min.js # htmx library
|
||||
└── BUILD.bazel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Web UI
|
||||
|
||||
### Routes
|
||||
|
||||
| Route | Method | Description |
|
||||
|-------|--------|-------------|
|
||||
| `/` | GET | Redirect to `/games` |
|
||||
| `/games` | GET | Game list page (HTML) |
|
||||
| `/games/{id}` | GET | Game detail page with history |
|
||||
| `/games/{id}/history` | GET | History partial (htmx, for infinite scroll) |
|
||||
| `/api/games` | GET | JSON API (existing, keep for programmatic access) |
|
||||
| `/api/games/{id}/history` | GET | JSON API (existing) |
|
||||
|
||||
### Game List Page
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Eagle Admin [Settings] [Health] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Running Games (3) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Game abc123f Round 45 │ │
|
||||
│ │ Players: Liu Bei (Human), Cao Cao (AI), Sun Quan │ │
|
||||
│ │ Actions: 1,234 [View] [Rewind]│ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Game def456a Round 12 │ │
|
||||
│ │ Players: Test Player (Human) │ │
|
||||
│ │ Actions: 456 [View] [Rewind]│ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Game Detail Page
|
||||
|
||||
Shows game info and scrollable action history:
|
||||
- **Reverse chronological order**: Most recent actions displayed first
|
||||
- Each action shows: index, type, round ID
|
||||
- **Clickable actions**: Clicking an action row expands to show JSON representation of the full action data
|
||||
- "Rewind to here" button on each action row
|
||||
- Infinite scroll loads more history via htmx (loading older actions as user scrolls down)
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
1. **Embed static files**: Use `//go:embed` to bundle templates and static files
|
||||
2. **Template functions**: Add helpers for formatting (hex IDs, timestamps, action summaries)
|
||||
3. **CSS framework**: Use Pico CSS (~10KB) for clean defaults without classes
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Settings Management
|
||||
|
||||
### New gRPC Endpoints (Eagle Server)
|
||||
|
||||
Add to `eagle.proto`:
|
||||
|
||||
```protobuf
|
||||
message Setting {
|
||||
string name = 1;
|
||||
string type = 2; // "Int" or "Double"
|
||||
string value = 3; // Current value as string
|
||||
string default_value = 4; // Default from BUILD.bazel
|
||||
string description = 5; // Optional, for UI hints
|
||||
}
|
||||
|
||||
message GetSettingsRequest {
|
||||
string filter = 1; // Optional name filter (substring match)
|
||||
}
|
||||
|
||||
message GetSettingsResponse {
|
||||
repeated Setting settings = 1;
|
||||
}
|
||||
|
||||
message UpdateSettingRequest {
|
||||
string name = 1;
|
||||
string value = 2;
|
||||
}
|
||||
|
||||
message UpdateSettingResponse {
|
||||
Setting setting = 1; // Updated setting
|
||||
string error = 2; // Empty on success
|
||||
}
|
||||
|
||||
service Eagle {
|
||||
// ... existing methods ...
|
||||
rpc GetSettings(GetSettingsRequest) returns (GetSettingsResponse);
|
||||
rpc UpdateSetting(UpdateSettingRequest) returns (UpdateSettingResponse);
|
||||
}
|
||||
```
|
||||
|
||||
### Eagle Server Implementation
|
||||
|
||||
Create a settings registry that:
|
||||
1. Discovers all `IntSetting` and `DoubleSetting` instances via reflection or explicit registration
|
||||
2. Provides get/set by name
|
||||
3. Validates types on update
|
||||
|
||||
```scala
|
||||
// src/main/scala/net/eagle0/eagle/library/settings/SettingsRegistry.scala
|
||||
object SettingsRegistry {
|
||||
private val settings: Map[String, Either[IntSetting, DoubleSetting]] = Map(
|
||||
"ActionVigorCost" -> Left(ActionVigorCost),
|
||||
"BaseFoodBuyPrice" -> Right(BaseFoodBuyPrice),
|
||||
// ... register all 275 settings
|
||||
)
|
||||
|
||||
def getAll(filter: Option[String]): Seq[Setting] = ...
|
||||
def get(name: String): Option[Setting] = ...
|
||||
def update(name: String, value: String): Either[String, Setting] = ...
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative: Code generation**
|
||||
|
||||
Rather than manually registering 275 settings, modify `setting_rule.bzl` to generate a registry file during build.
|
||||
|
||||
### Admin Server Routes
|
||||
|
||||
| Route | Method | Description |
|
||||
|-------|--------|-------------|
|
||||
| `/settings` | GET | Settings list page with search |
|
||||
| `/settings/{name}` | GET | Single setting detail (htmx partial) |
|
||||
| `/settings/{name}` | PUT | Update setting value |
|
||||
| `/api/settings` | GET | JSON API |
|
||||
| `/api/settings/{name}` | PUT | JSON API |
|
||||
|
||||
### Settings UI
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Eagle Admin [Games] [Health] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Settings [Search: __________ ] │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ ActionVigorCost (Int) │ │
|
||||
│ │ Current: [15 ] Default: 15 [Save] │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ BaseFoodBuyPrice (Double) │ │
|
||||
│ │ Current: [0.5 ] Default: 0.5 [Save] │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ... (275 settings, virtualized/paginated) ... │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Considerations
|
||||
|
||||
1. **Persistence**: Settings changes are in-memory only. Document that restarts reset to defaults.
|
||||
2. **Validation**: Validate numeric ranges where applicable (e.g., percentages 0-100)
|
||||
3. **Categories**: Consider grouping settings by prefix (AI*, Combat*, Economy*, etc.)
|
||||
4. **Audit log**: Log setting changes with timestamp for debugging
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: Game Rewind
|
||||
|
||||
### Concept
|
||||
|
||||
Restore a game to a previous point in its action history. This is useful for:
|
||||
- Debugging issues that occurred at a specific point
|
||||
- Testing "what if" scenarios
|
||||
- Recovering from bugs that corrupted state
|
||||
|
||||
### New gRPC Endpoint
|
||||
|
||||
Add to `eagle.proto`:
|
||||
|
||||
```protobuf
|
||||
message RewindGameRequest {
|
||||
int64 game_id = 1;
|
||||
int32 target_action_count = 2; // Rewind to state after this many actions
|
||||
}
|
||||
|
||||
message RewindGameResponse {
|
||||
bool success = 1;
|
||||
string error = 2;
|
||||
int32 new_action_count = 3;
|
||||
int32 disconnected_clients = 4; // Number of clients that were disconnected
|
||||
}
|
||||
|
||||
service Eagle {
|
||||
// ... existing methods ...
|
||||
rpc RewindGame(RewindGameRequest) returns (RewindGameResponse);
|
||||
}
|
||||
```
|
||||
|
||||
### Eagle Server Implementation
|
||||
|
||||
The `GameHistory` already stores `ActionWithResultingState` for each action, which includes the `GameState` after that action. Rewinding means:
|
||||
|
||||
1. **Validate**: Check that `target_action_count` is within valid range (0 to current count)
|
||||
2. **Get target state**: Retrieve `GameState` at target action count from history
|
||||
3. **Disconnect clients**: Close all human player connections (they'll need to reconnect)
|
||||
4. **Replace engine**: Create new `EngineImpl` with target state and truncated history
|
||||
5. **Reset AI state**: Clear any cached AI state that depends on current game state
|
||||
|
||||
```scala
|
||||
// GameController.scala (pseudocode)
|
||||
def rewindTo(targetActionCount: Int): Either[String, RewindResult] = {
|
||||
if (targetActionCount < 0 || targetActionCount > engine.history.count)
|
||||
return Left(s"Invalid action count: $targetActionCount")
|
||||
|
||||
// Get state at target point
|
||||
val targetState = engine.history.stateAt(targetActionCount)
|
||||
val truncatedHistory = engine.history.truncateTo(targetActionCount)
|
||||
|
||||
// Disconnect all human clients
|
||||
val disconnectedCount = humanClients.length
|
||||
humanClients.foreach(_.disconnect("Game rewound by admin"))
|
||||
|
||||
// Create new engine at target state
|
||||
val newEngine = EngineImpl(
|
||||
gameId = engine.gameId,
|
||||
currentState = targetState,
|
||||
history = truncatedHistory,
|
||||
// ... other fields
|
||||
)
|
||||
|
||||
// Replace controller's engine
|
||||
this.engine = newEngine
|
||||
|
||||
Right(RewindResult(targetActionCount, disconnectedCount))
|
||||
}
|
||||
```
|
||||
|
||||
### GameHistory Enhancement
|
||||
|
||||
Add method to get state at a specific action count:
|
||||
|
||||
```scala
|
||||
trait GameHistory {
|
||||
// ... existing methods ...
|
||||
|
||||
def stateAt(actionCount: Int): GameState = {
|
||||
if (actionCount == 0) initialState
|
||||
else all(actionCount - 1).resultingState
|
||||
}
|
||||
|
||||
def truncateTo(actionCount: Int): GameHistory = {
|
||||
GameHistoryImpl(
|
||||
initialState = initialState,
|
||||
actions = all.take(actionCount)
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Server Route
|
||||
|
||||
| Route | Method | Description |
|
||||
|-------|--------|-------------|
|
||||
| `/games/{id}/rewind` | POST | Rewind game (form: `target_action_count`) |
|
||||
| `/games/{id}/rewind/confirm` | GET | Confirmation modal (htmx partial) |
|
||||
|
||||
### Rewind UI Flow
|
||||
|
||||
1. User views game history
|
||||
2. User clicks "Rewind to here" on an action row
|
||||
3. Confirmation modal appears via htmx:
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Rewind Game abc123f? │
|
||||
│ │
|
||||
│ This will: │
|
||||
│ • Restore to action 456 (Round 23) │
|
||||
│ • Discard 778 subsequent actions │
|
||||
│ • Disconnect 2 connected players │
|
||||
│ │
|
||||
│ This cannot be undone. │
|
||||
│ │
|
||||
│ [Cancel] [Rewind] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
4. On confirm, POST to `/games/{id}/rewind`
|
||||
5. Success: redirect to game detail showing new state
|
||||
6. Error: show error message
|
||||
|
||||
### Safety Considerations
|
||||
|
||||
1. **No undo**: Rewinding discards history. Consider optional backup before rewind.
|
||||
2. **Client disconnect**: All connected clients are forcibly disconnected.
|
||||
3. **AI state**: Ensure AI clients restart cleanly after rewind.
|
||||
4. **Concurrent access**: Lock game during rewind to prevent race conditions.
|
||||
5. **Authorization**: In production, require admin authentication.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Web UI Foundation
|
||||
|
||||
**Status: Complete**
|
||||
|
||||
1. ✅ Set up Go templates with `embed`
|
||||
2. ✅ Add Pico CSS and htmx
|
||||
3. ✅ Create base layout with navigation
|
||||
4. ✅ Convert `/games` to HTML with styling
|
||||
5. ✅ Add game detail page with history table
|
||||
6. ✅ Implement htmx infinite scroll for history
|
||||
7. ✅ Reverse history order (most recent first)
|
||||
8. ✅ Clickable action rows that expand to show JSON representation
|
||||
9. ✅ Add `/games/{id}/action/{index}` endpoint for fetching action details
|
||||
|
||||
**Deliverable**: Browsable game list and history in HTML with clickable action details
|
||||
|
||||
### Phase 2: Settings Management
|
||||
|
||||
**Status: Complete**
|
||||
|
||||
1. ✅ Add `GetSettings` to `eagle.proto` (uses existing `AddSettings` for updates)
|
||||
2. ✅ Add `getAllSettings` method to auto-generated `SettingsLoader`
|
||||
3. ✅ Implement `getSettings` in `EagleServiceImpl`
|
||||
4. ✅ Create settings list page with live search
|
||||
5. ✅ Add inline editing with htmx
|
||||
6. ✅ Modified settings are highlighted
|
||||
|
||||
**Deliverable**: View and edit settings via admin UI
|
||||
|
||||
### Phase 3: Game Rewind
|
||||
|
||||
**Status: Complete**
|
||||
|
||||
1. ✅ Add `RewindGame` to `eagle.proto`
|
||||
2. ✅ Implement `stateAt` and `truncateTo` in `GameHistory`
|
||||
3. ✅ Implement rewind logic in `Engine` and `GameController`
|
||||
4. ✅ Add rewind confirmation (htmx `hx-confirm` dialog)
|
||||
5. ✅ Handle client disconnection gracefully
|
||||
6. ✅ Add rewind button to history rows
|
||||
7. ✅ Implement `rewindGame` in `GamesManager` and `EagleServiceImpl`
|
||||
8. ✅ Add admin server `/games/{id}/rewind` POST handler
|
||||
9. ✅ Add success/error feedback UI
|
||||
|
||||
**Deliverable**: Rewind games to any previous action
|
||||
|
||||
### Phase 4: Polish
|
||||
|
||||
**Status: Not Started**
|
||||
|
||||
#### High Priority
|
||||
1. **Add tests for rewind functionality**
|
||||
- `PersistedHistory.truncateTo` (handles complex persisted vs recent logic)
|
||||
- `InMemoryHistory.truncateTo`
|
||||
- `EngineImpl.rewindTo`
|
||||
- `GameController.rewindTo`
|
||||
- `GamesManager.rewindGame`
|
||||
|
||||
2. **Improve action history display**
|
||||
- Human-readable action type names (e.g., "New Round" instead of "NewRoundAction")
|
||||
- Show acting faction/province when available
|
||||
- Action summaries from the `summary` field in `GameHistoryEntry`
|
||||
|
||||
#### Medium Priority
|
||||
3. **Settings improvements**
|
||||
- Group settings by category prefix (AI*, Combat*, Economy*, etc.)
|
||||
- Show setting descriptions where available
|
||||
- Pagination for large settings lists
|
||||
|
||||
4. **Error handling improvements**
|
||||
- Better error messages on failed operations
|
||||
- Retry logic for transient gRPC failures
|
||||
|
||||
#### Low Priority (Nice to Have)
|
||||
5. **Basic auth** - HTTP Basic Auth or OAuth for production use
|
||||
6. **Audit logging** - Log admin actions with timestamps
|
||||
7. **Documentation** - Usage guide, deployment notes
|
||||
|
||||
#### Future Considerations
|
||||
- Game creation from admin UI
|
||||
- Player management (view connected players, force disconnect)
|
||||
- Export game history to file
|
||||
- Metrics/stats dashboard
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
The admin server is intended for local/trusted network use only. For production:
|
||||
|
||||
1. **Do not expose to public internet** without authentication
|
||||
2. Consider adding HTTP Basic Auth or OAuth
|
||||
3. Run on internal network or behind VPN
|
||||
4. Log all admin actions for audit trail
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Settings persistence**: Should we add optional persistence to disk/database?
|
||||
2. **Game snapshots**: Should rewind create a backup first?
|
||||
3. **Multi-admin**: Need locking if multiple admins access simultaneously?
|
||||
4. **Shardok settings**: Are there Shardok (C++) settings to expose too?
|
||||
+88
-31
@@ -197,34 +197,86 @@ Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versi
|
||||
|
||||
### Remaining Proto Usage in Actions
|
||||
|
||||
The following actions still have proto usage, blocked by utility dependencies:
|
||||
**Progress: 47 of 52 action files (90%) are fully protoless.**
|
||||
|
||||
| Action | Proto Usage | Blocker |
|
||||
|--------|-------------|---------|
|
||||
| `EndBattleAftermathPhaseAction` | 1 `toProto` call | `ProvinceViewFilter` needs Scala types |
|
||||
| `NewRoundAction` | 1 `fromProto` call | `ChronicleEventGenerator` returns proto |
|
||||
| `EndHandleRiotsPhaseAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
|
||||
| `PerformVassalCommandsPhaseAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
|
||||
| `PerformVassalDefenseDecisionsAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
|
||||
| `EndVassalCommandsPhaseAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
|
||||
| `PerformReconResolutionAction` | Uses `ProvinceViewFilter` | `ProvinceViewFilter` needs Scala types |
|
||||
| `ResolveBattleAction` | Heavy proto usage | Large refactor needed |
|
||||
The following 5 actions still have proto usage:
|
||||
|
||||
| Action | Proto Usages | Blocker | Effort |
|
||||
|--------|--------------|---------|--------|
|
||||
| `ResolveBattleAction` | 24 | Shardok interface, complex battle logic | High |
|
||||
| `PerformVassalCommandsPhaseAction` | 3 | `CommandChoiceHelpers` takes proto GameState | Medium |
|
||||
| `EndHandleRiotsPhaseAction` | 2 | `CommandChoiceHelpers` takes proto GameState | Medium |
|
||||
| `PerformVassalDefenseDecisionsAction` | 2 | `CommandChoiceHelpers` takes proto GameState | Medium |
|
||||
| `EndVassalCommandsPhaseAction` | 1 | `CommandChoiceHelpers` takes proto GameState | Medium |
|
||||
|
||||
**Note:** `NewRoundAction` is now fully protoless after converting `ChronicleEventGenerator` to return Scala `ChronicleEvent` types directly.
|
||||
|
||||
**Deleted Dead Code:**
|
||||
- `UnaffiliatedHeroMovedAction` - Was never called; `PerformUnaffiliatedHeroesAction.heroMovedResult` constructs `ActionResultC` directly
|
||||
- `HeroBackstoryUpdateActionGenerator.fromGameState` - Dead method that converted proto to Scala; only `apply(GameState)` is used
|
||||
|
||||
**Note**: `PerformReconResolutionAction` and `EndBattleAftermathPhaseAction` are now fully protoless after:
|
||||
1. Migrating `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` to use Scala `ProvinceView`
|
||||
2. Adding Scala overload of `ProvinceViewFilter.withdrawnFromProvinceView`
|
||||
|
||||
### Estimated Effort (Remaining)
|
||||
|
||||
| Component | Lines | Complexity |
|
||||
|-----------|-------|------------|
|
||||
| `ProvinceViewFilter` to Scala | ~150 | Medium |
|
||||
| `CommandChoiceHelpers` to Scala | ~2000 | High |
|
||||
| `ChronicleEventGenerator` to Scala | ~400 | Medium |
|
||||
| History API updates | ~100 | Low |
|
||||
| **Total Remaining** | **~2650** | |
|
||||
| Component | Lines | Complexity | Blocks |
|
||||
|-----------|-------|------------|--------|
|
||||
| `CommandChoiceHelpers` to Scala | ~2000 | High | 4 vassal actions |
|
||||
| `ResolveBattleAction` refactor | ~500 | High | 1 action (complex) |
|
||||
| History API updates | ~100 | Low | - |
|
||||
| **Total Remaining** | **~2600** | | |
|
||||
|
||||
**Completed:**
|
||||
- `ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
|
||||
|
||||
### CommandChoiceHelpers Migration Status
|
||||
|
||||
Several command selectors have already been converted to use Scala types:
|
||||
|
||||
| File | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| `AttackCommandChooser.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
|
||||
| `AlmsCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
|
||||
| `FoodConsumptionUtils.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `RoundPhase` |
|
||||
| `MarchSuppliesHelpers.scala` | ✅ **Protoless** | Uses `BattalionT` |
|
||||
| `CombatUnitSelector.scala` | ✅ **Protoless** | Uses `HeroT`, `BattalionT`, `BattalionType` |
|
||||
| `ExpandCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `FactionT` |
|
||||
| `ImproveCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `HeroT` |
|
||||
| `OrganizeCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `BattalionT`, `BattalionType` |
|
||||
| `RansomOfferHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState`, `FactionT` |
|
||||
| `CommandChoiceHelpers.scala` | ❌ Proto | Main entry point, converts to Scala when calling converted selectors |
|
||||
| `ProvinceGoldSurplusCalculator.scala` | **Partial** | Has both Scala and proto overloads |
|
||||
| Other selectors | ❌ Proto | Various proto dependencies |
|
||||
|
||||
**Pattern**: `CommandChoiceHelpers` currently uses `GameStateConverter.fromProto(gameState)` when calling already-converted selectors like `AlmsCommandSelector` and `AttackCommandChooser`. This allows incremental migration.
|
||||
|
||||
**Next Steps**:
|
||||
1. ~~Convert `ExpandCommandSelector` to Scala types~~ ✅ Done
|
||||
2. ~~Convert `ImproveCommandSelector` to Scala types~~ ✅ Done
|
||||
3. ~~Convert `OrganizeCommandSelector` to Scala types~~ ✅ Done (PR #4812)
|
||||
4. ~~Convert `RansomOfferHelpers` to Scala types~~ ✅ Done (PR #4821)
|
||||
5. Convert remaining selectors one at a time
|
||||
6. Update `CommandChoiceHelpers` to accept Scala `GameState` once all selectors are converted
|
||||
|
||||
### Progress Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Action files fully protoless | 47 / 52 (90%) |
|
||||
| Proto usages in remaining actions | 32 total |
|
||||
| Biggest blocker | `ResolveBattleAction` (24 usages) |
|
||||
| Second biggest blocker | `CommandChoiceHelpers` (blocks 4 actions) |
|
||||
|
||||
### Validation
|
||||
- [x] `ActionResultApplier` created and tested
|
||||
- [x] `RandomStateSequencer` threads Scala GameState throughout
|
||||
- [x] `RoundPhaseAdvancer` uses T-types internally
|
||||
- [ ] `ProvinceViewFilter` uses 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
|
||||
- [ ] `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
|
||||
@@ -248,22 +300,27 @@ Remove remaining direct proto imports from utility classes.
|
||||
| `BattalionTypeLoader.scala` | Keep proto for file loading, convert immediately after |
|
||||
| `BeastUtils.scala` | **Complete** - now uses Scala `BeastInfo` only |
|
||||
|
||||
### View Filters (Blocking Full Deproto)
|
||||
### View Filters (Partially Complete)
|
||||
|
||||
The `ProvinceViewFilter` utility currently works entirely with proto types, blocking full deproto of actions that generate province views:
|
||||
The view filter utilities now have Scala overloads for server-side use:
|
||||
|
||||
| File | Issue | Needed |
|
||||
|------|-------|--------|
|
||||
| `ProvinceViewFilter.scala` | Takes proto `Province`/`GameState`, returns proto `ProvinceView` | Scala `ProvinceViewT` model |
|
||||
| `GameStateViewFilter.scala` | Uses proto types throughout | Depends on `ProvinceViewT` |
|
||||
| `GameStateViewDiffer.scala` | Works with view protos | Depends on `ProvinceViewT` |
|
||||
| File | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| `ProvinceViewFilter.scala` | **Partial** | `filteredProvinceView(ProvinceT, ScalaGameState)` added (PR #4752) |
|
||||
| `ArmyFilter.scala` | **Partial** | `filterArmy(ScalaArmy, Map[BattalionId, BattalionT], Option[FactionId])` added |
|
||||
| `BattalionViewFilter.scala` | **Complete** | Uses Scala `BattalionT` throughout |
|
||||
| `GameStateViewFilter.scala` | Pending | Uses proto types throughout |
|
||||
| `GameStateViewDiffer.scala` | Pending | Works with view protos |
|
||||
|
||||
**Blocked Actions**:
|
||||
- `EndBattleAftermathPhaseAction` - uses `ProvinceViewFilter` for `revelationChange`, requires lazy proto conversion
|
||||
- `PerformReconResolutionAction` - uses `ProvinceViewFilter` for reconned provinces
|
||||
- `GameStateFactionExtensions` - uses `ProvinceViewFilter` for `updatedReconnedProvinces`
|
||||
**Unblocked Actions** (PR #4752):
|
||||
- `EndBattleAftermathPhaseAction` - can now use `filteredProvinceView(province, scalaGameState)`
|
||||
- `PerformReconResolutionAction` - can now use Scala overload
|
||||
- `GameStateFactionExtensions` - can now use `updatedReconnedProvinces` with Scala types
|
||||
|
||||
**Solution**: Create Scala `ProvinceViewT` (and possibly `ProvinceViewC`) that mirrors the proto `ProvinceView`. Then create a protoless `ProvinceViewFilter` that operates on Scala types. The proto version can delegate to the Scala version + convert, or we maintain both during transition.
|
||||
**Remaining Work**:
|
||||
- Faction-filtered `filteredProvinceView(Province, GameState, FactionId)` still uses proto types
|
||||
- `withdrawnFromProvinceView` still uses proto types
|
||||
- These are needed for client-facing views with visibility restrictions
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# Discord + Google OAuth Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Replace HTTP Basic Auth with OAuth 2.0 (Discord + Google) for Eagle0. Users authenticate via system browser, receive JWT tokens, and choose their own display names.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Unity Client Eagle Server
|
||||
| |
|
||||
| 1. Click "Login with Discord/Google" |
|
||||
| -------------------------------------------------> |
|
||||
| GetOAuthUrl(provider) -> auth_url + state |
|
||||
| |
|
||||
| 2. Open system browser -> OAuth consent |
|
||||
| 3. User authenticates with provider |
|
||||
| 4. Redirect to eagle0://auth/callback?code=xxx |
|
||||
| |
|
||||
| 5. ExchangeCode(code, state) |
|
||||
| -------------------------------------------------> |
|
||||
| Exchange code with provider |
|
||||
| Fetch user info (id, email, avatar) |
|
||||
| Create/update user record |
|
||||
| Issue JWT + refresh token |
|
||||
| <------------------------------------------------- |
|
||||
| (jwt, refresh_token, user_info, is_new_user) |
|
||||
| |
|
||||
| 6. [If new user] SetDisplayName(name) |
|
||||
| -------------------------------------------------> |
|
||||
| |
|
||||
| 7. Subsequent gRPC calls |
|
||||
| Authorization: Bearer <jwt> |
|
||||
| -------------------------------------------------> |
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| OAuth flow | System browser + deep link | Secure, supports password managers |
|
||||
| Code exchange | Eagle server directly | No separate auth service needed |
|
||||
| JWT signing | RS256 (asymmetric) | Future flexibility for token verification |
|
||||
| User storage | Protobuf file via Persister | Consistent with existing patterns |
|
||||
| Token expiry | 7-day access, 30-day refresh | Balance security and gaming UX |
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Proto Definitions & Infrastructure
|
||||
|
||||
**New files:**
|
||||
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth API messages
|
||||
- `src/main/protobuf/net/eagle0/eagle/internal/user.proto` - User storage schema
|
||||
|
||||
**Key proto messages:**
|
||||
```protobuf
|
||||
// API
|
||||
GetOAuthUrlRequest/Response // Get OAuth URL to open in browser
|
||||
ExchangeCodeRequest/Response // Exchange auth code for JWT
|
||||
SetDisplayNameRequest/Response // Set user's display name
|
||||
RefreshTokenRequest/Response // Refresh expired access token
|
||||
|
||||
// Internal storage
|
||||
User // user_id, display_name, oauth_identities
|
||||
UserDatabase // All users + indexes for lookup
|
||||
```
|
||||
|
||||
### Phase 2: Eagle Server Auth Services
|
||||
|
||||
**New Scala files:**
|
||||
- `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` - Discord/Google config from env vars
|
||||
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation (RS256)
|
||||
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD, display name validation
|
||||
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth code exchange
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC service implementation
|
||||
|
||||
**Modify:**
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala`
|
||||
- Replace Basic Auth parsing with JWT validation
|
||||
- Skip auth for public endpoints (GetOAuthUrl, ExchangeCode, RefreshToken)
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala`
|
||||
- Change context keys from `userName` to `userId` + `displayName`
|
||||
- `src/main/scala/net/eagle0/eagle/service/Main.scala`
|
||||
- Wire up new auth services and JWT key loading
|
||||
|
||||
### Phase 3: Unity Client OAuth Flow
|
||||
|
||||
**New C# files:**
|
||||
- `Assets/Auth/OAuthManager.cs` - OAuth flow + deep link handling
|
||||
- `Assets/Auth/TokenStorage.cs` - Secure token persistence
|
||||
- `Assets/Auth/AuthClient.cs` - gRPC client for auth service
|
||||
|
||||
**Modify:**
|
||||
- `Assets/EagleConnection.cs`
|
||||
- Replace `AuthInterceptor` (Basic Auth) with `JwtAuthInterceptor` (Bearer token)
|
||||
- `Assets/ConnectionHandler/ConnectionHandler.cs`
|
||||
- Replace username/password UI with Discord/Google login buttons
|
||||
- Add display name setup flow for new users
|
||||
|
||||
### Phase 4: Platform Configuration
|
||||
|
||||
**Deep link registration:**
|
||||
- iOS: Add `eagle0://` to CFBundleURLSchemes in Info.plist
|
||||
- Android: Add intent-filter for `eagle0://auth` in AndroidManifest.xml
|
||||
- Desktop: Register URL scheme (Windows registry / macOS plist)
|
||||
|
||||
**OAuth provider setup:**
|
||||
1. Discord Developer Portal: Create app, add redirect URI `eagle0://auth/callback`
|
||||
2. Google Cloud Console: Create OAuth client, add redirect URI
|
||||
|
||||
**Environment variables (server):**
|
||||
```
|
||||
DISCORD_CLIENT_ID
|
||||
DISCORD_CLIENT_SECRET
|
||||
GOOGLE_CLIENT_ID
|
||||
GOOGLE_CLIENT_SECRET
|
||||
JWT_PRIVATE_KEY_PATH
|
||||
JWT_PUBLIC_KEY_PATH
|
||||
```
|
||||
|
||||
### Phase 5: Testing
|
||||
|
||||
**Unit tests:**
|
||||
- `JwtServiceSpec.scala` - Token creation/validation
|
||||
- `UserServiceSpec.scala` - Display name validation, uniqueness
|
||||
- `OAuthServiceSpec.scala` - OAuth flow with mocked providers
|
||||
|
||||
**Integration tests:**
|
||||
- Full OAuth flow with mock provider
|
||||
- JWT validation in AuthorizationInterceptor
|
||||
- gRPC calls with valid/invalid tokens
|
||||
|
||||
**Manual testing:**
|
||||
- [ ] Discord login (Windows, macOS)
|
||||
- [ ] Google login (Windows, macOS)
|
||||
- [ ] Deep link callback works
|
||||
- [ ] Display name validation
|
||||
- [ ] Session persistence across restarts
|
||||
- [ ] Token refresh
|
||||
|
||||
## Files Summary
|
||||
|
||||
### Create
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/main/protobuf/net/eagle0/eagle/api/auth.proto` | Auth API definitions |
|
||||
| `src/main/protobuf/net/eagle0/eagle/internal/user.proto` | User storage schema |
|
||||
| `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` | Provider config |
|
||||
| `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` | JWT handling |
|
||||
| `src/main/scala/net/eagle0/eagle/auth/UserService.scala` | User management |
|
||||
| `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` | OAuth flow |
|
||||
| `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` | gRPC service |
|
||||
| `Assets/Auth/OAuthManager.cs` | Unity OAuth manager |
|
||||
| `Assets/Auth/TokenStorage.cs` | Token storage |
|
||||
| `Assets/Auth/AuthClient.cs` | Auth gRPC client |
|
||||
|
||||
### Modify
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `AuthorizationInterceptor.scala` | Basic Auth -> JWT validation |
|
||||
| `AuthorizationUtils.scala` | userName -> userId + displayName |
|
||||
| `Main.scala` | Wire auth services |
|
||||
| `EagleConnection.cs` | AuthInterceptor -> JwtAuthInterceptor |
|
||||
| `ConnectionHandler.cs` | Login UI -> OAuth buttons + display name |
|
||||
|
||||
### Delete
|
||||
- nginx htpasswd configuration (no longer needed)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **State parameter** - CSRF protection in OAuth flow
|
||||
2. **PKCE** - Consider adding for mobile (enhancement)
|
||||
3. **Secure storage** - Use Keychain (iOS) / Keystore (Android) for tokens
|
||||
4. **Token refresh** - 7-day access tokens with 30-day refresh
|
||||
5. **Rate limiting** - Limit login attempts per IP
|
||||
|
||||
## Dependencies to Add
|
||||
|
||||
**Scala (MODULE.bazel):**
|
||||
- JWT library (e.g., `jwt-scala` or `nimbus-jose-jwt`)
|
||||
- HTTP client (e.g., `sttp` for OAuth requests)
|
||||
|
||||
**Unity:**
|
||||
- Deep linking is built-in (Unity 2021+)
|
||||
- No additional packages required
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
Keep Basic Auth code in a feature branch. Both auth methods can coexist during transition via feature flag if needed.
|
||||
File diff suppressed because it is too large
Load Diff
+54
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
|
||||
"__INPUT_ARTIFACTS_HASH": 289080209,
|
||||
"__RESOLVED_ARTIFACTS_HASH": -131178107,
|
||||
"__INPUT_ARTIFACTS_HASH": -1064460283,
|
||||
"__RESOLVED_ARTIFACTS_HASH": -1574144850,
|
||||
"conflict_resolution": {
|
||||
"com.google.guava:failureaccess:1.0.1": "com.google.guava:failureaccess:1.0.2",
|
||||
"com.squareup.okio:okio:2.10.0": "com.squareup.okio:okio:3.6.0",
|
||||
@@ -48,6 +48,12 @@
|
||||
},
|
||||
"version": "2.12.7"
|
||||
},
|
||||
"com.github.stephenc.jcip:jcip-annotations": {
|
||||
"shasums": {
|
||||
"jar": "4fccff8382aafc589962c4edb262f6aa595e34f1e11e61057d1c6a96e8fc7323"
|
||||
},
|
||||
"version": "1.0-1"
|
||||
},
|
||||
"com.google.android:annotations": {
|
||||
"shasums": {
|
||||
"jar": "ba734e1e84c09d615af6a09d33034b4f0442f8772dec120efb376d86a565ae15"
|
||||
@@ -156,6 +162,12 @@
|
||||
},
|
||||
"version": "1.4.2"
|
||||
},
|
||||
"com.nimbusds:nimbus-jose-jwt": {
|
||||
"shasums": {
|
||||
"jar": "12ae4a3a260095d7aeba2adea7ae396e8b9570db8b7b409e09a824c219cc0444"
|
||||
},
|
||||
"version": "9.37.3"
|
||||
},
|
||||
"com.squareup.okhttp3:okhttp": {
|
||||
"shasums": {
|
||||
"jar": "b1050081b14bb7a3a7e55a4d3ef01b5dcfabc453b4573a4fc019767191d5f4e0"
|
||||
@@ -810,6 +822,9 @@
|
||||
"org.checkerframework:checker-qual",
|
||||
"org.ow2.asm:asm"
|
||||
],
|
||||
"com.nimbusds:nimbus-jose-jwt": [
|
||||
"com.github.stephenc.jcip:jcip-annotations"
|
||||
],
|
||||
"com.squareup.okhttp3:okhttp": [
|
||||
"com.squareup.okio:okio",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
||||
@@ -1355,6 +1370,9 @@
|
||||
"com.fasterxml.jackson.databind.type",
|
||||
"com.fasterxml.jackson.databind.util"
|
||||
],
|
||||
"com.github.stephenc.jcip:jcip-annotations": [
|
||||
"net.jcip.annotations"
|
||||
],
|
||||
"com.google.android:annotations": [
|
||||
"android.annotation"
|
||||
],
|
||||
@@ -1500,6 +1518,38 @@
|
||||
"com.google.truth:truth": [
|
||||
"com.google.common.truth"
|
||||
],
|
||||
"com.nimbusds:nimbus-jose-jwt": [
|
||||
"com.nimbusds.jose",
|
||||
"com.nimbusds.jose.crypto",
|
||||
"com.nimbusds.jose.crypto.bc",
|
||||
"com.nimbusds.jose.crypto.factories",
|
||||
"com.nimbusds.jose.crypto.impl",
|
||||
"com.nimbusds.jose.crypto.opts",
|
||||
"com.nimbusds.jose.crypto.utils",
|
||||
"com.nimbusds.jose.jca",
|
||||
"com.nimbusds.jose.jwk",
|
||||
"com.nimbusds.jose.jwk.gen",
|
||||
"com.nimbusds.jose.jwk.source",
|
||||
"com.nimbusds.jose.mint",
|
||||
"com.nimbusds.jose.proc",
|
||||
"com.nimbusds.jose.produce",
|
||||
"com.nimbusds.jose.shaded.gson",
|
||||
"com.nimbusds.jose.shaded.gson.annotations",
|
||||
"com.nimbusds.jose.shaded.gson.internal",
|
||||
"com.nimbusds.jose.shaded.gson.internal.bind",
|
||||
"com.nimbusds.jose.shaded.gson.internal.bind.util",
|
||||
"com.nimbusds.jose.shaded.gson.internal.reflect",
|
||||
"com.nimbusds.jose.shaded.gson.internal.sql",
|
||||
"com.nimbusds.jose.shaded.gson.reflect",
|
||||
"com.nimbusds.jose.shaded.gson.stream",
|
||||
"com.nimbusds.jose.util",
|
||||
"com.nimbusds.jose.util.cache",
|
||||
"com.nimbusds.jose.util.events",
|
||||
"com.nimbusds.jose.util.health",
|
||||
"com.nimbusds.jwt",
|
||||
"com.nimbusds.jwt.proc",
|
||||
"com.nimbusds.jwt.util"
|
||||
],
|
||||
"com.squareup.okhttp3:okhttp": [
|
||||
"okhttp3",
|
||||
"okhttp3.internal",
|
||||
@@ -2337,6 +2387,7 @@
|
||||
"com.fasterxml.jackson.core:jackson-annotations",
|
||||
"com.fasterxml.jackson.core:jackson-core",
|
||||
"com.fasterxml.jackson.core:jackson-databind",
|
||||
"com.github.stephenc.jcip:jcip-annotations",
|
||||
"com.google.android:annotations",
|
||||
"com.google.api.grpc:proto-google-common-protos",
|
||||
"com.google.auth:google-auth-library-credentials",
|
||||
@@ -2355,6 +2406,7 @@
|
||||
"com.google.protobuf:protobuf-java",
|
||||
"com.google.re2j:re2j",
|
||||
"com.google.truth:truth",
|
||||
"com.nimbusds:nimbus-jose-jwt",
|
||||
"com.squareup.okhttp3:okhttp",
|
||||
"com.squareup.okhttp3:okhttp-sse",
|
||||
"com.squareup.okhttp:okhttp",
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
# Logging
|
||||
log_format grpc_json escape=json '{'
|
||||
'"time":"$time_iso8601",'
|
||||
'"client":"$remote_addr",'
|
||||
'"uri":"$uri",'
|
||||
'"status":$status,'
|
||||
'"grpc_status":"$sent_http_grpc_status",'
|
||||
'"request_time":$request_time,'
|
||||
'"upstream_time":"$upstream_response_time"'
|
||||
'}';
|
||||
|
||||
access_log /var/log/nginx/access.log grpc_json;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
# Rate limiting zone
|
||||
limit_req_zone $binary_remote_addr zone=grpc_limit:10m rate=100r/s;
|
||||
|
||||
# Docker DNS resolver - re-resolve hostnames every 10s
|
||||
# This prevents stale IP caching when containers restart
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
# Upstream for Eagle gRPC server
|
||||
upstream eagle_grpc {
|
||||
server eagle:40032;
|
||||
keepalive 100;
|
||||
}
|
||||
|
||||
# HTTP server for Let's Encrypt challenge and redirect
|
||||
server {
|
||||
listen 80;
|
||||
server_name prod.eagle0.net;
|
||||
|
||||
# Let's Encrypt challenge
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# Redirect all other HTTP to HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server for gRPC
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name prod.eagle0.net;
|
||||
|
||||
# SSL certificates (managed by certbot)
|
||||
ssl_certificate /etc/letsencrypt/live/prod.eagle0.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/prod.eagle0.net/privkey.pem;
|
||||
|
||||
# SSL configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# gRPC proxy for Eagle service
|
||||
location /net.eagle0.eagle.api.Eagle {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# gRPC proxy
|
||||
grpc_pass grpc://eagle_grpc;
|
||||
|
||||
# Timeouts for long-running streams
|
||||
grpc_read_timeout 1200s;
|
||||
grpc_send_timeout 1200s;
|
||||
grpc_socket_keepalive on;
|
||||
|
||||
# Error handling
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# gRPC proxy for Auth service
|
||||
location /net.eagle0.eagle.api.auth.Auth {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# gRPC proxy
|
||||
grpc_pass grpc://eagle_grpc;
|
||||
|
||||
# Timeouts
|
||||
grpc_read_timeout 30s;
|
||||
grpc_send_timeout 30s;
|
||||
|
||||
# Error handling
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# OAuth callback endpoint (proxied to Eagle's HTTP handler)
|
||||
location /oauth/callback {
|
||||
proxy_pass http://eagle:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Admin server proxy
|
||||
location /admin/ {
|
||||
proxy_pass http://admin:8080/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+473
@@ -0,0 +1,473 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# generate_changelog.sh
|
||||
#
|
||||
# Generates a weekly changelog from merged PRs, uses Claude to create a synopsis,
|
||||
# and sends an HTML email via Fastmail JMAP API.
|
||||
#
|
||||
# Usage: ./scripts/generate_changelog.sh [--dry-run]
|
||||
#
|
||||
# Configuration files (in ~/.config/eagle0/):
|
||||
# fastmail_token - API token (required)
|
||||
# changelog_recipient - Email addresses, one per line (optional, defaults to sender)
|
||||
#
|
||||
# To set up:
|
||||
# mkdir -p ~/.config/eagle0
|
||||
# echo 'your-token' > ~/.config/eagle0/fastmail_token
|
||||
# chmod 600 ~/.config/eagle0/fastmail_token
|
||||
#
|
||||
# # Optional: configure recipients (one per line, # for comments)
|
||||
# cat > ~/.config/eagle0/changelog_recipient << EOF
|
||||
# alice@example.com
|
||||
# bob@example.com
|
||||
# EOF
|
||||
#
|
||||
# The script tracks its last run using a git tag 'changelog-last-run'.
|
||||
# On first run (no tag), it defaults to the previous Friday at 4pm.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Ensure homebrew binaries are in PATH
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TAG_NAME="changelog-last-run"
|
||||
DRY_RUN=false
|
||||
FASTMAIL_API="https://api.fastmail.com/jmap/api/"
|
||||
CONFIG_DIR="$HOME/.config/eagle0"
|
||||
TOKEN_FILE="$CONFIG_DIR/fastmail_token"
|
||||
RECIPIENT_FILE="$CONFIG_DIR/changelog_recipient"
|
||||
|
||||
# Load API token from file or environment
|
||||
load_api_token() {
|
||||
# Environment variable takes precedence
|
||||
if [[ -n "${FASTMAIL_API_TOKEN:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Try loading from config file
|
||||
if [[ -f "$TOKEN_FILE" ]]; then
|
||||
FASTMAIL_API_TOKEN=$(cat "$TOKEN_FILE" | tr -d '[:space:]')
|
||||
if [[ -n "$FASTMAIL_API_TOKEN" ]]; then
|
||||
echo "Loaded API token from $TOKEN_FILE"
|
||||
export FASTMAIL_API_TOKEN
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Load recipient emails from config file (one per line)
|
||||
# Returns JSON array fragment like: {"email": "a@b.com"}, {"email": "c@d.com"}
|
||||
load_recipients_json() {
|
||||
local recipients=""
|
||||
if [[ -f "$RECIPIENT_FILE" ]]; then
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
# Skip empty lines and comments
|
||||
line=$(echo "$line" | tr -d '[:space:]')
|
||||
[[ -z "$line" || "$line" == \#* ]] && continue
|
||||
|
||||
if [[ -n "$recipients" ]]; then
|
||||
recipients="$recipients, "
|
||||
fi
|
||||
recipients="$recipients{\"email\": \"$line\"}"
|
||||
done < "$RECIPIENT_FILE"
|
||||
fi
|
||||
echo "$recipients"
|
||||
}
|
||||
|
||||
# Get human-readable list of recipients
|
||||
load_recipients_display() {
|
||||
if [[ -f "$RECIPIENT_FILE" ]]; then
|
||||
grep -v '^#' "$RECIPIENT_FILE" | grep -v '^[[:space:]]*$' | tr '\n' ', ' | sed 's/, $//'
|
||||
fi
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Usage: $0 [--dry-run]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Get the cutoff date - either from tag or previous Friday 4pm
|
||||
get_cutoff_date() {
|
||||
# Try to get the date from the tag
|
||||
if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
|
||||
# Get the commit date of the tagged commit
|
||||
git log -1 --format="%aI" "$TAG_NAME"
|
||||
else
|
||||
# Calculate previous Friday at 4pm
|
||||
# Get current day of week (1=Monday, 7=Sunday)
|
||||
local dow=$(date +%u)
|
||||
local days_since_friday
|
||||
|
||||
if [[ $dow -ge 5 ]]; then
|
||||
# Friday (5), Saturday (6), or Sunday (7)
|
||||
days_since_friday=$((dow - 5))
|
||||
else
|
||||
# Monday (1) through Thursday (4)
|
||||
days_since_friday=$((dow + 2))
|
||||
fi
|
||||
|
||||
# Get previous Friday at 4pm in ISO format
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
date -v-"${days_since_friday}d" -v16H -v0M -v0S +"%Y-%m-%dT%H:%M:%S%z"
|
||||
else
|
||||
date -d "$days_since_friday days ago 16:00:00" --iso-8601=seconds
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Fetch merged PRs since the cutoff date
|
||||
fetch_merged_prs() {
|
||||
local since_date="$1"
|
||||
local output_file="$2"
|
||||
|
||||
echo "Fetching PRs merged since: $since_date"
|
||||
|
||||
# Use gh to search for merged PRs
|
||||
gh pr list \
|
||||
--state merged \
|
||||
--base main \
|
||||
--json number,title,body,mergedAt,author \
|
||||
--jq ".[] | select(.mergedAt >= \"$since_date\")" \
|
||||
> "$output_file.json"
|
||||
|
||||
# Format the output nicely
|
||||
echo "# Merged PRs since $since_date" > "$output_file"
|
||||
echo "" >> "$output_file"
|
||||
|
||||
# Process each PR
|
||||
jq -r '
|
||||
"## PR #\(.number): \(.title)\n" +
|
||||
"Author: \(.author.login)\n" +
|
||||
"Merged: \(.mergedAt)\n\n" +
|
||||
"### Description\n" +
|
||||
(.body // "(No description)") +
|
||||
"\n\n---\n"
|
||||
' "$output_file.json" >> "$output_file"
|
||||
|
||||
# Count PRs
|
||||
local pr_count=$(jq -s 'length' "$output_file.json")
|
||||
echo "Found $pr_count merged PRs"
|
||||
|
||||
rm -f "$output_file.json"
|
||||
|
||||
if [[ $pr_count -eq 0 ]]; then
|
||||
echo "No PRs found since $since_date"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Generate synopsis using Claude
|
||||
generate_synopsis() {
|
||||
local input_file="$1"
|
||||
local output_file="$2"
|
||||
|
||||
echo "Generating synopsis with Claude..."
|
||||
|
||||
# Create a prompt file to avoid shell escaping issues
|
||||
local prompt_file="/tmp/eagle0_prompt_$$.txt"
|
||||
# Get repo URL for PR links
|
||||
local repo_url=$(gh repo view --json url -q '.url')
|
||||
|
||||
cat > "$prompt_file" <<PROMPT_HEADER
|
||||
You are summarizing changes for a weekly engineering update email.
|
||||
|
||||
Read the following list of merged PRs and create a concise synopsis grouped by theme/feature/area of the codebase.
|
||||
|
||||
Structure:
|
||||
1. <h1> title (e.g., "Eagle0 Weekly Update")
|
||||
2. <h2>BLUF</h2> (Bottom Line Up Front) - A short prose paragraph (2-4 sentences) highlighting the 1-3 most important changes this week and what to look for when testing. This should be conversational and help readers quickly understand what matters most.
|
||||
3. Synopsis sections (<h2> headings with bullet point summaries)
|
||||
4. <hr> divider
|
||||
5. <h2>PR Details</h2> with the same groupings, but smaller (<h3> headings) and listing PR links
|
||||
- Format each PR as: <a href="${repo_url}/pull/NUMBER">#NUMBER</a>: Title
|
||||
|
||||
Guidelines for the SYNOPSIS sections:
|
||||
- Group related changes together under clear headings (use <h2> tags)
|
||||
- Use bullet points (<ul><li>) for individual changes
|
||||
- Highlight any significant new features, breaking changes, or important fixes
|
||||
- Keep the tone professional but accessible
|
||||
- Don't include PR numbers in the synopsis - focus on what changed and why it matters
|
||||
|
||||
IMPORTANT: Output valid HTML that can be used directly in an email body. Do NOT wrap in \`\`\`html code blocks - just output the raw HTML.
|
||||
|
||||
Here are the merged PRs:
|
||||
|
||||
PROMPT_HEADER
|
||||
|
||||
cat "$input_file" >> "$prompt_file"
|
||||
echo "" >> "$prompt_file"
|
||||
echo "Generate the synopsis now:" >> "$prompt_file"
|
||||
|
||||
# Use Claude CLI to generate the synopsis, wrapped in proper HTML with charset
|
||||
local raw_output="/tmp/eagle0_raw_$$.html"
|
||||
cat "$prompt_file" | claude --print > "$raw_output"
|
||||
|
||||
# Wrap in HTML document with UTF-8 charset
|
||||
cat > "$output_file" <<'HTML_HEAD'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body>
|
||||
HTML_HEAD
|
||||
cat "$raw_output" >> "$output_file"
|
||||
echo "</body></html>" >> "$output_file"
|
||||
|
||||
rm -f "$prompt_file" "$raw_output"
|
||||
echo "Synopsis generated at: $output_file"
|
||||
}
|
||||
|
||||
# Get Fastmail session info (account ID, identity ID, drafts mailbox ID)
|
||||
get_fastmail_session() {
|
||||
echo "Fetching Fastmail session info..." >&2
|
||||
|
||||
# Get session
|
||||
local session=$(curl -s \
|
||||
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
|
||||
"https://api.fastmail.com/jmap/session")
|
||||
|
||||
# Extract account ID (first account)
|
||||
FASTMAIL_ACCOUNT_ID=$(echo "$session" | jq -r '.primaryAccounts["urn:ietf:params:jmap:mail"]')
|
||||
|
||||
if [[ -z "$FASTMAIL_ACCOUNT_ID" || "$FASTMAIL_ACCOUNT_ID" == "null" ]]; then
|
||||
echo "Error: Could not get Fastmail account ID. Check your API token." >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Account ID: $FASTMAIL_ACCOUNT_ID" >&2
|
||||
|
||||
# Get identity ID
|
||||
local identity_response=$(curl -s \
|
||||
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
-d "{
|
||||
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\", \"urn:ietf:params:jmap:submission\"],
|
||||
\"methodCalls\": [
|
||||
[\"Identity/get\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\"}, \"0\"]
|
||||
]
|
||||
}" \
|
||||
"$FASTMAIL_API")
|
||||
|
||||
FASTMAIL_IDENTITY_ID=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].id')
|
||||
FASTMAIL_FROM_EMAIL=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].email')
|
||||
|
||||
if [[ -z "$FASTMAIL_IDENTITY_ID" || "$FASTMAIL_IDENTITY_ID" == "null" ]]; then
|
||||
echo "Error: Could not get Fastmail identity ID." >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Identity ID: $FASTMAIL_IDENTITY_ID (${FASTMAIL_FROM_EMAIL})" >&2
|
||||
|
||||
# Get drafts mailbox ID
|
||||
local mailbox_response=$(curl -s \
|
||||
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
-d "{
|
||||
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\"],
|
||||
\"methodCalls\": [
|
||||
[\"Mailbox/query\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\", \"filter\": {\"role\": \"drafts\"}}, \"0\"]
|
||||
]
|
||||
}" \
|
||||
"$FASTMAIL_API")
|
||||
|
||||
FASTMAIL_DRAFTS_ID=$(echo "$mailbox_response" | jq -r '.methodResponses[0][1].ids[0]')
|
||||
|
||||
if [[ -z "$FASTMAIL_DRAFTS_ID" || "$FASTMAIL_DRAFTS_ID" == "null" ]]; then
|
||||
echo "Error: Could not get Fastmail drafts mailbox ID." >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Drafts mailbox ID: $FASTMAIL_DRAFTS_ID" >&2
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Send email via Fastmail JMAP API
|
||||
send_email_fastmail() {
|
||||
local synopsis_file="$1"
|
||||
local recipients_json="$2" # JSON array fragment: {"email": "a@b.com"}, {"email": "c@d.com"}
|
||||
|
||||
local subject="Eagle0 Weekly Changelog - $(date +%Y-%m-%d)"
|
||||
local html_body=$(cat "$synopsis_file" | jq -Rs .)
|
||||
|
||||
echo "Sending email via Fastmail JMAP API..."
|
||||
|
||||
# Create the email and send it in one request
|
||||
local response=$(curl -s \
|
||||
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
-d "{
|
||||
\"using\": [
|
||||
\"urn:ietf:params:jmap:core\",
|
||||
\"urn:ietf:params:jmap:mail\",
|
||||
\"urn:ietf:params:jmap:submission\"
|
||||
],
|
||||
\"methodCalls\": [
|
||||
[\"Email/set\", {
|
||||
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
|
||||
\"create\": {
|
||||
\"draft\": {
|
||||
\"from\": [{\"email\": \"$FASTMAIL_FROM_EMAIL\"}],
|
||||
\"to\": [$recipients_json],
|
||||
\"subject\": \"$subject\",
|
||||
\"mailboxIds\": {\"$FASTMAIL_DRAFTS_ID\": true},
|
||||
\"keywords\": {\"\$draft\": true},
|
||||
\"htmlBody\": [{\"partId\": \"body\", \"type\": \"text/html\"}],
|
||||
\"bodyValues\": {
|
||||
\"body\": {
|
||||
\"charset\": \"utf-8\",
|
||||
\"value\": $html_body
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, \"0\"],
|
||||
[\"EmailSubmission/set\", {
|
||||
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
|
||||
\"onSuccessDestroyEmail\": [\"#sendIt\"],
|
||||
\"create\": {
|
||||
\"sendIt\": {
|
||||
\"emailId\": \"#draft\",
|
||||
\"identityId\": \"$FASTMAIL_IDENTITY_ID\"
|
||||
}
|
||||
}
|
||||
}, \"1\"]
|
||||
]
|
||||
}" \
|
||||
"$FASTMAIL_API")
|
||||
|
||||
# Check for errors
|
||||
local error=$(echo "$response" | jq -r '.methodResponses[0][1].notCreated.draft.description // empty')
|
||||
if [[ -n "$error" ]]; then
|
||||
echo "Error creating email: $error" >&2
|
||||
echo "Full response: $response" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local send_error=$(echo "$response" | jq -r '.methodResponses[1][1].notCreated.sendIt.description // empty')
|
||||
if [[ -n "$send_error" ]]; then
|
||||
echo "Error sending email: $send_error" >&2
|
||||
echo "Full response: $response" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Email sent successfully"
|
||||
}
|
||||
|
||||
# Update the tag to mark this run
|
||||
update_tag() {
|
||||
echo "Updating $TAG_NAME tag..."
|
||||
|
||||
# Delete existing tag if present
|
||||
git tag -d "$TAG_NAME" 2>/dev/null || true
|
||||
git push origin --delete "$TAG_NAME" 2>/dev/null || true
|
||||
|
||||
# Create new tag at HEAD
|
||||
git tag "$TAG_NAME"
|
||||
git push origin "$TAG_NAME"
|
||||
|
||||
echo "Tag updated to current HEAD"
|
||||
}
|
||||
|
||||
# Main
|
||||
main() {
|
||||
echo "=== Eagle0 Weekly Changelog Generator ==="
|
||||
echo ""
|
||||
|
||||
# Load API token (only required for actual send)
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
if ! load_api_token; then
|
||||
echo "Error: No Fastmail API token found."
|
||||
echo ""
|
||||
echo "To create a token:"
|
||||
echo "1. Go to Fastmail Settings -> Password & Security -> API tokens"
|
||||
echo "2. Create a new token with 'Email submission' scope"
|
||||
echo "3. Save it using one of these methods:"
|
||||
echo ""
|
||||
echo " Option A (recommended): Store in config file"
|
||||
echo " mkdir -p ~/.config/eagle0"
|
||||
echo " echo 'your-token' > ~/.config/eagle0/fastmail_token"
|
||||
echo " chmod 600 ~/.config/eagle0/fastmail_token"
|
||||
echo ""
|
||||
echo " Option B: Set environment variable"
|
||||
echo " export FASTMAIL_API_TOKEN='your-token'"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Get cutoff date
|
||||
local cutoff_date=$(get_cutoff_date)
|
||||
echo "Cutoff date: $cutoff_date"
|
||||
|
||||
# Create temp files
|
||||
local pr_file="/tmp/eagle0_prs_$(date +%s).md"
|
||||
local synopsis_file="/tmp/eagle0_synopsis_$(date +%s).html"
|
||||
|
||||
# Fetch PRs
|
||||
if ! fetch_merged_prs "$cutoff_date" "$pr_file"; then
|
||||
echo "No changes to report. Exiting."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "PR details saved to: $pr_file"
|
||||
|
||||
# Generate synopsis
|
||||
generate_synopsis "$pr_file" "$synopsis_file"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo ""
|
||||
echo "=== DRY RUN - Synopsis content ==="
|
||||
cat "$synopsis_file"
|
||||
echo ""
|
||||
echo "=== DRY RUN - Skipping email send and tag update ==="
|
||||
else
|
||||
# Get Fastmail session info
|
||||
if ! get_fastmail_session; then
|
||||
echo "Failed to get Fastmail session info. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine recipients (from config file, or default to sender)
|
||||
local recipients_json=$(load_recipients_json)
|
||||
if [[ -z "$recipients_json" ]]; then
|
||||
recipients_json="{\"email\": \"$FASTMAIL_FROM_EMAIL\"}"
|
||||
echo "No recipients configured, sending to self ($FASTMAIL_FROM_EMAIL)"
|
||||
else
|
||||
local recipients_display=$(load_recipients_display)
|
||||
echo "Sending to: $recipients_display"
|
||||
fi
|
||||
|
||||
# Send email
|
||||
send_email_fastmail "$synopsis_file" "$recipients_json"
|
||||
|
||||
# Update tag for next run
|
||||
update_tag
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done!"
|
||||
echo "PR details: $pr_file"
|
||||
echo "Synopsis: $synopsis_file"
|
||||
}
|
||||
|
||||
main
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Setup script for Eagle0 production droplet
|
||||
# Run this on a fresh DigitalOcean droplet (Ubuntu 24.04)
|
||||
#
|
||||
# Usage: curl -sSL https://raw.githubusercontent.com/nolen777/eagle0/main/scripts/setup_droplet.sh | sudo bash
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DOMAIN="${DOMAIN:-eagle0.net}"
|
||||
DEPLOY_USER="${DEPLOY_USER:-deploy}"
|
||||
APP_DIR="/opt/eagle0"
|
||||
|
||||
echo "=== Eagle0 Production Server Setup ==="
|
||||
echo "Domain: ${DOMAIN}"
|
||||
echo "Deploy user: ${DEPLOY_USER}"
|
||||
echo ""
|
||||
|
||||
# Check if running as root
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This script must be run as root (use sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Updating system ==="
|
||||
apt-get update
|
||||
apt-get upgrade -y
|
||||
|
||||
echo "=== Installing Docker ==="
|
||||
if ! command -v docker &> /dev/null; then
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
else
|
||||
echo "Docker already installed"
|
||||
fi
|
||||
|
||||
echo "=== Installing Docker Compose plugin ==="
|
||||
apt-get install -y docker-compose-plugin
|
||||
|
||||
echo "=== Installing additional utilities ==="
|
||||
apt-get install -y \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
netcat-openbsd \
|
||||
jq \
|
||||
htop \
|
||||
unattended-upgrades
|
||||
|
||||
echo "=== Configuring automatic security updates ==="
|
||||
cat > /etc/apt/apt.conf.d/20auto-upgrades << 'EOF'
|
||||
APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
APT::Periodic::AutocleanInterval "7";
|
||||
EOF
|
||||
|
||||
echo "=== Creating deploy user ==="
|
||||
if ! id "${DEPLOY_USER}" &>/dev/null; then
|
||||
useradd -m -s /bin/bash -G docker "${DEPLOY_USER}"
|
||||
mkdir -p "/home/${DEPLOY_USER}/.ssh"
|
||||
chmod 700 "/home/${DEPLOY_USER}/.ssh"
|
||||
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "/home/${DEPLOY_USER}/.ssh"
|
||||
echo ""
|
||||
echo "*** IMPORTANT: Add your SSH public key to /home/${DEPLOY_USER}/.ssh/authorized_keys ***"
|
||||
echo ""
|
||||
else
|
||||
echo "User ${DEPLOY_USER} already exists"
|
||||
# Ensure user is in docker group
|
||||
usermod -aG docker "${DEPLOY_USER}"
|
||||
fi
|
||||
|
||||
echo "=== Creating application directory ==="
|
||||
mkdir -p "${APP_DIR}"/{nginx,certbot/conf,certbot/www,saves}
|
||||
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "${APP_DIR}"
|
||||
|
||||
echo "=== Configuring Docker registry authentication ==="
|
||||
echo ""
|
||||
echo "*** IMPORTANT: Run the following command to authenticate with DigitalOcean Container Registry: ***"
|
||||
echo " docker login registry.digitalocean.com"
|
||||
echo ""
|
||||
|
||||
echo "=== Creating systemd service ==="
|
||||
cat > /etc/systemd/system/eagle0.service << EOF
|
||||
[Unit]
|
||||
Description=Eagle0 Game Servers
|
||||
Requires=docker.service
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=${APP_DIR}
|
||||
ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d
|
||||
ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down
|
||||
User=${DEPLOY_USER}
|
||||
Group=${DEPLOY_USER}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable eagle0
|
||||
|
||||
echo "=== Configuring firewall (UFW) ==="
|
||||
if ! command -v ufw &> /dev/null; then
|
||||
apt-get install -y ufw
|
||||
fi
|
||||
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw allow ssh
|
||||
ufw allow 80/tcp
|
||||
ufw allow 443/tcp
|
||||
ufw --force enable
|
||||
|
||||
echo "=== Setting up log rotation ==="
|
||||
cat > /etc/logrotate.d/eagle0 << EOF
|
||||
/var/log/eagle0/*.log {
|
||||
daily
|
||||
missingok
|
||||
rotate 14
|
||||
compress
|
||||
delaycompress
|
||||
notifempty
|
||||
create 0640 ${DEPLOY_USER} ${DEPLOY_USER}
|
||||
sharedscripts
|
||||
}
|
||||
EOF
|
||||
|
||||
mkdir -p /var/log/eagle0
|
||||
chown "${DEPLOY_USER}:${DEPLOY_USER}" /var/log/eagle0
|
||||
|
||||
echo ""
|
||||
echo "=== Setup Complete ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Add SSH public key to /home/${DEPLOY_USER}/.ssh/authorized_keys"
|
||||
echo "2. Copy docker-compose.prod.yml to ${APP_DIR}/"
|
||||
echo "3. Copy nginx/nginx.conf to ${APP_DIR}/nginx/"
|
||||
echo "4. Create .env file in ${APP_DIR}/ with OPENAI_API_KEY"
|
||||
echo "5. Run: docker login registry.digitalocean.com"
|
||||
echo "6. Get SSL certificate: (see init_ssl.sh)"
|
||||
echo "7. Start services: systemctl start eagle0"
|
||||
echo ""
|
||||
echo "Server IP: $(curl -s ifconfig.me)"
|
||||
echo ""
|
||||
@@ -26,6 +26,13 @@ namespace fs = std::filesystem;
|
||||
static string rLocation;
|
||||
|
||||
auto rloc(const string& execPath) -> string {
|
||||
// First check for environment variable override for Docker deployment
|
||||
const char* resourcesPath = getenv("SHARDOK_RESOURCES_PATH");
|
||||
if (resourcesPath != nullptr) {
|
||||
return ""; // Return empty so StaticShardokFilesDirectory uses env var directly
|
||||
}
|
||||
|
||||
// Fall back to Bazel runfiles for development
|
||||
string error;
|
||||
const std::unique_ptr<Runfiles> runfiles(Runfiles::Create(execPath, &error));
|
||||
|
||||
@@ -58,10 +65,14 @@ auto FilesystemUtils::FileExistsAtPath(const string& path) -> bool { return fs::
|
||||
auto FilesystemUtils::StaticEagle0FilesDirectory() -> string { return "/usr/local/share/eagle0/"; }
|
||||
|
||||
auto FilesystemUtils::StaticShardokFilesDirectory() -> string {
|
||||
const char* resourcesPath = getenv("SHARDOK_RESOURCES_PATH");
|
||||
if (resourcesPath != nullptr) { return string(resourcesPath) + "/"; }
|
||||
return rLocation + "/src/main/resources/net/eagle0/shardok/";
|
||||
}
|
||||
|
||||
auto FilesystemUtils::MapFilesDirectory() -> string {
|
||||
const char* mapsPath = getenv("SHARDOK_MAPS_PATH");
|
||||
if (mapsPath != nullptr) { return string(mapsPath) + "/"; }
|
||||
return StaticShardokFilesDirectory() + "maps/";
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <bit>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
|
||||
#define ITERABLE_BITSET_INDEX_CHECKS false
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "MapUtils.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
|
||||
static inline std::string StringForKey(
|
||||
const std::unordered_map<std::string, std::string>& map,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#ifndef byte_vector_h
|
||||
#define byte_vector_h
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -65,15 +66,11 @@ private:
|
||||
const GameStateW& guessedState,
|
||||
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
|
||||
|
||||
[[nodiscard]] auto ChooseCommandIndex(
|
||||
const GameSettingsSPtr& settings,
|
||||
const net::eagle0::shardok::api::GameStateView& gsv,
|
||||
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
|
||||
|
||||
public:
|
||||
explicit ShardokAIClient(
|
||||
PlayerId playerId,
|
||||
bool isDefender,
|
||||
bool isAllAiBattle,
|
||||
const HexMap* hexMap,
|
||||
const SettingsGetter& settings,
|
||||
AIAlgorithmType aiAlgorithmType,
|
||||
@@ -86,6 +83,12 @@ public:
|
||||
[[nodiscard]] auto ChooseCommandIndex(const ShardokEngine& engine) const
|
||||
-> CommandChoiceResults;
|
||||
|
||||
// Overload that works on copies of state - allows caller to release lock during AI thinking
|
||||
[[nodiscard]] auto ChooseCommandIndex(
|
||||
const GameSettingsSPtr& settings,
|
||||
const net::eagle0::shardok::api::GameStateView& gsv,
|
||||
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
|
||||
|
||||
// MCTS configuration methods (only relevant when using MCTS algorithm)
|
||||
[[nodiscard]] auto GetMCTSConfig() const -> const mcts::MCTSConfig& { return mctsConfig; }
|
||||
void SetMCTSConfig(const mcts::MCTSConfig& config) { mctsConfig = config; }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "TranspositionTable.hpp"
|
||||
|
||||
#include <cinttypes>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
@@ -101,10 +102,10 @@ void TranspositionTable::clear() {
|
||||
|
||||
void TranspositionTable::printStats() const {
|
||||
printf("TranspositionTable Stats:\n");
|
||||
printf(" Probes: %llu\n", stats.probes.load());
|
||||
printf(" Hits: %llu (%.1f%%)\n", stats.hits.load(), stats.hitRate());
|
||||
printf(" Stores: %llu\n", stats.stores.load());
|
||||
printf(" Collisions: %llu\n", stats.collisions.load());
|
||||
printf(" Probes: %" PRIu64 "\n", stats.probes.load());
|
||||
printf(" Hits: %" PRIu64 " (%.1f%%)\n", stats.hits.load(), stats.hitRate());
|
||||
printf(" Stores: %" PRIu64 "\n", stats.stores.load());
|
||||
printf(" Collisions: %" PRIu64 "\n", stats.collisions.load());
|
||||
printf(" Table size: %zu entries (%.1f MB)\n",
|
||||
TABLE_SIZE,
|
||||
(TABLE_SIZE * sizeof(TTEntry)) / (1024.0 * 1024.0));
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
#include "AiBattleConfig.hpp"
|
||||
#include "AiBattleSimulator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
|
||||
|
||||
using shardok::ai_battle_simulator::AiBattleConfigLoader;
|
||||
using shardok::ai_battle_simulator::AiBattleSimulator;
|
||||
@@ -109,10 +108,6 @@ int main(int argc, char* argv[]) {
|
||||
// Set exec path for FilesystemUtils
|
||||
FilesystemUtils::SetExecPath(argv[0]);
|
||||
|
||||
// Set cache directory for ActionPointDistances
|
||||
shardok::FixedActionPointDistances::SetCacheDirectory(
|
||||
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
|
||||
|
||||
try {
|
||||
if (argc < 2) {
|
||||
PrintUsage(argv[0]);
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/AIClientFactory.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GamePhaseRunner.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
|
||||
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
|
||||
|
||||
using namespace shardok;
|
||||
@@ -84,10 +83,6 @@ int main(int argc, char* argv[]) {
|
||||
// Set exec path so FilesystemUtils can find resource files
|
||||
FilesystemUtils::SetExecPath(argv[0]);
|
||||
|
||||
// Set cache directory for ActionPointDistances
|
||||
FixedActionPointDistances::SetCacheDirectory(
|
||||
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
|
||||
|
||||
try {
|
||||
std::cout << "Shardok AI Performance Runner\n";
|
||||
std::cout << "==============================\n";
|
||||
|
||||
@@ -24,9 +24,6 @@ using std::scoped_lock;
|
||||
using std::string;
|
||||
using std::unique_lock;
|
||||
using std::weak_ptr;
|
||||
using std::chrono::duration;
|
||||
|
||||
static constexpr duration kWaitForUpdatesDuration = std::chrono::milliseconds(5000);
|
||||
|
||||
using net::eagle0::shardok::common::GameStatus;
|
||||
|
||||
@@ -74,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,
|
||||
@@ -111,17 +114,67 @@ void ShardokGameController::DoAIThread() {
|
||||
if (aiClients.empty()) { printf("No AI players, exiting AI thread.\n"); }
|
||||
|
||||
while (aiThreadKeepGoing) {
|
||||
while (incomingRegistrations > 0) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
// Phase 1: Gather data for AI decision (brief lock)
|
||||
shared_ptr<ShardokAIClient> aiClient;
|
||||
PlayerId playerId;
|
||||
GameSettingsSPtr settings;
|
||||
net::eagle0::shardok::api::GameStateView gsv;
|
||||
CommandListSPtr availableCommands;
|
||||
size_t expectedHistoryCount;
|
||||
|
||||
{
|
||||
unique_lock lk(masterLock);
|
||||
|
||||
if (engine->GameIsOver()) {
|
||||
aiThreadKeepGoing = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
playerId = engine->GetCurrentPlayerId();
|
||||
aiClient = LockedAIClientForPid(playerId);
|
||||
|
||||
if (!aiClient) {
|
||||
// Not an AI player's turn - wait for signal
|
||||
aiCondition.wait(lk);
|
||||
aiThreadKeepGoing = aiThreadKeepGoing && !engine->GameIsOver();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get copies of everything the AI needs
|
||||
settings = engine->GetGameSettings();
|
||||
gsv = engine->GetGameStateView(playerId);
|
||||
availableCommands = engine->GetAvailableCommandsForAIPlayer(playerId);
|
||||
expectedHistoryCount = engine->GetUnfilteredHistoryCount();
|
||||
}
|
||||
// Lock released - polls can now get through
|
||||
|
||||
if (availableCommands->empty()) {
|
||||
printf("no commands for player %d\n", playerId);
|
||||
continue;
|
||||
}
|
||||
|
||||
unique_lock lk(masterLock);
|
||||
if (LockedCheckOneAICommand()) {
|
||||
// Phase 2: AI thinks (NO LOCK - this is the slow part)
|
||||
const auto results = aiClient->ChooseCommandIndex(settings, gsv, availableCommands);
|
||||
|
||||
// Phase 3: Post the command (brief lock)
|
||||
{
|
||||
unique_lock lk(masterLock);
|
||||
|
||||
// Verify state hasn't changed while we were thinking
|
||||
if (engine->GetUnfilteredHistoryCount() != expectedHistoryCount) {
|
||||
// State changed (e.g., human posted command) - re-evaluate
|
||||
printf("AI: State changed while thinking, re-evaluating\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (engine->GameIsOver()) {
|
||||
aiThreadKeepGoing = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
engine->PostCommand(playerId, results.chosenIndex);
|
||||
LockedNotifyClients();
|
||||
aiThreadKeepGoing = aiThreadKeepGoing && !engine->GameIsOver();
|
||||
} else {
|
||||
aiCondition.wait(lk);
|
||||
aiThreadKeepGoing = aiThreadKeepGoing && !engine->GameIsOver();
|
||||
aiThreadKeepGoing = !engine->GameIsOver();
|
||||
}
|
||||
}
|
||||
printf("Exiting AI thread.\n");
|
||||
@@ -136,24 +189,6 @@ ShardokGameController::~ShardokGameController() {
|
||||
aiThread.join();
|
||||
}
|
||||
|
||||
auto ShardokGameController::LockedCheckOneAICommand() -> bool {
|
||||
if (engine->GameIsOver()) {
|
||||
printf("Game is over!\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
const PlayerId currentPid = engine->GetCurrentPlayerId();
|
||||
if (const shared_ptr<ShardokAIClient> currentPlayerClient = LockedAIClientForPid(currentPid)) {
|
||||
const int index = currentPlayerClient->ChooseCommandIndex(*engine).chosenIndex;
|
||||
|
||||
engine->PostCommand(currentPid, index);
|
||||
LockedNotifyClients();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void CheckFactionId(
|
||||
const unique_ptr<ShardokEngine> &engine,
|
||||
const PlayerId shardokPlayerId,
|
||||
@@ -229,6 +264,7 @@ void ShardokGameController::PostPlacementCommands(
|
||||
}
|
||||
|
||||
auto ShardokGameController::GetCurrentGameStateBytes() -> byte_vector {
|
||||
scoped_lock<mutex> guard(masterLock);
|
||||
return engine->GetCurrentGameStateBytes();
|
||||
}
|
||||
|
||||
@@ -245,15 +281,9 @@ auto ShardokGameController::GetUpdates(const int64_t startingActionId) -> AllUpd
|
||||
awrs = engine->GetGameHistory(startingActionId);
|
||||
incomingRegistrations--;
|
||||
|
||||
// If the current player is an AI, wait for some results to post. Otherwise go ahead and
|
||||
// return, we might be telling the caller about available commands.
|
||||
if (awrs.empty() && !engine->GameIsOver() &&
|
||||
engine->GetPlayerInfos()[engine->GetCurrentPlayerId()].is_ai()) {
|
||||
updateCondition.wait_for(guard, kWaitForUpdatesDuration);
|
||||
incomingRegistrations++;
|
||||
awrs = engine->GetGameHistory(startingActionId);
|
||||
incomingRegistrations--;
|
||||
}
|
||||
// Note: Previously this had a 5-second wait for AI players to support long-polling.
|
||||
// With streaming (WaitForUpdatesAndPush), the caller already waits for updates,
|
||||
// so this wait is no longer needed. GetGameStatus is deprecated in favor of streaming.
|
||||
|
||||
updates.mainResults.reserve(awrs.size());
|
||||
std::ranges::transform(
|
||||
@@ -292,6 +322,9 @@ auto ShardokGameController::GetUpdates(const int64_t startingActionId) -> AllUpd
|
||||
-1,
|
||||
engine->FilterNewResults(-1, startingActionId),
|
||||
nullptr);
|
||||
|
||||
auto gameStateBytes = engine->GetCurrentGameStateBytes();
|
||||
updates.currentGameState.swap(gameStateBytes);
|
||||
}
|
||||
|
||||
return updates;
|
||||
@@ -321,4 +354,78 @@ auto ShardokGameController::ResolvedPlayerInfos()
|
||||
return engine->GetPlayerInfos();
|
||||
}
|
||||
|
||||
void ShardokGameController::RegisterSubscriber(std::shared_ptr<StreamSubscriber> subscriber) {
|
||||
scoped_lock<mutex> guard(subscriberLock);
|
||||
subscribers.push_back(subscriber);
|
||||
}
|
||||
|
||||
void ShardokGameController::UnregisterSubscriber(const StreamSubscriber *subscriber) {
|
||||
scoped_lock<mutex> guard(subscriberLock);
|
||||
subscribers.erase(
|
||||
std::remove_if(
|
||||
subscribers.begin(),
|
||||
subscribers.end(),
|
||||
[subscriber](const std::weak_ptr<StreamSubscriber> &weakSub) {
|
||||
auto sub = weakSub.lock();
|
||||
return !sub || sub.get() == subscriber;
|
||||
}),
|
||||
subscribers.end());
|
||||
}
|
||||
|
||||
auto ShardokGameController::WaitForUpdatesAndPush(
|
||||
std::shared_ptr<StreamSubscriber> subscriber,
|
||||
int64_t startingActionId) -> bool {
|
||||
int64_t lastPushedActionId = startingActionId;
|
||||
|
||||
while (subscriber->IsActive()) {
|
||||
bool gameOver = false;
|
||||
GameOverInfo gameOverInfo{};
|
||||
|
||||
{
|
||||
unique_lock<mutex> guard(masterLock);
|
||||
|
||||
// Wait for updates or game over
|
||||
updateCondition.wait(guard, [this, lastPushedActionId] {
|
||||
return engine->GetUnfilteredHistoryCount() >
|
||||
static_cast<size_t>(lastPushedActionId) ||
|
||||
engine->GameIsOver();
|
||||
});
|
||||
|
||||
if (!subscriber->IsActive()) { return false; }
|
||||
|
||||
gameOver = engine->GameIsOver();
|
||||
|
||||
if (gameOver) {
|
||||
gameOverInfo.gameStatus = fb::ToProto(engine->GetGameStatus());
|
||||
gameOverInfo.playerInfos = engine->GetPlayerInfos();
|
||||
gameOverInfo.endGameUnits = engine->EndGameUnits();
|
||||
}
|
||||
}
|
||||
// Lock released - GetUpdates will acquire its own lock
|
||||
|
||||
// IMPORTANT: Send any pending updates FIRST, including the final actions
|
||||
// that caused the game to end. Previously, we returned early on gameOver
|
||||
// without sending these final updates, causing the client to never see
|
||||
// the last few battle actions.
|
||||
AllUpdates updates = GetUpdates(lastPushedActionId);
|
||||
lastPushedActionId = updates.newUnfilteredCount;
|
||||
|
||||
if (!updates.mainResults.empty()) {
|
||||
subscriber->OnUpdate(
|
||||
updates.mainResults,
|
||||
updates.filteredResults,
|
||||
updates.newUnfilteredCount,
|
||||
updates.currentGameState);
|
||||
}
|
||||
|
||||
// Now send gameOver notification after all updates have been sent
|
||||
if (gameOver) {
|
||||
subscriber->OnGameOver(gameOverInfo);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false; // Subscriber disconnected
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#define ShardokGameController_hpp
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
@@ -29,6 +30,50 @@ using std::shared_ptr;
|
||||
using std::unique_ptr;
|
||||
using std::weak_ptr;
|
||||
|
||||
// Forward declaration
|
||||
class ShardokGameController;
|
||||
|
||||
/// Info about a game that has ended, for notifying subscribers
|
||||
struct GameOverInfo {
|
||||
vector<net::eagle0::shardok::common::PlayerInfo> playerInfos;
|
||||
vector<net::eagle0::shardok::storage::ResolvedUnit> endGameUnits;
|
||||
net::eagle0::shardok::common::GameStatus gameStatus;
|
||||
};
|
||||
|
||||
/// Updates for a single player (includes faction ID for client routing)
|
||||
struct OnePlayerUpdates {
|
||||
int32_t eagleFactionId;
|
||||
vector<ActionResultView> resultViews;
|
||||
shared_ptr<AvailableCommands> availableCommands;
|
||||
|
||||
OnePlayerUpdates(
|
||||
const int32_t fid,
|
||||
const vector<ActionResultView>& arvs,
|
||||
const shared_ptr<AvailableCommands>& acs)
|
||||
: eagleFactionId(fid),
|
||||
resultViews(arvs),
|
||||
availableCommands(acs) {}
|
||||
};
|
||||
|
||||
/// Interface for subscribers that receive streaming updates from a game
|
||||
class StreamSubscriber {
|
||||
public:
|
||||
virtual ~StreamSubscriber() = default;
|
||||
|
||||
/// Called when new game updates are available
|
||||
virtual void OnUpdate(
|
||||
const vector<ActionResult>& mainResults,
|
||||
const vector<OnePlayerUpdates>& filteredResults,
|
||||
int32_t newUnfilteredCount,
|
||||
const byte_vector& currentGameState) = 0;
|
||||
|
||||
/// Called when the game ends
|
||||
virtual void OnGameOver(const GameOverInfo& info) = 0;
|
||||
|
||||
/// Returns true if this subscriber is still active and should receive updates
|
||||
[[nodiscard]] virtual auto IsActive() const -> bool = 0;
|
||||
};
|
||||
|
||||
class ShardokGameController {
|
||||
private:
|
||||
// This lock should be held any time we call into engine or modify clients.
|
||||
@@ -39,10 +84,17 @@ private:
|
||||
// Fires whenever there is a new game state update.
|
||||
mutable std::condition_variable updateCondition{};
|
||||
|
||||
// Stream subscribers - protected by separate lock to avoid deadlock with masterLock
|
||||
mutable std::mutex subscriberLock{};
|
||||
std::vector<std::weak_ptr<StreamSubscriber>> subscribers{};
|
||||
|
||||
string serializedRequest;
|
||||
|
||||
unique_ptr<ShardokEngine> engine;
|
||||
|
||||
// Cached immutable data - safe to access without lock since it never changes after construction
|
||||
const GameId cachedGameId;
|
||||
|
||||
std::atomic_int incomingRegistrations = 0;
|
||||
|
||||
const string mapName;
|
||||
@@ -60,11 +112,9 @@ private:
|
||||
|
||||
void LockedNotifyClients() const;
|
||||
|
||||
auto LockedCheckOneAICommand() -> bool;
|
||||
|
||||
auto LockedAIClientForPid(PlayerId pid) const -> shared_ptr<ShardokAIClient>;
|
||||
|
||||
static vector<shared_ptr<ShardokAIClient>> MakeAIClients(const unique_ptr<ShardokEngine> &e);
|
||||
static vector<shared_ptr<ShardokAIClient>> MakeAIClients(const unique_ptr<ShardokEngine>& e);
|
||||
|
||||
void DoAIThread();
|
||||
|
||||
@@ -75,6 +125,7 @@ public:
|
||||
string serializedRequest = "")
|
||||
: serializedRequest(std::move(serializedRequest)),
|
||||
engine(std::move(e)),
|
||||
cachedGameId(engine->GetGameId()),
|
||||
mapName(std::move(mapName)),
|
||||
logFilePath(MakeLogFilePath()),
|
||||
aiClients(MakeAIClients(engine)),
|
||||
@@ -98,26 +149,13 @@ public:
|
||||
PlayerId shardokPlayerId,
|
||||
int eagleFactionId,
|
||||
int64_t token,
|
||||
const vector<UnitPlacementInfo> &infos);
|
||||
|
||||
struct OnePlayerUpdates {
|
||||
int32_t eagleFactionId;
|
||||
vector<ActionResultView> resultViews;
|
||||
shared_ptr<AvailableCommands> availableCommands;
|
||||
|
||||
OnePlayerUpdates(
|
||||
const int32_t fid,
|
||||
const vector<ActionResultView> &arvs,
|
||||
const shared_ptr<AvailableCommands> &acs)
|
||||
: eagleFactionId(fid),
|
||||
resultViews(arvs),
|
||||
availableCommands(acs) {}
|
||||
};
|
||||
const vector<UnitPlacementInfo>& infos);
|
||||
|
||||
struct AllUpdates {
|
||||
vector<ActionResult> mainResults;
|
||||
vector<OnePlayerUpdates> filteredResults;
|
||||
int32_t newUnfilteredCount;
|
||||
byte_vector currentGameState;
|
||||
};
|
||||
auto GetUpdates(int64_t startingActionId) -> AllUpdates;
|
||||
auto GetCurrentGameStateBytes() -> byte_vector;
|
||||
@@ -127,13 +165,23 @@ public:
|
||||
auto ResolvedPlayerInfos() -> vector<net::eagle0::shardok::common::PlayerInfo>;
|
||||
auto EndGameUnits() -> vector<net::eagle0::shardok::storage::ResolvedUnit>;
|
||||
|
||||
[[nodiscard]] auto GetGameId() const -> GameId { return engine->GetGameId(); }
|
||||
|
||||
[[nodiscard]] auto GetHexMap() const -> const HexMap * {
|
||||
return engine->GetCurrentGameState()->hex_map();
|
||||
}
|
||||
[[nodiscard]] auto GetGameId() const -> GameId { return cachedGameId; }
|
||||
|
||||
[[nodiscard]] auto GetLogFilePath() const -> string { return logFilePath; }
|
||||
|
||||
/// Register a subscriber to receive streaming updates for this game.
|
||||
/// The subscriber will receive updates until it becomes inactive or is unregistered.
|
||||
void RegisterSubscriber(std::shared_ptr<StreamSubscriber> subscriber);
|
||||
|
||||
/// Unregister a subscriber. Safe to call even if the subscriber was never registered.
|
||||
void UnregisterSubscriber(const StreamSubscriber* subscriber);
|
||||
|
||||
/// Wait for game updates, pushing them to the given subscriber.
|
||||
/// Blocks until the game ends or the subscriber becomes inactive.
|
||||
/// Returns true if the game ended normally, false if subscriber disconnected.
|
||||
auto WaitForUpdatesAndPush(
|
||||
std::shared_ptr<StreamSubscriber> subscriber,
|
||||
int64_t startingActionId) -> bool;
|
||||
};
|
||||
} // namespace shardok
|
||||
|
||||
|
||||
+3
-21
@@ -29,8 +29,6 @@ thread_local struct {
|
||||
int localMisses = 0;
|
||||
int sharedAccesses = 0;
|
||||
int evictionEvents = 0;
|
||||
int apdLoadedFromFile = 0;
|
||||
int apdGeneratedFresh = 0;
|
||||
std::chrono::steady_clock::time_point lastReportTime = std::chrono::steady_clock::now();
|
||||
} cacheStats;
|
||||
|
||||
@@ -40,16 +38,13 @@ static void MaybePrintCacheStats() {
|
||||
if (std::chrono::duration_cast<std::chrono::seconds>(now - cacheStats.lastReportTime).count() >=
|
||||
CACHE_STATS_FREQUENCY_SECONDS_) {
|
||||
printf("Thread cache stats: %d persistent hits, %d persistent misses, %d local hits, "
|
||||
"%d local misses, %d shared accesses, %d eviction events, "
|
||||
"%d APD loaded from file, %d APD generated fresh\n",
|
||||
"%d local misses, %d shared accesses, %d eviction events\n",
|
||||
cacheStats.persistentHits,
|
||||
cacheStats.persistentMisses,
|
||||
cacheStats.localHits,
|
||||
cacheStats.localMisses,
|
||||
cacheStats.sharedAccesses,
|
||||
cacheStats.evictionEvents,
|
||||
cacheStats.apdLoadedFromFile,
|
||||
cacheStats.apdGeneratedFresh);
|
||||
cacheStats.evictionEvents);
|
||||
cacheStats.lastReportTime = now;
|
||||
}
|
||||
}
|
||||
@@ -195,25 +190,12 @@ auto ActionPointDistancesCache::GetRaw(
|
||||
}
|
||||
|
||||
// Create new pathfinding result using factory method
|
||||
auto creationResult = FixedActionPointDistances::Create(
|
||||
auto result = FixedActionPointDistances::Create(
|
||||
mapToUse,
|
||||
mapId.terrainTypesId,
|
||||
mapId.modifierId,
|
||||
battalionType,
|
||||
includeBravingWater,
|
||||
braveWaterActionPointCost);
|
||||
|
||||
#if CACHE_STATS_LOGGING_
|
||||
// Track whether this was loaded from file or generated fresh
|
||||
if (creationResult.loadedFromFile) {
|
||||
cacheStats.apdLoadedFromFile++;
|
||||
} else {
|
||||
cacheStats.apdGeneratedFresh++;
|
||||
}
|
||||
#endif
|
||||
|
||||
auto result = creationResult.apd;
|
||||
|
||||
// Store in shared cache
|
||||
sharedDistances.lazy_emplace_l(
|
||||
cacheKey,
|
||||
|
||||
+32
-86
@@ -22,110 +22,56 @@ static const int ASYNC_COUNT = []() {
|
||||
|
||||
namespace shardok {
|
||||
|
||||
void FixedActionPointDistances::SetCacheDirectory(const string& newDir) {
|
||||
cacheDirectory = newDir;
|
||||
FilesystemUtils::MakeDirectoryIfNecessary(cacheDirectory);
|
||||
}
|
||||
|
||||
static thread_local byte_vector _scratch;
|
||||
|
||||
FixedActionPointDistances::FixedActionPointDistances(const HexMap* /*map*/, int columnCount)
|
||||
: ActionPointDistances(columnCount) {}
|
||||
|
||||
auto FixedActionPointDistances::Create(
|
||||
const HexMap* map,
|
||||
int64_t terrainTypesHash,
|
||||
int64_t modifierHash,
|
||||
const BattalionTypeSPtr& battalionType,
|
||||
bool includeBravingWater,
|
||||
int braveWaterActionPointCost) -> CreationResult {
|
||||
int braveWaterActionPointCost) -> std::shared_ptr<FixedActionPointDistances> {
|
||||
// Create the object using private constructor
|
||||
auto apd = std::shared_ptr<FixedActionPointDistances>(
|
||||
new FixedActionPointDistances(map, map->column_count()));
|
||||
|
||||
CreationResult result;
|
||||
result.apd = apd;
|
||||
result.loadedFromFile = false;
|
||||
|
||||
string path = "";
|
||||
|
||||
if (!cacheDirectory.empty()) {
|
||||
std::stringstream stream;
|
||||
stream << cacheDirectory;
|
||||
stream << std::hex << terrainTypesHash << '/';
|
||||
|
||||
if (auto directoryPath = stream.str(); !FilesystemUtils::FileExistsAtPath(directoryPath)) {
|
||||
FilesystemUtils::MakeDirectoryIfNecessary(stream.str());
|
||||
}
|
||||
|
||||
stream << std::hex << modifierHash;
|
||||
stream << " " << battalionType->typeId;
|
||||
if (includeBravingWater) { stream << " " << braveWaterActionPointCost; }
|
||||
stream << ".apd";
|
||||
path = stream.str();
|
||||
}
|
||||
|
||||
const int indexCount = map->row_count() * map->column_count();
|
||||
|
||||
if (!path.empty() && FilesystemUtils::FileExistsAtPath(path)) {
|
||||
apd->distances.resize(indexCount);
|
||||
// load from file
|
||||
const auto& bytes = _scratch.ReplaceWithPath(path);
|
||||
const auto* ptr = reinterpret_cast<const DIST_T*>(bytes.data());
|
||||
vector<std::future<vector<vector<DIST_T>>>> futures(indexCount);
|
||||
|
||||
for (int fromIndex = 0; fromIndex < indexCount; fromIndex++) {
|
||||
apd->distances[fromIndex].insert(
|
||||
apd->distances[fromIndex].end(),
|
||||
&(ptr[0]),
|
||||
&(ptr[indexCount]));
|
||||
ptr += indexCount;
|
||||
}
|
||||
result.loadedFromFile = true;
|
||||
} else {
|
||||
_scratch.reserve(indexCount * indexCount * sizeof(DIST_T));
|
||||
auto braveWaterPossibleCoords =
|
||||
includeBravingWater ? apd->BraveWaterPossibleCoords(map) : nullptr;
|
||||
|
||||
vector<std::future<vector<vector<DIST_T>>>> futures(indexCount);
|
||||
int chunkSize = (indexCount + ASYNC_COUNT - 1) / ASYNC_COUNT;
|
||||
// Break into chunks for async
|
||||
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
|
||||
futures[chunkIdx] = std::async(std::launch::async, [=]() -> vector<vector<DIST_T>> {
|
||||
vector<vector<DIST_T>> chunkVec;
|
||||
chunkVec.reserve(chunkSize);
|
||||
const int chunkStartIndex = chunkIdx * chunkSize;
|
||||
|
||||
auto braveWaterPossibleCoords =
|
||||
includeBravingWater ? apd->BraveWaterPossibleCoords(map) : nullptr;
|
||||
|
||||
int chunkSize = (indexCount + ASYNC_COUNT - 1) / ASYNC_COUNT;
|
||||
// Break into chunks for async
|
||||
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
|
||||
futures[chunkIdx] = std::async(std::launch::async, [=]() -> vector<vector<DIST_T>> {
|
||||
vector<vector<DIST_T>> chunkVec;
|
||||
chunkVec.reserve(chunkSize);
|
||||
const int chunkStartIndex = chunkIdx * chunkSize;
|
||||
|
||||
for (int i = 0; i < chunkSize; i++) {
|
||||
const auto fromIndex = chunkStartIndex + i;
|
||||
if (fromIndex >= indexCount) { continue; }
|
||||
chunkVec.push_back(ActionPointDistances::GenerateDistances(
|
||||
fromIndex,
|
||||
map,
|
||||
includeBravingWater,
|
||||
braveWaterActionPointCost,
|
||||
battalionType,
|
||||
braveWaterPossibleCoords));
|
||||
}
|
||||
return chunkVec;
|
||||
});
|
||||
}
|
||||
|
||||
apd->distances.reserve(indexCount);
|
||||
_scratch.clear();
|
||||
_scratch.reserve(indexCount * indexCount * sizeof(DIST_T));
|
||||
|
||||
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
|
||||
auto resultsVec = futures[chunkIdx].get();
|
||||
apd->distances.insert(apd->distances.end(), resultsVec.begin(), resultsVec.end());
|
||||
for (const auto& r : resultsVec) { _scratch.append(r); }
|
||||
}
|
||||
|
||||
if (!path.empty()) { FilesystemUtils::AtomicallySaveToPath(path, _scratch); }
|
||||
for (int i = 0; i < chunkSize; i++) {
|
||||
const auto fromIndex = chunkStartIndex + i;
|
||||
if (fromIndex >= indexCount) { continue; }
|
||||
chunkVec.push_back(ActionPointDistances::GenerateDistances(
|
||||
fromIndex,
|
||||
map,
|
||||
includeBravingWater,
|
||||
braveWaterActionPointCost,
|
||||
battalionType,
|
||||
braveWaterPossibleCoords));
|
||||
}
|
||||
return chunkVec;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
apd->distances.reserve(indexCount);
|
||||
|
||||
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
|
||||
auto resultsVec = futures[chunkIdx].get();
|
||||
apd->distances.insert(apd->distances.end(), resultsVec.begin(), resultsVec.end());
|
||||
}
|
||||
|
||||
return apd;
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
+2
-16
@@ -17,31 +17,19 @@ using std::vector;
|
||||
using BattalionTypeSPtr = std::shared_ptr<const BattalionType>;
|
||||
|
||||
class FixedActionPointDistances final : public ActionPointDistances {
|
||||
public:
|
||||
struct CreationResult {
|
||||
std::shared_ptr<FixedActionPointDistances> apd;
|
||||
bool loadedFromFile;
|
||||
};
|
||||
|
||||
private:
|
||||
vector<vector<DIST_T>> distances;
|
||||
|
||||
inline static string cacheDirectory = "";
|
||||
|
||||
// Private constructor - use Create factory method instead
|
||||
explicit FixedActionPointDistances(const HexMap *map, int columnCount);
|
||||
|
||||
public:
|
||||
static void SetCacheDirectory(const string &newDir);
|
||||
|
||||
// Factory method to create FixedActionPointDistances with metadata
|
||||
// Factory method to create FixedActionPointDistances
|
||||
static auto Create(
|
||||
const HexMap *map,
|
||||
int64_t terrainTypesHash,
|
||||
int64_t modifierHash,
|
||||
const BattalionTypeSPtr &battalionType,
|
||||
bool includeBravingWater,
|
||||
int braveWaterActionPointCost = -1) -> CreationResult;
|
||||
int braveWaterActionPointCost = -1) -> std::shared_ptr<FixedActionPointDistances>;
|
||||
|
||||
~FixedActionPointDistances() override = default;
|
||||
|
||||
@@ -52,8 +40,6 @@ public:
|
||||
[[nodiscard]] auto Distance(const Coords &from, const Coords &to) const -> DIST_T override {
|
||||
return Distance(ToIndex(from), ToIndex(to));
|
||||
}
|
||||
|
||||
friend struct CreationResult;
|
||||
};
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
@@ -322,23 +322,6 @@ void EagleInterfaceImpl::PopulateGameStatusResponse(
|
||||
}
|
||||
}
|
||||
|
||||
auto EagleInterfaceImpl::GetGameStatus(
|
||||
ServerContext * /*context*/,
|
||||
const GameStatusRequest *request,
|
||||
GameStatusResponse *response) -> Status {
|
||||
shared_ptr<ShardokGameController> controller;
|
||||
try {
|
||||
controller = ControllerForGame(request->game_id(), request->game_setup_info());
|
||||
} catch (NewGameException &e) { return e.GetStatus(); }
|
||||
|
||||
PopulateGameStatusResponse(
|
||||
controller,
|
||||
request->game_setup_info().known_result_count(),
|
||||
response);
|
||||
|
||||
return Status::OK;
|
||||
}
|
||||
|
||||
auto EagleInterfaceImpl::GetHexMap(
|
||||
ServerContext * /*context*/,
|
||||
const HexMapRequest *request,
|
||||
@@ -529,6 +512,130 @@ auto FromInternalStatus(
|
||||
throw ShardokInternalErrorException("Bad unit status on resolved unit");
|
||||
}
|
||||
|
||||
auto EagleInterfaceImpl::SubscribeToGame(
|
||||
ServerContext *context,
|
||||
const GameSubscriptionRequest *request,
|
||||
grpc::ServerWriter<GameStatusResponse> *writer) -> Status {
|
||||
shared_ptr<ShardokGameController> controller;
|
||||
try {
|
||||
controller = ControllerForGame(request->game_id(), request->game_setup_info());
|
||||
} catch (NewGameException &e) { return e.GetStatus(); }
|
||||
|
||||
if (!controller) { return Status(StatusCode::NOT_FOUND, "Game not found"); }
|
||||
|
||||
// Send initial state
|
||||
GameStatusResponse initialResponse;
|
||||
PopulateGameStatusResponse(
|
||||
controller,
|
||||
request->game_setup_info().known_result_count(),
|
||||
&initialResponse);
|
||||
if (!writer->Write(initialResponse)) {
|
||||
return Status::OK; // Client disconnected
|
||||
}
|
||||
|
||||
// If game was already over, we're done
|
||||
if (initialResponse.has_game_over_response()) { return Status::OK; }
|
||||
|
||||
// Track the actual count after initial response to avoid duplicate sends
|
||||
const int64_t countAfterInitialResponse =
|
||||
initialResponse.has_game_update_response()
|
||||
? initialResponse.game_update_response().total_action_result_count()
|
||||
: request->game_setup_info().known_result_count();
|
||||
|
||||
// Create a subscriber that writes to the gRPC stream
|
||||
class GrpcStreamSubscriber : public StreamSubscriber {
|
||||
private:
|
||||
grpc::ServerWriter<GameStatusResponse> *writer_;
|
||||
ServerContext *context_;
|
||||
std::atomic<bool> active_{true};
|
||||
std::string gameId_;
|
||||
|
||||
public:
|
||||
GrpcStreamSubscriber(
|
||||
grpc::ServerWriter<GameStatusResponse> *w,
|
||||
ServerContext *ctx,
|
||||
std::string gameId)
|
||||
: writer_(w),
|
||||
context_(ctx),
|
||||
gameId_(std::move(gameId)) {}
|
||||
|
||||
void OnUpdate(
|
||||
const vector<ActionResult> &mainResults,
|
||||
const vector<OnePlayerUpdates> &filteredResults,
|
||||
int32_t newUnfilteredCount,
|
||||
const byte_vector ¤tGameState) override {
|
||||
if (!active_) return;
|
||||
|
||||
GameStatusResponse response;
|
||||
response.set_game_id(gameId_);
|
||||
|
||||
response.mutable_game_update_response()->mutable_update_responses()->Add(
|
||||
begin(mainResults),
|
||||
end(mainResults));
|
||||
|
||||
// Add filtered results for each player with their faction IDs
|
||||
for (const auto &playerUpdate : filteredResults) {
|
||||
auto *filtered =
|
||||
response.mutable_game_update_response()->add_filtered_update_responses();
|
||||
filtered->set_eagle_faction_id(playerUpdate.eagleFactionId);
|
||||
filtered->mutable_action_result_views()->Add(
|
||||
begin(playerUpdate.resultViews),
|
||||
end(playerUpdate.resultViews));
|
||||
if (playerUpdate.availableCommands) {
|
||||
*filtered->mutable_available_commands() = *playerUpdate.availableCommands;
|
||||
}
|
||||
}
|
||||
|
||||
response.mutable_game_update_response()->set_total_action_result_count(
|
||||
newUnfilteredCount);
|
||||
*response.mutable_game_update_response()->mutable_current_game_state() =
|
||||
std::string(currentGameState.begin(), currentGameState.end());
|
||||
|
||||
if (!writer_->Write(response)) { active_ = false; }
|
||||
}
|
||||
|
||||
void OnGameOver(const GameOverInfo &info) override {
|
||||
if (!active_) return;
|
||||
|
||||
GameStatusResponse response;
|
||||
response.set_game_id(gameId_);
|
||||
|
||||
PopulateGameOverResponse(
|
||||
gameId_,
|
||||
info.gameStatus,
|
||||
info.playerInfos,
|
||||
info.endGameUnits,
|
||||
response.mutable_game_over_response());
|
||||
|
||||
writer_->Write(response);
|
||||
active_ = false;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto IsActive() const -> bool override {
|
||||
return active_ && !context_->IsCancelled();
|
||||
}
|
||||
};
|
||||
|
||||
auto subscriber =
|
||||
std::make_shared<GrpcStreamSubscriber>(writer, context, controller->GetGameId());
|
||||
|
||||
controller->RegisterSubscriber(subscriber);
|
||||
|
||||
// Wait for updates and push them until game ends or subscriber disconnects
|
||||
// Use countAfterInitialResponse to avoid re-sending results already in initial response
|
||||
bool gameEnded = controller->WaitForUpdatesAndPush(subscriber, countAfterInitialResponse);
|
||||
|
||||
controller->UnregisterSubscriber(subscriber.get());
|
||||
|
||||
if (gameEnded) {
|
||||
printf("SubscribeToGame: Game ended normally\n");
|
||||
} else {
|
||||
printf("SubscribeToGame: Subscriber disconnected\n");
|
||||
}
|
||||
|
||||
return Status::OK;
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#ifndef NDEBUG
|
||||
|
||||
@@ -31,8 +31,8 @@ namespace shardok {
|
||||
using grpc::ServerContext;
|
||||
using grpc::Status;
|
||||
using net::eagle0::common::GameSetupInfo;
|
||||
using net::eagle0::common::GameStatusRequest;
|
||||
using net::eagle0::common::GameStatusResponse;
|
||||
using net::eagle0::common::GameSubscriptionRequest;
|
||||
using net::eagle0::common::HexMapNamesRequest;
|
||||
using net::eagle0::common::HexMapNamesResponse;
|
||||
using net::eagle0::common::HexMapRequest;
|
||||
@@ -67,10 +67,6 @@ public:
|
||||
ServerContext* context,
|
||||
const PlacementCommandsRequest* request,
|
||||
GameStatusResponse* response) -> Status override;
|
||||
auto GetGameStatus(
|
||||
ServerContext* context,
|
||||
const GameStatusRequest* request,
|
||||
GameStatusResponse* response) -> Status override;
|
||||
|
||||
auto GetHexMap(ServerContext* context, const HexMapRequest* request, HexMapResponse* response)
|
||||
-> Status override;
|
||||
@@ -78,6 +74,11 @@ public:
|
||||
ServerContext* context,
|
||||
const HexMapNamesRequest* request,
|
||||
HexMapNamesResponse* response) -> Status override;
|
||||
|
||||
auto SubscribeToGame(
|
||||
ServerContext* context,
|
||||
const GameSubscriptionRequest* request,
|
||||
grpc::ServerWriter<GameStatusResponse>* writer) -> Status override;
|
||||
};
|
||||
} // namespace shardok
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include "ServerConfiguration.hpp"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
@@ -30,9 +31,16 @@ using std::unordered_map;
|
||||
unordered_map<string, string> DefaultValues();
|
||||
|
||||
unordered_map<string, string> DefaultValues() {
|
||||
// Check environment variables for Docker deployment
|
||||
// Docker containers should set SHARDOK_EAGLE_INTERFACE_ADDRESS=0.0.0.0:40042
|
||||
// to listen on all interfaces for container networking
|
||||
const char* eagleInterfaceAddr = getenv("SHARDOK_EAGLE_INTERFACE_ADDRESS");
|
||||
const char* shardokAddr = getenv("SHARDOK_GRPC_ADDRESS");
|
||||
|
||||
return unordered_map<string, string>{
|
||||
{ServerConfiguration::kShardokGrpcAddress, "localhost"},
|
||||
{ServerConfiguration::kEagleInterfaceGrpcAddress, "localhost"}};
|
||||
{ServerConfiguration::kShardokGrpcAddress, shardokAddr ? shardokAddr : "localhost"},
|
||||
{ServerConfiguration::kEagleInterfaceGrpcAddress,
|
||||
eagleInterfaceAddr ? eagleInterfaceAddr : "localhost:40042"}};
|
||||
}
|
||||
|
||||
ServerConfiguration::ServerConfiguration(const string& filePath) {
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
#include "ServerConfiguration.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/controller/ShardokGameController.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings_loader/SettingsLoader.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/util/MapLoader.hpp"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit.hpp"
|
||||
@@ -45,9 +44,6 @@ ShardokGamesManager::ShardokGamesManager(const std::vector<std::string> &extraSe
|
||||
std::cerr << "NOT SETTING" << std::endl;
|
||||
// setter.SetRaw(key, value);
|
||||
}
|
||||
|
||||
FixedActionPointDistances::SetCacheDirectory(
|
||||
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
|
||||
}
|
||||
|
||||
auto ShardokGamesManager::GetController(const GameId &gameId)
|
||||
|
||||
@@ -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,161 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Cysharp.Net.Http;
|
||||
using Grpc.Core;
|
||||
using Grpc.Core.Interceptors;
|
||||
using Grpc.Net.Client;
|
||||
using Net.Eagle0.Eagle.Api.Auth;
|
||||
using UnityEngine;
|
||||
using GrpcAuthClient = Net.Eagle0.Eagle.Api.Auth.Auth.AuthClient;
|
||||
|
||||
namespace Auth {
|
||||
/// <summary>
|
||||
/// gRPC client for the Auth service.
|
||||
/// Handles OAuth URL generation, status polling, and token refresh.
|
||||
/// </summary>
|
||||
public class AuthClient : IDisposable {
|
||||
private const int PollIntervalMs = 2000; // Poll every 2 seconds
|
||||
private const int PollTimeoutMs = 300000; // 5 minute timeout
|
||||
|
||||
private readonly GrpcAuthClient _client;
|
||||
private readonly GrpcChannel _channel;
|
||||
|
||||
/// <summary>
|
||||
/// Create auth client for the given server URL.
|
||||
/// </summary>
|
||||
/// <param name="serverUrl">Full URL with scheme, e.g. "http://localhost:40032" or
|
||||
/// "https://prod.eagle0.net"</param>
|
||||
public AuthClient(string serverUrl) {
|
||||
_channel = GrpcChannel.ForAddress(
|
||||
serverUrl,
|
||||
new GrpcChannelOptions {
|
||||
HttpHandler = new YetAnotherHttpHandler { Http2Only = true },
|
||||
DisposeHttpClient = true
|
||||
});
|
||||
|
||||
// Create invoker with JWT interceptor for authenticated requests
|
||||
CallInvoker invoker = _channel.Intercept(new JwtAuthInterceptor());
|
||||
_client = new GrpcAuthClient(invoker);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get OAuth URL to open in system browser.
|
||||
/// Returns both the URL and the state token for polling.
|
||||
/// </summary>
|
||||
/// <param name="provider">Discord or Google</param>
|
||||
/// <returns>Tuple of (URL to open in browser, state token for polling)</returns>
|
||||
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(OAuthProvider provider) {
|
||||
var request = new GetOAuthUrlRequest { Provider = provider };
|
||||
|
||||
var response = await _client.GetOAuthUrlAsync(request);
|
||||
|
||||
Debug.Log($"[AuthClient] Got OAuth URL for {provider}, state={response.State}");
|
||||
return (response.AuthUrl, response.State);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Poll for OAuth completion. Blocks until success, failure, or timeout.
|
||||
/// The server handles the OAuth callback and token exchange.
|
||||
/// </summary>
|
||||
/// <param name="state">State token from GetOAuthUrlAsync</param>
|
||||
/// <returns>Response with tokens and user info on success</returns>
|
||||
public async Task<CheckOAuthStatusResponse> PollForOAuthCompletionAsync(string state) {
|
||||
var startTime = DateTime.UtcNow;
|
||||
|
||||
while (true) {
|
||||
var request = new CheckOAuthStatusRequest { State = state };
|
||||
var response = await _client.CheckOAuthStatusAsync(request);
|
||||
|
||||
switch (response.Status) {
|
||||
case OAuthStatus.Success:
|
||||
Debug.Log(
|
||||
$"[AuthClient] OAuth successful, user={response.User?.DisplayName}, isNew={response.IsNewUser}");
|
||||
return response;
|
||||
|
||||
case OAuthStatus.Failed:
|
||||
throw new Exception($"OAuth failed: {response.ErrorMessage}");
|
||||
|
||||
case OAuthStatus.Expired:
|
||||
throw new Exception("OAuth session expired. Please try again.");
|
||||
|
||||
case OAuthStatus.Pending:
|
||||
// Check timeout
|
||||
if ((DateTime.UtcNow - startTime).TotalMilliseconds > PollTimeoutMs) {
|
||||
throw new TimeoutException("OAuth login timed out. Please try again.");
|
||||
}
|
||||
// Wait before polling again
|
||||
await Task.Delay(PollIntervalMs);
|
||||
break;
|
||||
|
||||
default: throw new Exception($"Unknown OAuth status: {response.Status}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh access token using stored refresh token.
|
||||
/// </summary>
|
||||
/// <returns>New access token and expiry, or null if refresh failed</returns>
|
||||
public async Task<RefreshTokenResponse> RefreshTokenAsync() {
|
||||
var refreshToken = TokenStorage.RefreshToken;
|
||||
if (string.IsNullOrEmpty(refreshToken)) {
|
||||
throw new InvalidOperationException("No refresh token available");
|
||||
}
|
||||
|
||||
var request = new RefreshTokenRequest { RefreshToken = refreshToken };
|
||||
|
||||
var response = await _client.RefreshTokenAsync(request);
|
||||
|
||||
// Update stored access token
|
||||
TokenStorage.UpdateAccessToken(response.AccessToken, response.ExpiresAt);
|
||||
|
||||
Debug.Log($"[AuthClient] Token refreshed, expires at {response.ExpiresAt}");
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set display name for new users.
|
||||
/// Requires valid JWT in interceptor.
|
||||
/// </summary>
|
||||
public async Task<SetDisplayNameResponse> SetDisplayNameAsync(string displayName) {
|
||||
var request = new SetDisplayNameRequest { DisplayName = displayName };
|
||||
var response = await _client.SetDisplayNameAsync(request);
|
||||
|
||||
if (response.Success) {
|
||||
TokenStorage.UpdateDisplayName(displayName);
|
||||
Debug.Log($"[AuthClient] Display name set to: {displayName}");
|
||||
} else {
|
||||
Debug.LogWarning(
|
||||
$"[AuthClient] Failed to set display name: {response.ErrorMessage}");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current user info. Validates the stored JWT.
|
||||
/// </summary>
|
||||
public async Task<GetCurrentUserResponse> GetCurrentUserAsync() {
|
||||
var response = await _client.GetCurrentUserAsync(new GetCurrentUserRequest());
|
||||
Debug.Log($"[AuthClient] Current user: {response.User?.DisplayName}");
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logout - invalidates refresh token on server.
|
||||
/// </summary>
|
||||
public async Task LogoutAsync() {
|
||||
try {
|
||||
await _client.LogoutAsync(new LogoutRequest());
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[AuthClient] Logout RPC failed (may be expected): {ex.Message}");
|
||||
}
|
||||
|
||||
// Clear local tokens regardless of server response
|
||||
TokenStorage.Clear();
|
||||
Debug.Log("[AuthClient] Logged out, tokens cleared");
|
||||
}
|
||||
|
||||
public void Dispose() { _channel?.Dispose(); }
|
||||
}
|
||||
}
|
||||
@@ -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,183 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Net.Eagle0.Eagle.Api.Auth;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Auth {
|
||||
/// <summary>
|
||||
/// Manages OAuth authentication flow using server-mediated polling:
|
||||
/// - Opens system browser for OAuth consent
|
||||
/// - Polls server for OAuth completion (no deep links needed)
|
||||
/// - Token storage and refresh
|
||||
/// </summary>
|
||||
public class OAuthManager : MonoBehaviour {
|
||||
public static OAuthManager Instance { get; private set; }
|
||||
|
||||
private AuthClient _authClient;
|
||||
private string _currentServerUrl;
|
||||
|
||||
// Events for UI updates
|
||||
public event Action<UserInfo> OnLoginSuccess;
|
||||
public event Action<string> OnLoginFailed;
|
||||
public event Action OnLogout;
|
||||
public event Action<bool> OnNewUserNeedsDisplayName; // true if new user
|
||||
|
||||
public bool IsAuthenticated => TokenStorage.HasValidToken;
|
||||
public string DisplayName => TokenStorage.DisplayName;
|
||||
public string UserId => TokenStorage.UserId;
|
||||
|
||||
private void Awake() {
|
||||
if (Instance != null && Instance != this) {
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
// AuthClient is created lazily when SetServerUrl is called
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the server URL for OAuth requests. Call this before any OAuth operations.
|
||||
/// </summary>
|
||||
/// <param name="serverUrl">Full URL with scheme, e.g. "https://prod.eagle0.net"</param>
|
||||
public void SetServerUrl(string serverUrl) {
|
||||
if (_currentServerUrl == serverUrl && _authClient != null) {
|
||||
return; // Already configured for this URL
|
||||
}
|
||||
|
||||
_authClient?.Dispose();
|
||||
_currentServerUrl = serverUrl;
|
||||
_authClient = new AuthClient(serverUrl);
|
||||
Debug.Log($"[OAuthManager] Configured for server: {serverUrl}");
|
||||
}
|
||||
|
||||
private void OnDestroy() {
|
||||
_authClient?.Dispose();
|
||||
if (Instance == this) { Instance = null; }
|
||||
}
|
||||
|
||||
private void EnsureConfigured() {
|
||||
if (_authClient == null) {
|
||||
throw new InvalidOperationException(
|
||||
"OAuthManager not configured. Call SetServerUrl() first.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start OAuth login with specified provider.
|
||||
/// Opens system browser for user consent, then polls for completion.
|
||||
/// </summary>
|
||||
public async Task<CheckOAuthStatusResponse> LoginAsync(OAuthProvider provider) {
|
||||
EnsureConfigured();
|
||||
try {
|
||||
// Get OAuth URL and state from server
|
||||
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider);
|
||||
|
||||
// Open system browser
|
||||
Debug.Log($"[OAuthManager] Opening browser: {authUrl}");
|
||||
Application.OpenURL(authUrl);
|
||||
|
||||
// Poll for OAuth completion (server handles the callback)
|
||||
var response = await _authClient.PollForOAuthCompletionAsync(state);
|
||||
|
||||
// Store tokens
|
||||
TokenStorage.StoreTokens(
|
||||
response.AccessToken,
|
||||
response.RefreshToken,
|
||||
response.ExpiresAt,
|
||||
response.User.UserId,
|
||||
response.User.DisplayName ?? "");
|
||||
|
||||
// Notify listeners
|
||||
if (response.IsNewUser) {
|
||||
OnNewUserNeedsDisplayName?.Invoke(true);
|
||||
} else {
|
||||
OnLoginSuccess?.Invoke(response.User);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (Exception ex) {
|
||||
Debug.LogError($"[OAuthManager] Login failed: {ex.Message}");
|
||||
OnLoginFailed?.Invoke(ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set display name for new user after OAuth.
|
||||
/// </summary>
|
||||
public async Task<bool> SetDisplayNameAsync(string displayName) {
|
||||
EnsureConfigured();
|
||||
var response = await _authClient.SetDisplayNameAsync(displayName);
|
||||
|
||||
if (response.Success) { OnLoginSuccess?.Invoke(response.User); }
|
||||
|
||||
return response.Success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to restore session from stored tokens.
|
||||
/// Returns true if valid session exists.
|
||||
/// Must call SetServerUrl() before this method.
|
||||
/// </summary>
|
||||
public async Task<bool> TryRestoreSessionAsync() {
|
||||
if (_authClient == null) {
|
||||
Debug.Log("[OAuthManager] Not configured yet, skipping session restore");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TokenStorage.HasValidToken && !TokenStorage.HasRefreshToken) {
|
||||
Debug.Log("[OAuthManager] No stored session");
|
||||
return false;
|
||||
}
|
||||
|
||||
// If token is expired but we have refresh token, try to refresh
|
||||
if (!TokenStorage.HasValidToken && TokenStorage.HasRefreshToken) {
|
||||
try {
|
||||
await _authClient.RefreshTokenAsync();
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[OAuthManager] Token refresh failed: {ex.Message}");
|
||||
TokenStorage.Clear();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate session with server
|
||||
try {
|
||||
var response = await _authClient.GetCurrentUserAsync();
|
||||
Debug.Log($"[OAuthManager] Session restored for {response.User.DisplayName}");
|
||||
OnLoginSuccess?.Invoke(response.User);
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[OAuthManager] Session validation failed: {ex.Message}");
|
||||
TokenStorage.Clear();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh access token if needed.
|
||||
/// Call this before making API requests.
|
||||
/// </summary>
|
||||
public async Task EnsureValidTokenAsync() {
|
||||
EnsureConfigured();
|
||||
if (TokenStorage.NeedsRefresh && TokenStorage.HasRefreshToken) {
|
||||
await _authClient.RefreshTokenAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logout and clear stored tokens.
|
||||
/// </summary>
|
||||
public async Task LogoutAsync() {
|
||||
// LogoutAsync can work even without server connection - just clear local tokens
|
||||
if (_authClient != null) {
|
||||
await _authClient.LogoutAsync();
|
||||
} else {
|
||||
TokenStorage.Clear();
|
||||
}
|
||||
OnLogout?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0e9d5b0cb9f26477eaf26e8a09c41707
|
||||
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Auth {
|
||||
/// <summary>
|
||||
/// Secure storage for OAuth tokens using Unity's PlayerPrefs.
|
||||
/// In production, consider using platform-specific secure storage
|
||||
/// (Keychain on iOS, Keystore on Android).
|
||||
/// </summary>
|
||||
public static class TokenStorage {
|
||||
private const string AccessTokenKey = "eagle0_access_token";
|
||||
private const string RefreshTokenKey = "eagle0_refresh_token";
|
||||
private const string ExpiresAtKey = "eagle0_expires_at";
|
||||
private const string UserIdKey = "eagle0_user_id";
|
||||
private const string DisplayNameKey = "eagle0_display_name";
|
||||
|
||||
public static bool HasValidToken {
|
||||
get {
|
||||
var token = AccessToken;
|
||||
var expiresAt = ExpiresAt;
|
||||
return !string.IsNullOrEmpty(token) &&
|
||||
expiresAt > DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasRefreshToken => !string.IsNullOrEmpty(RefreshToken);
|
||||
|
||||
public static string AccessToken {
|
||||
get => PlayerPrefs.GetString(AccessTokenKey, null);
|
||||
private
|
||||
set => PlayerPrefs.SetString(AccessTokenKey, value ?? "");
|
||||
}
|
||||
|
||||
public static string RefreshToken {
|
||||
get => PlayerPrefs.GetString(RefreshTokenKey, null);
|
||||
private
|
||||
set => PlayerPrefs.SetString(RefreshTokenKey, value ?? "");
|
||||
}
|
||||
|
||||
public static long ExpiresAt {
|
||||
get => long.TryParse(PlayerPrefs.GetString(ExpiresAtKey, "0"), out var val) ? val : 0;
|
||||
private
|
||||
set => PlayerPrefs.SetString(ExpiresAtKey, value.ToString());
|
||||
}
|
||||
|
||||
public static string UserId {
|
||||
get => PlayerPrefs.GetString(UserIdKey, null);
|
||||
private
|
||||
set => PlayerPrefs.SetString(UserIdKey, value ?? "");
|
||||
}
|
||||
|
||||
public static string DisplayName {
|
||||
get => PlayerPrefs.GetString(DisplayNameKey, null);
|
||||
private
|
||||
set => PlayerPrefs.SetString(DisplayNameKey, value ?? "");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store tokens received from OAuth exchange.
|
||||
/// </summary>
|
||||
public static void StoreTokens(
|
||||
string accessToken,
|
||||
string refreshToken,
|
||||
long expiresAt,
|
||||
string userId,
|
||||
string displayName) {
|
||||
AccessToken = accessToken;
|
||||
RefreshToken = refreshToken;
|
||||
ExpiresAt = expiresAt;
|
||||
UserId = userId;
|
||||
DisplayName = displayName;
|
||||
PlayerPrefs.Save();
|
||||
|
||||
Debug.Log(
|
||||
$"[TokenStorage] Stored tokens for user {displayName} (expires at {expiresAt})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update access token after refresh.
|
||||
/// </summary>
|
||||
public static void UpdateAccessToken(string accessToken, long expiresAt) {
|
||||
AccessToken = accessToken;
|
||||
ExpiresAt = expiresAt;
|
||||
PlayerPrefs.Save();
|
||||
|
||||
Debug.Log($"[TokenStorage] Updated access token (expires at {expiresAt})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update display name after user sets it.
|
||||
/// </summary>
|
||||
public static void UpdateDisplayName(string displayName) {
|
||||
DisplayName = displayName;
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all stored tokens (logout).
|
||||
/// </summary>
|
||||
public static void Clear() {
|
||||
PlayerPrefs.DeleteKey(AccessTokenKey);
|
||||
PlayerPrefs.DeleteKey(RefreshTokenKey);
|
||||
PlayerPrefs.DeleteKey(ExpiresAtKey);
|
||||
PlayerPrefs.DeleteKey(UserIdKey);
|
||||
PlayerPrefs.DeleteKey(DisplayNameKey);
|
||||
PlayerPrefs.Save();
|
||||
|
||||
Debug.Log("[TokenStorage] Cleared all tokens");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if token needs refresh (expires within 5 minutes).
|
||||
/// </summary>
|
||||
public static bool NeedsRefresh {
|
||||
get {
|
||||
var expiresAt = ExpiresAt;
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
return expiresAt > 0 && expiresAt - now < 300; // 5 minutes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+348
-17
@@ -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 {
|
||||
@@ -77,11 +80,31 @@ public class AuthInterceptor : Interceptor {
|
||||
};
|
||||
|
||||
public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
public TMP_InputField urlField;
|
||||
public TMP_Dropdown environmentDropdown;
|
||||
public TMP_InputField nameField;
|
||||
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;
|
||||
|
||||
public GameObject connectionPanel;
|
||||
public GameObject gameSelectionPanel;
|
||||
public GameObject customBattlePanel;
|
||||
@@ -95,6 +118,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;
|
||||
@@ -109,14 +134,42 @@ 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;
|
||||
|
||||
const String DefaultUrl = "eagle0.net";
|
||||
const String URLKey = "urlKey";
|
||||
const String BaseDomain = "eagle0.net";
|
||||
const String EnvironmentKey = "environmentKey";
|
||||
const String NameKey = "nameKey";
|
||||
const String PasswordKey = "passwordKey";
|
||||
|
||||
// Environment options: index 0 = prod., index 1 = qa., index 2 = (none)
|
||||
private static readonly List<string> EnvironmentOptions = new() { "prod.", "qa.", "" };
|
||||
private static readonly List<string> EnvironmentDisplayNames =
|
||||
new() { "prod.", "qa.", "(none)" };
|
||||
|
||||
/// <summary>
|
||||
/// Returns the connected environment name ("prod." or "qa.") or null if not connected.
|
||||
/// </summary>
|
||||
public string ConnectedEnvironmentName =>
|
||||
_connectedEnvironmentIndex >= 0 &&
|
||||
_connectedEnvironmentIndex < EnvironmentDisplayNames.Count
|
||||
? EnvironmentDisplayNames[_connectedEnvironmentIndex]
|
||||
: null;
|
||||
|
||||
private string GetUrlFromEnvironment() {
|
||||
int envIndex = environmentDropdown.value;
|
||||
string prefix = EnvironmentOptions[envIndex];
|
||||
return prefix + BaseDomain;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get full URL with scheme for the selected environment.
|
||||
/// Remote servers use HTTPS, could be extended for local HTTP.
|
||||
/// </summary>
|
||||
private string GetFullUrlFromEnvironment() { return "https://" + GetUrlFromEnvironment(); }
|
||||
|
||||
public void ReceiveLobbyUpdate(LobbyResponse lobbyResponse) {
|
||||
_handleLobbyResponse(lobbyResponse);
|
||||
}
|
||||
@@ -133,7 +186,11 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
resolutionDropdown.AddOptions(resolutions.Select(r => $"{r.width} x {r.height}").ToList());
|
||||
resolutionDropdown.value = resolutions.ToList().IndexOf(currentResolution);
|
||||
|
||||
urlField.text = PlayerPrefs.GetString(URLKey, DefaultUrl);
|
||||
// Set up environment dropdown
|
||||
environmentDropdown.ClearOptions();
|
||||
environmentDropdown.AddOptions(EnvironmentDisplayNames);
|
||||
environmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
|
||||
|
||||
nameField.text = PlayerPrefs.GetString(NameKey, "sample_name");
|
||||
passwordField.text = PlayerPrefs.GetString(PasswordKey, "sample_password");
|
||||
|
||||
@@ -147,6 +204,234 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
customBattlePanel.gameObject.SetActive(false);
|
||||
|
||||
errorHandler.gameObject.SetActive(true);
|
||||
|
||||
// Initialize OAuth UI
|
||||
SetupOAuthUI();
|
||||
|
||||
// Initialize status text
|
||||
UpdateConnectionStatus();
|
||||
|
||||
// Try to restore existing OAuth session
|
||||
TryRestoreSession();
|
||||
}
|
||||
|
||||
private void SetupOAuthUI() {
|
||||
// Set up OAuth button click handlers
|
||||
if (discordLoginButton != null) {
|
||||
discordLoginButton.onClick.AddListener(
|
||||
() => OnOAuthLoginClicked(OAuthProvider.Discord));
|
||||
}
|
||||
if (googleLoginButton != null) {
|
||||
googleLoginButton.onClick.AddListener(() => OnOAuthLoginClicked(OAuthProvider.Google));
|
||||
}
|
||||
if (setDisplayNameButton != null) {
|
||||
setDisplayNameButton.onClick.AddListener(OnSetDisplayNameClicked);
|
||||
}
|
||||
if (useLegacyAuthButton != null) {
|
||||
useLegacyAuthButton.onClick.AddListener(OnUseLegacyAuthClicked);
|
||||
}
|
||||
|
||||
// Subscribe to OAuthManager events
|
||||
if (OAuthManager.Instance != null) {
|
||||
OAuthManager.Instance.OnLoginSuccess += OnOAuthLoginSuccess;
|
||||
OAuthManager.Instance.OnLoginFailed += OnOAuthLoginFailed;
|
||||
OAuthManager.Instance.OnNewUserNeedsDisplayName += OnNewUserNeedsDisplayName;
|
||||
OAuthManager.Instance.OnLogout += OnOAuthLogout;
|
||||
}
|
||||
|
||||
// Show appropriate panel based on auth state
|
||||
ShowAuthPanel();
|
||||
}
|
||||
|
||||
private void ShowAuthPanel() {
|
||||
// Hide all auth panels first
|
||||
if (oauthPanel != null) oauthPanel.SetActive(false);
|
||||
if (displayNamePanel != null) displayNamePanel.SetActive(false);
|
||||
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(false);
|
||||
|
||||
// Show appropriate panel based on _useOAuth
|
||||
if (_useOAuth) {
|
||||
if (oauthPanel != null) oauthPanel.SetActive(true);
|
||||
if (useLegacyAuthButtonText != null)
|
||||
useLegacyAuthButtonText.text = "Use Classic Sign-in";
|
||||
} else {
|
||||
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(true);
|
||||
if (useLegacyAuthButtonText != null) useLegacyAuthButtonText.text = "Use OAuth Sign-in";
|
||||
}
|
||||
}
|
||||
|
||||
private async void TryRestoreSession() {
|
||||
if (OAuthManager.Instance == null) return;
|
||||
|
||||
// Configure OAuthManager with current environment URL
|
||||
OAuthManager.Instance.SetServerUrl(GetFullUrlFromEnvironment());
|
||||
|
||||
if (oauthStatusText != null) { oauthStatusText.text = "Checking for existing session..."; }
|
||||
|
||||
var restored = await OAuthManager.Instance.TryRestoreSessionAsync();
|
||||
if (restored) {
|
||||
// Session restored, connect to lobby
|
||||
ConnectWithOAuth();
|
||||
} else if (oauthStatusText != null) {
|
||||
oauthStatusText.text = "";
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnOAuthLoginClicked(OAuthProvider provider) {
|
||||
// Configure OAuthManager with current environment URL (user may have changed dropdown)
|
||||
OAuthManager.Instance.SetServerUrl(GetFullUrlFromEnvironment());
|
||||
|
||||
if (oauthStatusText != null) {
|
||||
oauthStatusText.text = $"Opening browser for {provider} login...";
|
||||
}
|
||||
|
||||
try {
|
||||
await OAuthManager.Instance.LoginAsync(provider);
|
||||
// OnLoginSuccess or OnNewUserNeedsDisplayName will be called
|
||||
} catch (Exception ex) {
|
||||
Debug.LogError($"OAuth login failed: {ex.Message}");
|
||||
if (oauthStatusText != null) { oauthStatusText.text = $"Login failed: {ex.Message}"; }
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOAuthLoginSuccess(UserInfo user) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
if (oauthStatusText != null) { oauthStatusText.text = $"Welcome, {user.DisplayName}!"; }
|
||||
ConnectWithOAuth();
|
||||
});
|
||||
}
|
||||
|
||||
private void OnOAuthLoginFailed(string error) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
if (oauthStatusText != null) { oauthStatusText.text = $"Login failed: {error}"; }
|
||||
});
|
||||
}
|
||||
|
||||
private void OnNewUserNeedsDisplayName(bool isNewUser) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
// Show display name panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(false);
|
||||
if (displayNamePanel != null) displayNamePanel.SetActive(true);
|
||||
if (displayNameErrorText != null) displayNameErrorText.text = "";
|
||||
});
|
||||
}
|
||||
|
||||
private async void OnSetDisplayNameClicked() {
|
||||
if (displayNameField == null || OAuthManager.Instance == null) return;
|
||||
|
||||
var displayName = displayNameField.text.Trim();
|
||||
if (string.IsNullOrEmpty(displayName)) {
|
||||
if (displayNameErrorText != null) {
|
||||
displayNameErrorText.text = "Please enter a display name";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (displayNameErrorText != null) { displayNameErrorText.text = "Setting display name..."; }
|
||||
|
||||
var success = await OAuthManager.Instance.SetDisplayNameAsync(displayName);
|
||||
if (!success && displayNameErrorText != null) {
|
||||
displayNameErrorText.text = "Name already taken or invalid. Try another.";
|
||||
}
|
||||
// OnLoginSuccess will be called if successful
|
||||
}
|
||||
|
||||
private void OnOAuthLogout() {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
ShowAuthPanel();
|
||||
if (oauthStatusText != null) { oauthStatusText.text = ""; }
|
||||
});
|
||||
}
|
||||
|
||||
private void OnUseLegacyAuthClicked() {
|
||||
_useOAuth = !_useOAuth; // Toggle instead of just setting false
|
||||
|
||||
if (_useOAuth) {
|
||||
// Show OAuth panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(true);
|
||||
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(false);
|
||||
if (useLegacyAuthButtonText != null)
|
||||
useLegacyAuthButtonText.text = "Use Classic Sign-in";
|
||||
} else {
|
||||
// Show legacy panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(false);
|
||||
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(true);
|
||||
if (useLegacyAuthButtonText != null) useLegacyAuthButtonText.text = "Use OAuth Sign-in";
|
||||
}
|
||||
}
|
||||
|
||||
private void ConnectWithOAuth() {
|
||||
_useOAuth = true;
|
||||
_internalConnectEagle();
|
||||
}
|
||||
|
||||
private float _statusUpdateTimer = 0f;
|
||||
private const float StatusUpdateInterval = 0.5f;
|
||||
|
||||
void Update() {
|
||||
// Only update status periodically and when connection panel is visible
|
||||
if (!connectionPanel.activeInHierarchy) return;
|
||||
|
||||
_statusUpdateTimer += Time.deltaTime;
|
||||
if (_statusUpdateTimer >= StatusUpdateInterval) {
|
||||
_statusUpdateTimer = 0f;
|
||||
UpdateConnectionStatus();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateConnectionStatus() {
|
||||
if (connectionStatusText == null) return;
|
||||
|
||||
// No connection yet - show nothing
|
||||
if (_persistentClientConnection == null) {
|
||||
connectionStatusText.text = "";
|
||||
return;
|
||||
}
|
||||
|
||||
var circuitState = _persistentClientConnection.CircuitBreaker.CurrentState;
|
||||
var connState = _persistentClientConnection.CurrentState;
|
||||
|
||||
// Circuit breaker takes precedence
|
||||
if (circuitState == eagle.ConnectionCircuitBreaker.State.Open) {
|
||||
var nextTest = _persistentClientConnection.CircuitBreaker.NextTestAttempt;
|
||||
if (nextTest.HasValue) {
|
||||
var timeUntilTest = nextTest.Value - DateTime.UtcNow;
|
||||
if (timeUntilTest.TotalSeconds > 0) {
|
||||
int seconds = Math.Min(10, (int)Math.Ceiling(timeUntilTest.TotalSeconds));
|
||||
connectionStatusText.text =
|
||||
$"<color=red>●</color> Server unavailable. Retry in {seconds}s";
|
||||
return;
|
||||
}
|
||||
}
|
||||
connectionStatusText.text = "<color=red>●</color> Server unavailable";
|
||||
return;
|
||||
}
|
||||
|
||||
if (circuitState == eagle.ConnectionCircuitBreaker.State.HalfOpen) {
|
||||
connectionStatusText.text = "<color=yellow>●</color> Testing connection...";
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal connection states
|
||||
connectionStatusText.text = connState switch {
|
||||
eagle.ConnectionState.Connected => "<color=green>●</color> Connected",
|
||||
eagle.ConnectionState.Connecting => "<color=yellow>●</color> Connecting...",
|
||||
eagle.ConnectionState.Disconnected => "<color=red>●</color> Disconnected",
|
||||
eagle.ConnectionState.Reconnecting => GetReconnectingText(),
|
||||
eagle.ConnectionState.SubscriptionPending => "<color=yellow>●</color> Subscribing...",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
|
||||
private string GetReconnectingText() {
|
||||
var nextAttempt = _persistentClientConnection?.NextReconnectAttempt;
|
||||
if (!nextAttempt.HasValue) return "<color=yellow>●</color> Reconnecting...";
|
||||
|
||||
var timeUntilRetry = nextAttempt.Value - DateTime.UtcNow;
|
||||
if (timeUntilRetry.TotalSeconds <= 0) { return "<color=yellow>●</color> Reconnecting..."; }
|
||||
|
||||
int seconds = Math.Min(10, (int)Math.Ceiling(timeUntilRetry.TotalSeconds));
|
||||
return $"<color=orange>●</color> Retry in {seconds}s";
|
||||
}
|
||||
|
||||
public void EditorButtonClicked() { SceneManager.LoadScene("Map Editor"); }
|
||||
@@ -172,6 +457,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
_persistentClientConnection = null;
|
||||
eagleConnection?.Dispose();
|
||||
eagleConnection = null;
|
||||
|
||||
_connectedEnvironmentIndex = -1;
|
||||
}
|
||||
|
||||
public void OnResolutionChanged(int dropdownIndex) {
|
||||
@@ -207,10 +494,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
|
||||
@@ -246,14 +533,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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -267,7 +556,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
}
|
||||
|
||||
private void _createConnection() {
|
||||
PlayerPrefs.SetString(URLKey, urlField.text);
|
||||
_connectedEnvironmentIndex = environmentDropdown.value;
|
||||
PlayerPrefs.SetInt(EnvironmentKey, _connectedEnvironmentIndex);
|
||||
PlayerPrefs.SetString(NameKey, nameField.text);
|
||||
PlayerPrefs.SetString(PasswordKey, passwordField.text);
|
||||
|
||||
@@ -280,7 +570,27 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
// Initialize cancellation token for thread management
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
eagleConnection = new EagleConnection(nameField.text, passwordField.text, urlField.text);
|
||||
string url = GetUrlFromEnvironment();
|
||||
|
||||
if (_useOAuth && TokenStorage.HasValidToken) {
|
||||
// Use JWT-based connection
|
||||
eagleConnection = EagleConnection.CreateWithJwt(url);
|
||||
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", TokenStorage.AccessToken);
|
||||
} else {
|
||||
// Legacy Basic Auth connection
|
||||
PlayerPrefs.SetString(NameKey, nameField.text);
|
||||
PlayerPrefs.SetString(PasswordKey, passwordField.text);
|
||||
|
||||
eagleConnection = new EagleConnection(nameField.text, passwordField.text, url);
|
||||
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Basic", eagleConnection.authHeader);
|
||||
}
|
||||
|
||||
_persistentClientConnection = new PersistentClientConnection(
|
||||
eagleConnection.EagleGrpcClient,
|
||||
eagleConnection.credentials,
|
||||
@@ -288,10 +598,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);
|
||||
}
|
||||
|
||||
@@ -349,6 +655,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);
|
||||
}
|
||||
@@ -425,6 +743,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);
|
||||
@@ -442,7 +772,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;
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
using UnityEngine;
|
||||
|
||||
public class ConnectionKiller : MonoBehaviour {
|
||||
@@ -10,6 +12,10 @@ public class ConnectionKiller : MonoBehaviour {
|
||||
public void OnApplicationQuit() { ShutdownAll(); }
|
||||
|
||||
#if UNITY_EDITOR
|
||||
void OnEnable() { EditorApplication.playModeStateChanged += OnPlayModeStateChanged; }
|
||||
|
||||
void OnDisable() { EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; }
|
||||
|
||||
void OnPlayModeStateChanged(PlayModeStateChange state) {
|
||||
if (state == PlayModeStateChange.ExitingPlayMode) { ShutdownAll(); }
|
||||
}
|
||||
|
||||
+142
-49
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using EagleGUIUtils;
|
||||
@@ -6,6 +6,7 @@ using Net.Eagle0.Eagle.Api;
|
||||
using Net.Eagle0.Eagle.Common;
|
||||
using Net.Eagle0.Eagle.Views;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace eagle {
|
||||
@@ -14,16 +15,44 @@ namespace eagle {
|
||||
|
||||
public class ImproveCommandSelector : CommandSelector {
|
||||
public HeroDropdownController heroDropdownController;
|
||||
public TMP_Dropdown typeDropdown;
|
||||
public Toggle lockToggle;
|
||||
|
||||
// Toggle buttons for each improvement type (assign to same ToggleGroup in Unity)
|
||||
public Toggle economyToggle;
|
||||
public Toggle agricultureToggle;
|
||||
public Toggle infrastructureToggle;
|
||||
public Toggle devastationToggle;
|
||||
public ToggleGroup improvementToggleGroup;
|
||||
|
||||
// Labels showing type name and value
|
||||
public TMP_Text economyLabel;
|
||||
public TMP_Text agricultureLabel;
|
||||
public TMP_Text infrastructureLabel;
|
||||
public TMP_Text devastationLabel;
|
||||
|
||||
List<HeroView> orderedHeroes;
|
||||
|
||||
private int SelectedTypeIndex => typeDropdown.value;
|
||||
private ImproveAvailableCommand ImproveAvailableCommand => _availableCommand.ImproveCommand;
|
||||
private ProvinceId ActingProvinceId => ImproveAvailableCommand.ActingProvinceId;
|
||||
private ImprovementType SelectedType =>
|
||||
ImproveAvailableCommand.AvailableTypes[SelectedTypeIndex];
|
||||
|
||||
private ImprovementType SelectedType {
|
||||
get {
|
||||
if (economyToggle != null && economyToggle.isOn && economyToggle.interactable)
|
||||
return ImprovementType.Economy;
|
||||
if (agricultureToggle != null && agricultureToggle.isOn &&
|
||||
agricultureToggle.interactable)
|
||||
return ImprovementType.Agriculture;
|
||||
if (infrastructureToggle != null && infrastructureToggle.isOn &&
|
||||
infrastructureToggle.interactable)
|
||||
return ImprovementType.Infrastructure;
|
||||
if (devastationToggle != null && devastationToggle.isOn &&
|
||||
devastationToggle.interactable)
|
||||
return ImprovementType.Devastation;
|
||||
|
||||
// Fallback: return first available type
|
||||
return ImproveAvailableCommand.AvailableTypes.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
private float OriginalImprovementValueForType(ImprovementType type) {
|
||||
var province = _model.Provinces[ActingProvinceId];
|
||||
@@ -57,60 +86,142 @@ namespace eagle {
|
||||
}
|
||||
}
|
||||
|
||||
private int MinimumImprovementIndex(ProvinceView province) {
|
||||
var minIndex = 0;
|
||||
float minValue =
|
||||
OriginalImprovementValueForType(ImproveAvailableCommand.AvailableTypes[0]);
|
||||
private ImprovementType GetMinimumImprovementType() {
|
||||
ImprovementType minType = ImproveAvailableCommand.AvailableTypes[0];
|
||||
float minValue = OriginalImprovementValueForType(minType);
|
||||
for (int i = 1; i < ImproveAvailableCommand.AvailableTypes.Count; i++) {
|
||||
float thisVal =
|
||||
OriginalImprovementValueForType(ImproveAvailableCommand.AvailableTypes[i]);
|
||||
var thisType = ImproveAvailableCommand.AvailableTypes[i];
|
||||
float thisVal = OriginalImprovementValueForType(thisType);
|
||||
if (thisVal < minValue) {
|
||||
minIndex = i;
|
||||
minType = thisType;
|
||||
minValue = thisVal;
|
||||
}
|
||||
}
|
||||
return minIndex;
|
||||
return minType;
|
||||
}
|
||||
|
||||
private void ChooseDefaultImprovement(ProvinceView province) {
|
||||
var devastationIndex =
|
||||
ImproveAvailableCommand.AvailableTypes.IndexOf(ImprovementType.Devastation);
|
||||
if (devastationIndex == -1) {
|
||||
typeDropdown.value = MinimumImprovementIndex(province);
|
||||
private ImprovementType GetDefaultImprovementType() {
|
||||
// Prefer devastation if available
|
||||
if (ImproveAvailableCommand.AvailableTypes.Contains(ImprovementType.Devastation)) {
|
||||
return ImprovementType.Devastation;
|
||||
}
|
||||
|
||||
// Otherwise pick the lowest stat
|
||||
return GetMinimumImprovementType();
|
||||
}
|
||||
|
||||
private Toggle GetToggleForType(ImprovementType type) {
|
||||
switch (type) {
|
||||
case ImprovementType.Economy: return economyToggle;
|
||||
case ImprovementType.Agriculture: return agricultureToggle;
|
||||
case ImprovementType.Infrastructure: return infrastructureToggle;
|
||||
case ImprovementType.Devastation: return devastationToggle;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
private TMP_Text GetLabelForType(ImprovementType type) {
|
||||
switch (type) {
|
||||
case ImprovementType.Economy: return economyLabel;
|
||||
case ImprovementType.Agriculture: return agricultureLabel;
|
||||
case ImprovementType.Infrastructure: return infrastructureLabel;
|
||||
case ImprovementType.Devastation: return devastationLabel;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectType(ImprovementType type) {
|
||||
// Turn on the selected toggle - ToggleGroup handles turning off others
|
||||
var toggle = GetToggleForType(type);
|
||||
if (toggle != null && toggle.interactable) { toggle.isOn = true; }
|
||||
}
|
||||
|
||||
private const float DisabledAlpha = 0.35f;
|
||||
|
||||
private void ConfigureToggle(ImprovementType type, bool available) {
|
||||
var toggle = GetToggleForType(type);
|
||||
var label = GetLabelForType(type);
|
||||
|
||||
if (toggle == null) return;
|
||||
|
||||
// Always show the toggle, but disable if not available
|
||||
toggle.gameObject.SetActive(true);
|
||||
toggle.interactable = available;
|
||||
|
||||
// Turn off unavailable toggles so they don't appear selected
|
||||
if (!available) { toggle.isOn = false; }
|
||||
|
||||
// Only add to toggle group if available
|
||||
toggle.group = available ? improvementToggleGroup : null;
|
||||
|
||||
// Gray out unavailable toggles using CanvasGroup
|
||||
var canvasGroup = toggle.GetComponent<CanvasGroup>();
|
||||
if (canvasGroup == null) {
|
||||
canvasGroup = toggle.gameObject.AddComponent<CanvasGroup>();
|
||||
}
|
||||
canvasGroup.alpha = available ? 1f : DisabledAlpha;
|
||||
|
||||
// Always show label with current value
|
||||
if (label != null) { label.text = LabelStringForType(type); }
|
||||
}
|
||||
|
||||
private string LabelStringForType(ImprovementType type) {
|
||||
var originalStat =
|
||||
type == ImprovementType.Devastation
|
||||
? ProvinceStatUtils.RoundedDevastation(
|
||||
OriginalImprovementValueForType(type))
|
||||
: ProvinceStatUtils.RoundedStat(OriginalImprovementValueForType(type));
|
||||
if (type == ImprovementType.Devastation) {
|
||||
return GUIUtils.ColoredString(Color.red, originalStat.ToString());
|
||||
}
|
||||
var devastatedStat =
|
||||
ProvinceStatUtils.RoundedStat(EffectiveImprovementValueForType(type));
|
||||
if (devastatedStat == originalStat) {
|
||||
return $"{devastatedStat}";
|
||||
} else {
|
||||
typeDropdown.value = devastationIndex;
|
||||
return $"{GUIUtils.ColoredString(Color.red, devastatedStat.ToString())} / {originalStat}";
|
||||
}
|
||||
}
|
||||
|
||||
protected override void SetUpUI() {
|
||||
var province = _model.Provinces[ImproveAvailableCommand.ActingProvinceId];
|
||||
var improveCommand = ImproveAvailableCommand;
|
||||
|
||||
typeDropdown.ClearOptions();
|
||||
|
||||
orderedHeroes = ImproveAvailableCommand.AvailableHeroIds.Select(id => _model.Heroes[id])
|
||||
.ToList();
|
||||
heroDropdownController.AvailableHeroes = orderedHeroes;
|
||||
|
||||
heroDropdownController.SelectedHeroId = improveCommand.RecommendedHeroId;
|
||||
|
||||
typeDropdown.AddOptions(
|
||||
improveCommand.AvailableTypes.Select(DropdownStringForType).ToList());
|
||||
// Configure each toggle based on available types
|
||||
ConfigureToggle(
|
||||
ImprovementType.Economy,
|
||||
improveCommand.AvailableTypes.Contains(ImprovementType.Economy));
|
||||
ConfigureToggle(
|
||||
ImprovementType.Agriculture,
|
||||
improveCommand.AvailableTypes.Contains(ImprovementType.Agriculture));
|
||||
ConfigureToggle(
|
||||
ImprovementType.Infrastructure,
|
||||
improveCommand.AvailableTypes.Contains(ImprovementType.Infrastructure));
|
||||
ConfigureToggle(
|
||||
ImprovementType.Devastation,
|
||||
improveCommand.AvailableTypes.Contains(ImprovementType.Devastation));
|
||||
|
||||
// Determine which type to select
|
||||
ImprovementType selectedType;
|
||||
if (improveCommand.LockedType != ImprovementType.None) {
|
||||
var lockedType = improveCommand.LockedType;
|
||||
var lockedTypeIndex = improveCommand.AvailableTypes.IndexOf(lockedType);
|
||||
if (lockedTypeIndex > -1) {
|
||||
typeDropdown.value = lockedTypeIndex;
|
||||
if (improveCommand.AvailableTypes.Contains(lockedType)) {
|
||||
selectedType = lockedType;
|
||||
lockToggle.isOn = true;
|
||||
} else {
|
||||
ChooseDefaultImprovement(province);
|
||||
selectedType = GetDefaultImprovementType();
|
||||
lockToggle.isOn = false;
|
||||
}
|
||||
} else {
|
||||
ChooseDefaultImprovement(province);
|
||||
selectedType = GetDefaultImprovementType();
|
||||
lockToggle.isOn = false;
|
||||
}
|
||||
|
||||
SelectType(selectedType);
|
||||
}
|
||||
|
||||
public override AvailableCommand.SealedValueOneofCase CommandType =>
|
||||
@@ -137,23 +248,5 @@ namespace eagle {
|
||||
LockType = lockToggle.isOn
|
||||
}
|
||||
};
|
||||
|
||||
private string DropdownStringForType(ImprovementType type) {
|
||||
var originalStat =
|
||||
type == ImprovementType.Devastation
|
||||
? ProvinceStatUtils.RoundedDevastation(
|
||||
OriginalImprovementValueForType(type))
|
||||
: ProvinceStatUtils.RoundedStat(OriginalImprovementValueForType(type));
|
||||
if (type == ImprovementType.Devastation) {
|
||||
return $"{type} ({GUIUtils.ColoredString(UnityEngine.Color.red, originalStat.ToString())})";
|
||||
}
|
||||
var devastatedStat =
|
||||
ProvinceStatUtils.RoundedStat(EffectiveImprovementValueForType(type));
|
||||
if (devastatedStat == originalStat) {
|
||||
return $"{type} ({devastatedStat})";
|
||||
} else {
|
||||
return $"{type} ({GUIUtils.ColoredString(UnityEngine.Color.red, devastatedStat.ToString())} / {originalStat})";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-1
@@ -120,6 +120,19 @@ namespace eagle {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force the circuit breaker to allow an immediate reconnect attempt.
|
||||
/// Resets the state to HalfOpen so the next connection will be a test.
|
||||
/// </summary>
|
||||
public void ForceReconnect() {
|
||||
lock (this) {
|
||||
if (_state == State.Open) {
|
||||
_state = State.HalfOpen;
|
||||
_logger.LogLine("[CIRCUIT] OPEN → HALF_OPEN (forced by user)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get human-readable status for UI display.
|
||||
/// </summary>
|
||||
@@ -138,7 +151,7 @@ namespace eagle {
|
||||
var timeUntilTest = OpenTimeoutSeconds -
|
||||
(DateTime.UtcNow - _openedAt.Value).TotalSeconds;
|
||||
if (timeUntilTest > 0) {
|
||||
return $"Server unavailable. Retrying in {(int)timeUntilTest}s";
|
||||
return $"Server unavailable. Retrying in {Math.Min(10, (int)timeUntilTest)}s";
|
||||
}
|
||||
}
|
||||
return "Server unavailable. Testing...";
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using Net.Eagle0.Eagle.Api;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace eagle {
|
||||
/// <summary>
|
||||
@@ -25,6 +26,13 @@ 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;
|
||||
|
||||
// Update interval in seconds
|
||||
private const float UpdateInterval = 0.5f;
|
||||
@@ -39,9 +47,10 @@ namespace eagle {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the PersistentClientConnection - this assumes it's accessible
|
||||
// In production, this would need proper dependency injection
|
||||
// For now, the connection will be set externally or found via another method
|
||||
if (retryButton != null) {
|
||||
retryButton.onClick.AddListener(OnRetryClicked);
|
||||
retryButton.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -59,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; }
|
||||
|
||||
@@ -73,11 +91,12 @@ namespace eagle {
|
||||
// Check circuit breaker state first - it takes precedence
|
||||
var circuitState = _connection.CircuitBreaker.CurrentState;
|
||||
if (circuitState == ConnectionCircuitBreaker.State.Open) {
|
||||
SetRetryButtonVisible(true);
|
||||
var nextTest = _connection.CircuitBreaker.NextTestAttempt;
|
||||
if (nextTest.HasValue) {
|
||||
var timeUntilTest = nextTest.Value - DateTime.UtcNow;
|
||||
if (timeUntilTest.TotalSeconds > 0) {
|
||||
int seconds = (int)Math.Ceiling(timeUntilTest.TotalSeconds);
|
||||
int seconds = Math.Min(10, (int)Math.Ceiling(timeUntilTest.TotalSeconds));
|
||||
_textComponent.text =
|
||||
$"<color=red>●</color> Server down. Retry in {seconds}s";
|
||||
return;
|
||||
@@ -86,6 +105,7 @@ namespace eagle {
|
||||
_textComponent.text = "<color=red>●</color> Server unavailable";
|
||||
return;
|
||||
} else if (circuitState == ConnectionCircuitBreaker.State.HalfOpen) {
|
||||
SetRetryButtonVisible(false);
|
||||
_textComponent.text = "<color=yellow>●</color> Testing connection...";
|
||||
return;
|
||||
}
|
||||
@@ -94,6 +114,11 @@ namespace eagle {
|
||||
var state = _connection.CurrentState;
|
||||
var nextAttempt = _connection.NextReconnectAttempt;
|
||||
|
||||
// Show retry button if we're counting down to a reconnect attempt
|
||||
bool isCountingDown = state == ConnectionState.Reconnecting && nextAttempt.HasValue &&
|
||||
(nextAttempt.Value - DateTime.UtcNow).TotalSeconds > 0;
|
||||
SetRetryButtonVisible(isCountingDown);
|
||||
|
||||
string statusText = state switch {
|
||||
ConnectionState.Connected => GetConnectedStatusText(),
|
||||
ConnectionState.Connecting => "<color=yellow>●</color> Connecting...",
|
||||
@@ -142,8 +167,31 @@ namespace eagle {
|
||||
return "<color=yellow>●</color> Reconnecting...";
|
||||
}
|
||||
|
||||
int secondsRemaining = (int)Math.Ceiling(timeUntilRetry.TotalSeconds);
|
||||
int secondsRemaining = Math.Min(10, (int)Math.Ceiling(timeUntilRetry.TotalSeconds));
|
||||
return $"<color=orange>●</color> Retry in {secondsRemaining}s";
|
||||
}
|
||||
|
||||
private void SetRetryButtonVisible(bool visible) {
|
||||
if (retryButton != null) { retryButton.gameObject.SetActive(visible); }
|
||||
}
|
||||
|
||||
private void OnRetryClicked() {
|
||||
if (_connection != null) { _connection.ForceReconnect(); }
|
||||
}
|
||||
|
||||
private void UpdateEnvironmentIndicator() {
|
||||
if (environmentIndicatorText == null) return;
|
||||
|
||||
if (string.IsNullOrEmpty(_environmentName)) {
|
||||
environmentIndicatorText.text = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Color prod green, qa yellow
|
||||
environmentIndicatorText.text =
|
||||
_environmentName switch { "prod." => $"<color=green>{_environmentName}</color>",
|
||||
"qa." => $"<color=yellow>{_environmentName}</color>",
|
||||
_ => _environmentName };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
+117
@@ -0,0 +1,117 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1fb1f6deb68f9475281c9f7c427a7024
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 1
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 0
|
||||
wrapV: 0
|
||||
wrapW: 0
|
||||
nPOTScale: 1
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 0
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 0
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+12
-9
@@ -229,12 +229,11 @@ namespace eagle {
|
||||
|
||||
void OnApplicationPause(bool pause) {
|
||||
if (ModelUpdater == null) { return; }
|
||||
if (pause) {
|
||||
ModelUpdater.StopListeningForUpdates();
|
||||
} else {
|
||||
// Fire-and-forget - subscription is awaited internally and failures are logged
|
||||
_ = ModelUpdater.StartListeningForUpdates();
|
||||
}
|
||||
// Don't unsubscribe on pause - this caused a race condition where the subscriber
|
||||
// could be lost if pause happened before the async StartListeningForUpdates completed.
|
||||
// With MainQueue rate-limiting, keeping the subscription during pause is safe.
|
||||
// Reconnects will continue to work, and updates will queue up and be processed on
|
||||
// resume.
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
@@ -244,7 +243,8 @@ namespace eagle {
|
||||
public void SetUpGame(
|
||||
long gameId,
|
||||
int? playerId,
|
||||
PersistentClientConnection persistentClientConnection) {
|
||||
PersistentClientConnection persistentClientConnection,
|
||||
string environmentName = null) {
|
||||
ModelUpdater = new GameModelUpdater(
|
||||
gameId,
|
||||
playerId,
|
||||
@@ -270,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(
|
||||
|
||||
@@ -43,7 +43,7 @@ namespace eagle {
|
||||
TokenId? CommandToken { get; }
|
||||
TokenId LastPostedToken { get; }
|
||||
|
||||
Dictionary<String, ShardokGameModel> ShardokGameModels { get; }
|
||||
ConcurrentDictionary<String, ShardokGameModel> ShardokGameModels { get; }
|
||||
|
||||
private bool ShardokGameModelIsRunning(ShardokGameModel sgm) =>
|
||||
sgm.GameStatus.State == GameStatus.Types.State.GameRunning
|
||||
@@ -105,10 +105,9 @@ namespace eagle {
|
||||
_currentModel.ShardokGameModels
|
||||
.Select(sgm => {
|
||||
var needsResync = _shardokNeedsResync.GetValueOrDefault(sgm.Key, false);
|
||||
// Use thread-safe count from gRPC thread updates, fall back to model
|
||||
var count = _shardokResultCounts.GetValueOrDefault(
|
||||
sgm.Key,
|
||||
sgm.Value.History.Count());
|
||||
// Use thread-safe count from gRPC thread updates. Fall back to 0 if
|
||||
// not yet tracked (avoids accessing non-thread-safe History.Count).
|
||||
var count = _shardokResultCounts.GetValueOrDefault(sgm.Key, 0);
|
||||
return new IClientConnectionSubscriber.ShardokViewStatus {
|
||||
shardokGameId = sgm.Key,
|
||||
filteredResultCount = needsResync ? 0 : count,
|
||||
@@ -199,7 +198,8 @@ namespace eagle {
|
||||
return new List<AvailableCommand>();
|
||||
}
|
||||
|
||||
public Dictionary<String, ShardokGameModel> ShardokGameModels { get; set; }
|
||||
// Thread-safe: accessed from heartbeat timer thread via ShardokViewStatuses
|
||||
public ConcurrentDictionary<String, ShardokGameModel> ShardokGameModels { get; set; }
|
||||
|
||||
public FactionView MaybeDestroyedFaction(FactionId factionId) {
|
||||
if (ActiveFactions.TryGetValue(factionId, out var factionView)) {
|
||||
@@ -241,7 +241,8 @@ namespace eagle {
|
||||
new Dictionary<ProvinceId, OneProvinceAvailableCommands>();
|
||||
|
||||
_currentModel.GsView = new GameStateView();
|
||||
_currentModel.ShardokGameModels = new Dictionary<ShardokGameId, ShardokGameModel>();
|
||||
_currentModel.ShardokGameModels =
|
||||
new ConcurrentDictionary<ShardokGameId, ShardokGameModel>();
|
||||
|
||||
_currentModel.BattalionTypes = new Dictionary<BattalionTypeId, BattalionType>();
|
||||
|
||||
@@ -310,18 +311,36 @@ namespace eagle {
|
||||
// Store server-reported game status for UI
|
||||
if (updateItem.ActionResultResponse.ServerGameStatus != null) {
|
||||
ServerStatus = updateItem.ActionResultResponse.ServerGameStatus;
|
||||
_connectionLogger.LogLine(
|
||||
$"[UPDATE] ServerGameStatus updated: {ServerStatus.Status}");
|
||||
}
|
||||
|
||||
_lastUnfilteredResultCount =
|
||||
updateItem.ActionResultResponse.UnfilteredResultCountAfter;
|
||||
if (updateItem.ActionResultResponse.ActionResultViews.Any() ||
|
||||
updateItem.ActionResultResponse.AvailableCommands == null ||
|
||||
updateItem.ActionResultResponse.AvailableCommands.Token !=
|
||||
_currentModel.CommandToken) {
|
||||
// Note: _lastUnfilteredResultCount is updated on the gRPC thread in
|
||||
// UpdateResultCounts() before enqueueing. We don't update it here to avoid
|
||||
// race conditions where a backlogged MainQueue update overwrites a newer count.
|
||||
|
||||
var hasResults = updateItem.ActionResultResponse.ActionResultViews.Any();
|
||||
var hasCommands = updateItem.ActionResultResponse.AvailableCommands != null;
|
||||
var incomingToken =
|
||||
hasCommands ? updateItem.ActionResultResponse.AvailableCommands.Token
|
||||
: -1;
|
||||
var tokenMatches = hasCommands && incomingToken == _currentModel.CommandToken;
|
||||
|
||||
_connectionLogger.LogLine(
|
||||
$"[UPDATE] hasResults={hasResults}, hasCommands={hasCommands}, " +
|
||||
$"incomingToken={incomingToken}, currentToken={_currentModel.CommandTokenString}, " +
|
||||
$"tokenMatches={tokenMatches}");
|
||||
|
||||
if (hasResults || !hasCommands || !tokenMatches) {
|
||||
HandleUpdates(updateItem.ActionResultResponse.ActionResultViews.ToList());
|
||||
HandleAvailableCommands(updateItem.ActionResultResponse.AvailableCommands);
|
||||
|
||||
if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
|
||||
_connectionLogger.LogLine(
|
||||
"[UPDATE] Processed update and invoked UpdateAction");
|
||||
} else {
|
||||
_connectionLogger.LogLine(
|
||||
"[UPDATE] SKIPPED - no results, has commands, token matches");
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -329,6 +348,10 @@ namespace eagle {
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
|
||||
var specResponse = updateItem.ShardokActionResultResponse.ShardokGameResponses;
|
||||
|
||||
// Collect ended games to remove AFTER the UI callback, so the UI
|
||||
// can see the final battle results before the models are removed.
|
||||
var endedGames = new List<ShardokGameId>();
|
||||
|
||||
foreach (var oneResponse in specResponse) {
|
||||
if (!_currentModel.ShardokGameModels.TryGetValue(
|
||||
oneResponse.ShardokGameId,
|
||||
@@ -339,7 +362,9 @@ namespace eagle {
|
||||
// Battle may have ended before we could create the model - remove
|
||||
// any stale reference and skip this update
|
||||
if (shardokGameModel == null) {
|
||||
_currentModel.ShardokGameModels.Remove(oneResponse.ShardokGameId);
|
||||
_currentModel.ShardokGameModels.TryRemove(
|
||||
oneResponse.ShardokGameId,
|
||||
out _);
|
||||
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
|
||||
continue;
|
||||
}
|
||||
@@ -367,12 +392,22 @@ namespace eagle {
|
||||
_currentModel.ShardokGameModels[oneResponse.ShardokGameId] =
|
||||
shardokGameModel;
|
||||
} else {
|
||||
// Game ended - remove from active models so UI knows battle is over
|
||||
_currentModel.ShardokGameModels.Remove(oneResponse.ShardokGameId);
|
||||
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
|
||||
// Game ended - keep model for UI callback, mark for removal after
|
||||
_currentModel.ShardokGameModels[oneResponse.ShardokGameId] =
|
||||
shardokGameModel;
|
||||
endedGames.Add(oneResponse.ShardokGameId);
|
||||
}
|
||||
}
|
||||
|
||||
// Invoke UI callback BEFORE removing ended games, so the UI can
|
||||
// see the final battle results (with GameStatus = Victory/Defeat)
|
||||
if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
|
||||
|
||||
// Now remove ended games from active models
|
||||
foreach (var endedGameId in endedGames) {
|
||||
_currentModel.ShardokGameModels.TryRemove(endedGameId, out _);
|
||||
_shardokResultCounts.TryRemove(endedGameId, out _);
|
||||
}
|
||||
break;
|
||||
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.StreamingTextResponse:
|
||||
@@ -398,9 +433,14 @@ namespace eagle {
|
||||
public void UpdateResultCounts(GameUpdate update) {
|
||||
switch (update.GameUpdateDetailsCase) {
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
|
||||
var newCount = update.ActionResultResponse.UnfilteredResultCountAfter;
|
||||
lock (_resultCountLock) {
|
||||
_lastUnfilteredResultCount =
|
||||
update.ActionResultResponse.UnfilteredResultCountAfter;
|
||||
var oldCount = _lastUnfilteredResultCount;
|
||||
_lastUnfilteredResultCount = newCount;
|
||||
if (newCount != oldCount) {
|
||||
_connectionLogger.LogLine(
|
||||
$"[RESULT_COUNT] Updated count {oldCount} -> {newCount}");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -474,10 +514,44 @@ namespace eagle {
|
||||
_connectionLogger.LogLine(
|
||||
$"[STATE_RESYNC] timestamp={timestamp} round={startingState.CurrentRoundId} factions={startingState.Factions.Count} heroes={startingState.Heroes.Count}");
|
||||
|
||||
// Clear all stale state before applying new starting state.
|
||||
// This handles rewind/reset scenarios where the server sends an earlier state.
|
||||
_currentModel.ShardokGameModels.Clear();
|
||||
_shardokResultCounts.Clear();
|
||||
_shardokNeedsResync.Clear();
|
||||
_currentModel.CommandToken = null;
|
||||
_currentModel.LastPostedToken = -1;
|
||||
_currentModel.AvailableCommandsByProvince.Clear();
|
||||
_commandSubmittedTime = null;
|
||||
|
||||
_currentModel.GsView = startingState;
|
||||
_currentModel.BattalionTypes =
|
||||
startingState.BattalionTypes.ToDictionary(bt => bt.TypeId, bt => bt);
|
||||
_currentModel.ChronicleEntries = startingState.ChronicleEntries.ToList();
|
||||
|
||||
// For any outstanding battles in the new state, create models and mark for resync.
|
||||
bool needsResubscribe = false;
|
||||
foreach (var battle in _currentModel.ShardokBattles) {
|
||||
if (!_currentModel.ShardokGameModels.ContainsKey(battle.ShardokGameId)) {
|
||||
var model = MakeGameModel(battle.ShardokGameId);
|
||||
if (model != null) {
|
||||
_currentModel.ShardokGameModels[battle.ShardokGameId] = model;
|
||||
MarkShardokForResync(battle.ShardokGameId);
|
||||
_connectionLogger.LogLine(
|
||||
$"[STATE_RESYNC] Created ShardokGameModel for outstanding battle {battle.ShardokGameId}");
|
||||
needsResubscribe = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we created new ShardokGameModels, re-subscribe to request their full state.
|
||||
// The original subscribe didn't include these battles since we didn't know about them
|
||||
// yet.
|
||||
if (needsResubscribe) {
|
||||
_connectionLogger.LogLine(
|
||||
"[STATE_RESYNC] Re-subscribing to request full Shardok state for new battles");
|
||||
_ = StartListeningForUpdates();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleUpdates(List<ActionResultView> results) {
|
||||
@@ -780,7 +854,7 @@ namespace eagle {
|
||||
// the Eagle update removing the battle arrives before the Shardok Victory update.
|
||||
if (_currentModel.ShardokGameModels.TryGetValue(rb, out var sgm)) {
|
||||
sgm.MarkBattleEnded("Battle has ended.");
|
||||
_currentModel.ShardokGameModels.Remove(rb);
|
||||
_currentModel.ShardokGameModels.TryRemove(rb, out _);
|
||||
}
|
||||
_shardokResultCounts.TryRemove(rb, out _);
|
||||
|
||||
|
||||
+3
@@ -132,7 +132,10 @@ namespace eagle {
|
||||
|
||||
public void ProvinceHovered(ProvinceId? pid) {
|
||||
// Highlight moving armies table
|
||||
// Check row count to avoid index out of range if data changed after table was built
|
||||
var rowCount = movingArmiesTable.RowCount;
|
||||
MovingArmies.Each((army, i) => {
|
||||
if (i >= rowCount) return;
|
||||
var row = movingArmiesTable.ComponentAt<MovingArmyTableRow>(i);
|
||||
if (army.DestinationProvinceId == pid || army.OriginProvinceId == pid) {
|
||||
row.ShadeOn();
|
||||
|
||||
+2
-1
@@ -57,7 +57,8 @@ namespace eagle.Notifications {
|
||||
{ BreakAllianceAcceptedDetails, BreakAllianceAcceptedNotificationGenerator.Generator },
|
||||
{ VassalExiledDetails, VassalExiledDetailsNotificationGenerator.Generator },
|
||||
{ WithdrewForTruce, WithdrewForTruceDetailsNotificationGenerator.Generator },
|
||||
{ ProfessionGainedDetails, ProfessionGainedDetailsNotificationGenerator.Generator }
|
||||
{ ProfessionGainedDetails, ProfessionGainedDetailsNotificationGenerator.Generator },
|
||||
{ NewFactionHeadDetails, NewFactionHeadDetailsNotificationGenerator.Generator }
|
||||
};
|
||||
|
||||
public static IEnumerable<Notification> GenerateNotifications(
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Net.Eagle0.Eagle.Common;
|
||||
using Net.Eagle0.Eagle.Views;
|
||||
|
||||
namespace eagle.Notifications.ARNNotifications {
|
||||
using ProvinceId = Int32;
|
||||
using HeroId = Int32;
|
||||
|
||||
public static class NewFactionHeadDetailsNotificationGenerator {
|
||||
public static readonly ARNNotificationGenerator Generator = GenerateNotifications;
|
||||
|
||||
private static readonly Random Random = new();
|
||||
|
||||
private static readonly List<string> NotificationTitles = new() {
|
||||
"New Leadership",
|
||||
"Succession",
|
||||
"A New Era",
|
||||
"Rise to Power",
|
||||
"The Mantle Passes",
|
||||
"Heir Ascendant",
|
||||
"Crown of Command",
|
||||
"Leadership Transferred",
|
||||
"The Torch Passes",
|
||||
"New Commander"
|
||||
};
|
||||
|
||||
private static string GetNotificationTitle() {
|
||||
return NotificationTitles[Random.Next(NotificationTitles.Count)];
|
||||
}
|
||||
|
||||
private static ProvinceId? FindProvinceForHero(HeroId heroId, IGameModel model) {
|
||||
foreach (var province in model.Provinces.Values) {
|
||||
if (province.FullInfo?.RulingFactionHeroIds.Contains(heroId) == true) {
|
||||
return province.Id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<Notification> GenerateNotifications(
|
||||
Net.Eagle0.Eagle.Common.Notification notification,
|
||||
IGameModel currentModel) {
|
||||
var details = notification.Details.NewFactionHeadDetails;
|
||||
var newHeadHero = currentModel.Heroes[details.NewHeadHeroId];
|
||||
var previousHeadHero =
|
||||
currentModel.Heroes.TryGetValue(details.PreviousHeadHeroId, out var prev)
|
||||
? prev
|
||||
: null;
|
||||
|
||||
string textTemplate;
|
||||
List<ProvinceId> affectedProvinces;
|
||||
|
||||
if (details.FactionId == currentModel.PlayerId) {
|
||||
// Player's own faction - their new faction head
|
||||
string previousHeadDescription =
|
||||
previousHeadHero != null ? $"Following the death of {{PreviousHead}}, your"
|
||||
: "Your";
|
||||
textTemplate =
|
||||
$"{previousHeadDescription} sworn {DisplayNames.SiblingDescription(newHeadHero.PronounGender)} {{NewHead}} now leads your faction.\n\n";
|
||||
|
||||
var heroProvinceId = FindProvinceForHero(details.NewHeadHeroId, currentModel);
|
||||
affectedProvinces = heroProvinceId.HasValue
|
||||
? new List<ProvinceId> { heroProvinceId.Value }
|
||||
: currentModel.ProvincesForFaction(details.FactionId);
|
||||
} else {
|
||||
// Another faction
|
||||
var factionName = currentModel.FactionName(details.FactionId);
|
||||
if (previousHeadHero != null) {
|
||||
textTemplate =
|
||||
$"Following the death of {{PreviousHead}}, {{NewHead}} now leads {factionName}.\n\n";
|
||||
} else {
|
||||
textTemplate = $"{{NewHead}} now leads {factionName}.\n\n";
|
||||
}
|
||||
affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
|
||||
}
|
||||
|
||||
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
|
||||
{ "NewHead", (newHeadHero.NameTextId, "A hero") }
|
||||
};
|
||||
if (previousHeadHero != null) {
|
||||
heroPlaceholders["PreviousHead"] =
|
||||
(previousHeadHero.NameTextId, "the previous leader");
|
||||
}
|
||||
|
||||
var displayedHeroes = new List<HeroView> { newHeadHero };
|
||||
if (previousHeadHero != null) { displayedHeroes.Add(previousHeadHero); }
|
||||
|
||||
yield return DynamicTextNotification.StreamingDynamicNotification(
|
||||
title: GetNotificationTitle(),
|
||||
textTemplate: textTemplate,
|
||||
heroPlaceholders: heroPlaceholders,
|
||||
llmId: notification.LlmId,
|
||||
provinceIds: affectedProvinces,
|
||||
displayedHeroes: displayedHeroes);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e9afdd2068b294a29993f07b379eb9c3
|
||||
+2
-2
@@ -36,10 +36,10 @@ namespace eagle.Notifications.ARNNotifications {
|
||||
$"{DisplayNames.ResolvedFactionString(lastFaction, currentModel)}'s hero {{HeroName}}";
|
||||
}
|
||||
|
||||
textTemplate = $"{executingFactionName} has executed {victimDescription}!";
|
||||
textTemplate = $"{executingFactionName} has executed {victimDescription}!\n\n";
|
||||
heroPlaceholders["HeroName"] = (hero.NameTextId, "the hero");
|
||||
} else {
|
||||
textTemplate = $"{executingFactionName} has executed {{ExecutedHero}}!";
|
||||
textTemplate = $"{executingFactionName} has executed {{ExecutedHero}}!\n\n";
|
||||
heroPlaceholders["ExecutedHero"] = (hero.NameTextId, "the prisoner");
|
||||
}
|
||||
|
||||
|
||||
+135
-24
@@ -1,30 +1,106 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Net.Eagle0.Eagle.Common;
|
||||
using Net.Eagle0.Eagle.Views;
|
||||
|
||||
namespace eagle.Notifications.ARNNotifications {
|
||||
using ProvinceId = Int32;
|
||||
using HeroId = Int32;
|
||||
|
||||
public static class ProfessionGainedDetailsNotificationGenerator {
|
||||
public static readonly ARNNotificationGenerator Generator = GenerateNotifications;
|
||||
|
||||
private static readonly Dictionary<Profession, string> ProfessionNames =
|
||||
new Dictionary<Profession, string> {
|
||||
{ Profession.Mage, "Mage" },
|
||||
{ Profession.Necromancer, "Necromancer" },
|
||||
{ Profession.Engineer, "Engineer" },
|
||||
{ Profession.Paladin, "Paladin" },
|
||||
{ Profession.Ranger, "Ranger" },
|
||||
{ Profession.Champion, "Champion" }
|
||||
};
|
||||
private static readonly Random Random = new();
|
||||
|
||||
private static readonly Dictionary<Profession, string> ProfessionTitles =
|
||||
new Dictionary<Profession, string> {
|
||||
{ Profession.Mage, "Arcane Awakening" },
|
||||
{ Profession.Necromancer, "Dark Pact Sealed" },
|
||||
{ Profession.Engineer, "Genius Unleashed" },
|
||||
{ Profession.Paladin, "Divine Calling" },
|
||||
{ Profession.Ranger, "One with the Wild" },
|
||||
{ Profession.Champion, "Born for Battle" }
|
||||
};
|
||||
private static readonly Dictionary<Profession, string> ProfessionNames = new() {
|
||||
{ Profession.Mage, "Mage" },
|
||||
{ Profession.Necromancer, "Necromancer" },
|
||||
{ Profession.Engineer, "Engineer" },
|
||||
{ Profession.Paladin, "Paladin" },
|
||||
{ Profession.Ranger, "Ranger" },
|
||||
{ Profession.Champion, "Champion" }
|
||||
};
|
||||
|
||||
private static readonly Dictionary<Profession, List<string>> ProfessionTitles = new() {
|
||||
{ Profession.Mage,
|
||||
new List<string> {
|
||||
"Arcane Awakening",
|
||||
"The Gift Revealed",
|
||||
"Touched by Magic",
|
||||
"Mystical Ascension",
|
||||
"Power Unbound",
|
||||
"The Arcane Path",
|
||||
"Secrets of the Weave",
|
||||
"Spellborn",
|
||||
"Wielder of the Unseen",
|
||||
"Flames of Knowledge"
|
||||
} },
|
||||
{ Profession.Necromancer,
|
||||
new List<string> {
|
||||
"Dark Pact Sealed",
|
||||
"Beyond the Veil",
|
||||
"Death's Apprentice",
|
||||
"Whispers from Beyond",
|
||||
"The Forbidden Art",
|
||||
"Shadow Covenant",
|
||||
"Secrets of the Grave",
|
||||
"Communion with Darkness",
|
||||
"The Deathless Path",
|
||||
"Embrace of Shadow"
|
||||
} },
|
||||
{ Profession.Engineer,
|
||||
new List<string> {
|
||||
"Genius Unleashed",
|
||||
"Master of Mechanisms",
|
||||
"The Inventor's Spark",
|
||||
"Gears of Progress",
|
||||
"Mind of Innovation",
|
||||
"Builder of Wonders",
|
||||
"The Tinkerer's Art",
|
||||
"Siege Mastery",
|
||||
"Architect of War",
|
||||
"Mechanical Brilliance"
|
||||
} },
|
||||
{ Profession.Paladin,
|
||||
new List<string> {
|
||||
"Divine Calling",
|
||||
"Holy Vows Taken",
|
||||
"Blessed Champion",
|
||||
"The Righteous Path",
|
||||
"Shield of the Faith",
|
||||
"Anointed Warrior",
|
||||
"Oath of Light",
|
||||
"Heaven's Chosen",
|
||||
"Sacred Duty",
|
||||
"Defender of the Realm"
|
||||
} },
|
||||
{ Profession.Ranger,
|
||||
new List<string> {
|
||||
"One with the Wild",
|
||||
"Voice of the Forest",
|
||||
"The Untamed Path",
|
||||
"Nature's Guardian",
|
||||
"Shadow of the Woods",
|
||||
"Hunter's Instinct",
|
||||
"Wild Heart",
|
||||
"The Wanderer's Way",
|
||||
"Beast Companion",
|
||||
"Eyes of the Hawk"
|
||||
} },
|
||||
{ Profession.Champion,
|
||||
new List<string> {
|
||||
"Born for Battle",
|
||||
"Blade Mastery",
|
||||
"The Warrior's Edge",
|
||||
"Forged in Combat",
|
||||
"Unmatched Prowess",
|
||||
"Heart of Steel",
|
||||
"The Victor's Path",
|
||||
"Legend in the Making",
|
||||
"Master of Arms",
|
||||
"Glory Awaits"
|
||||
} }
|
||||
};
|
||||
|
||||
private static string GetProfessionName(Profession profession) {
|
||||
return ProfessionNames.TryGetValue(profession, out var name) ? name : "Unknown";
|
||||
@@ -40,8 +116,19 @@ namespace eagle.Notifications.ARNNotifications {
|
||||
}
|
||||
|
||||
private static string GetProfessionTitle(Profession profession) {
|
||||
return ProfessionTitles.TryGetValue(profession, out var title) ? title
|
||||
: "Profession Gained";
|
||||
if (ProfessionTitles.TryGetValue(profession, out var titles) && titles.Count > 0) {
|
||||
return titles[Random.Next(titles.Count)];
|
||||
}
|
||||
return "Profession Gained";
|
||||
}
|
||||
|
||||
private static ProvinceId? FindProvinceForHero(HeroId heroId, IGameModel model) {
|
||||
foreach (var province in model.Provinces.Values) {
|
||||
if (province.FullInfo?.RulingFactionHeroIds.Contains(heroId) == true) {
|
||||
return province.Id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<Notification> GenerateNotifications(
|
||||
@@ -49,14 +136,38 @@ namespace eagle.Notifications.ARNNotifications {
|
||||
IGameModel currentModel) {
|
||||
var details = notification.Details.ProfessionGainedDetails;
|
||||
var hero = currentModel.Heroes[details.HeroId];
|
||||
var factionName = currentModel.FactionName(details.FactionId);
|
||||
var professionName = GetProfessionName(details.NewProfession);
|
||||
var article = GetArticle(professionName);
|
||||
|
||||
var affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
|
||||
string textTemplate;
|
||||
List<ProvinceId> affectedProvinces;
|
||||
|
||||
string textTemplate =
|
||||
$"{{Hero}} of {factionName} became {article} {professionName}.\n\n";
|
||||
if (details.FactionId == currentModel.PlayerId) {
|
||||
// Player's own hero - vary text based on faction leader status
|
||||
string heroDescription;
|
||||
if (hero.IsFactionLeader) {
|
||||
heroDescription =
|
||||
$"Your sworn {DisplayNames.SiblingDescription(hero.PronounGender)}";
|
||||
} else {
|
||||
heroDescription = "Your vassal";
|
||||
}
|
||||
var heroProvinceId = FindProvinceForHero(details.HeroId, currentModel);
|
||||
if (heroProvinceId.HasValue) {
|
||||
var provinceName = currentModel.Provinces[heroProvinceId.Value].Name;
|
||||
textTemplate =
|
||||
$"{heroDescription} {{Hero}} in {provinceName} became {article} {professionName}.\n\n";
|
||||
affectedProvinces = new List<ProvinceId> { heroProvinceId.Value };
|
||||
} else {
|
||||
textTemplate =
|
||||
$"{heroDescription} {{Hero}} became {article} {professionName}.\n\n";
|
||||
affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
|
||||
}
|
||||
} else {
|
||||
// Another faction's hero
|
||||
var factionName = currentModel.FactionName(details.FactionId);
|
||||
textTemplate = $"{{Hero}} of {factionName} became {article} {professionName}.\n\n";
|
||||
affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
|
||||
}
|
||||
|
||||
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
|
||||
{ "Hero", (hero.NameTextId, "A hero") }
|
||||
|
||||
+7
-1
@@ -31,7 +31,13 @@ namespace eagle.Notifications.ARNNotifications {
|
||||
};
|
||||
|
||||
if (playerId.HasValue && playerId.Value == paidToFactionId) {
|
||||
// no notification
|
||||
yield return DynamicTextNotification.StreamingDynamicNotification(
|
||||
title: "Ransom Accepted",
|
||||
textTemplate: $"We have accepted the ransom from {currentModel.FactionName(paidByFactionId)} for {{RansomedHero}}.\n\n",
|
||||
heroPlaceholders: heroPlaceholders,
|
||||
llmId: notification.LlmId,
|
||||
provinceIds: new List<ProvinceId>(),
|
||||
displayedHeroes: new List<HeroView> { ransomedHero, offeringFactionHead });
|
||||
} else if (playerId.HasValue && playerId.Value == paidByFactionId) {
|
||||
yield return DynamicTextNotification.StreamingDynamicNotification(
|
||||
title: "Ransom Accepted",
|
||||
|
||||
+13
@@ -9,6 +9,7 @@ namespace eagle.Notifications {
|
||||
private List<GeneratedTextListener> textListeners = new();
|
||||
private string textTemplate;
|
||||
private Dictionary<string, string> placeholderValues = new();
|
||||
private Dictionary<string, string> fallbackValues = new();
|
||||
|
||||
public DynamicTextNotification(
|
||||
string title,
|
||||
@@ -72,6 +73,9 @@ namespace eagle.Notifications {
|
||||
string nameTextId = kvp.Value.nameTextId;
|
||||
string fallback = kvp.Value.fallback;
|
||||
|
||||
// Store fallback so UpdateText can use it for missing placeholders
|
||||
fallbackValues[placeholder] = fallback;
|
||||
|
||||
if (!string.IsNullOrEmpty(nameTextId)) {
|
||||
var listener = new GeneratedTextListener(
|
||||
nameTextId,
|
||||
@@ -95,6 +99,15 @@ namespace eagle.Notifications {
|
||||
|
||||
private void UpdateText() {
|
||||
string result = textTemplate;
|
||||
|
||||
// First apply fallbacks for any placeholder without a loaded value
|
||||
foreach (var kvp in fallbackValues) {
|
||||
if (!placeholderValues.ContainsKey(kvp.Key)) {
|
||||
result = result.Replace($"{{{kvp.Key}}}", kvp.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// Then apply actual loaded values
|
||||
foreach (var kvp in placeholderValues) {
|
||||
result = result.Replace($"{{{kvp.Key}}}", kvp.Value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e661753a1e43a4f2da81b76457cf55c8
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
LFS
BIN
Binary file not shown.
+117
@@ -0,0 +1,117 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8d524cd6fa08d47c99b2462c2686b8d0
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 1
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 0
|
||||
wrapV: 0
|
||||
wrapW: 0
|
||||
nPOTScale: 1
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 0
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 0
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
LFS
BIN
Binary file not shown.
+117
@@ -0,0 +1,117 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1e69ea88b87d34a48bbb45a1fbad81fc
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 1
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 0
|
||||
wrapV: 0
|
||||
wrapW: 0
|
||||
nPOTScale: 1
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 0
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 0
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/OAuth Buttons/web_neutral_sq_ctn@4x.png
LFS
BIN
Binary file not shown.
+117
@@ -0,0 +1,117 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1850a7332b6e84e1a9b69f5f5b4cf482
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 1
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 0
|
||||
wrapV: 0
|
||||
wrapW: 0
|
||||
nPOTScale: 1
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 0
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 0
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+371
-80
@@ -44,6 +44,18 @@ namespace eagle {
|
||||
|
||||
private readonly Logger _remoteEagleClientLogger = Logger.GetLogger("ConnectionLogger");
|
||||
private readonly Logger _timingsLogger = Logger.GetLogger("ConnectionLogger");
|
||||
|
||||
// Counter to diagnose duplicate logging - if logs show same seq# twice, two threads
|
||||
// are processing same message. If seq# increments but lines doubled, Logger issue.
|
||||
private int _logFlowSeq = 0;
|
||||
|
||||
/// <summary>Timestamped log for connection flow tracing.</summary>
|
||||
private void LogFlow(string message) {
|
||||
var seq = Interlocked.Increment(ref _logFlowSeq);
|
||||
var ts = DateTime.UtcNow.ToString("HH:mm:ss.fff");
|
||||
_remoteEagleClientLogger.LogLine($"[FLOW {ts}] #{seq} {message}");
|
||||
}
|
||||
|
||||
private volatile bool _isConnecting = false;
|
||||
private Timer _idleCheckTimer = null;
|
||||
private Timer _heartbeatTimer = null;
|
||||
@@ -69,6 +81,33 @@ namespace eagle {
|
||||
private ConnectionCircuitBreaker _circuitBreaker = new ConnectionCircuitBreaker();
|
||||
public ConnectionCircuitBreaker CircuitBreaker => _circuitBreaker;
|
||||
|
||||
/// <summary>
|
||||
/// Force an immediate reconnection attempt, bypassing the circuit breaker timeout.
|
||||
/// </summary>
|
||||
public void ForceReconnect() {
|
||||
_circuitBreaker.ForceReconnect();
|
||||
_retryTimer?.Dispose();
|
||||
NextReconnectAttempt = null;
|
||||
LogConnectionEvent("force_reconnect", "User requested immediate reconnect");
|
||||
RunConnectAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fire-and-forget wrapper for Connect() that ensures exceptions are logged
|
||||
/// rather than silently swallowed by Task.Run().
|
||||
/// </summary>
|
||||
private void RunConnectAsync() {
|
||||
Task.Run(async () => {
|
||||
try {
|
||||
await Connect();
|
||||
} catch (Exception e) {
|
||||
// Log but don't rethrow - this is fire-and-forget
|
||||
Console.WriteLine($"[CONNECT] Exception in Connect: {e}");
|
||||
_remoteEagleClientLogger.LogLine($"Exception in Connect: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private DateTime? GetDeadlineFromNow() {
|
||||
return DateTime.UtcNow.AddSeconds(TimeoutSeconds);
|
||||
}
|
||||
@@ -111,6 +150,7 @@ namespace eagle {
|
||||
_currentState = ConnectionState.Reconnecting;
|
||||
NextReconnectAttempt = DateTime.UtcNow.AddSeconds(backoffSeconds);
|
||||
|
||||
LogFlow($"SCHEDULE_RECONNECT reason={reason} backoff={backoffSeconds:F1}s attempt={_consecutiveFailures}");
|
||||
LogConnectionEvent(
|
||||
"schedule_reconnect",
|
||||
$"{reason}, backoff={backoffSeconds:F1}s, attempt={_consecutiveFailures}");
|
||||
@@ -119,7 +159,7 @@ namespace eagle {
|
||||
_retryTimer = new Timer();
|
||||
_retryTimer.AutoReset = false;
|
||||
_retryTimer.Interval = backoffSeconds * 1000;
|
||||
_retryTimer.Elapsed += (sender, args) => Task.Run(() => Connect());
|
||||
_retryTimer.Elapsed += (sender, args) => RunConnectAsync();
|
||||
_retryTimer.Enabled = true;
|
||||
}
|
||||
|
||||
@@ -138,6 +178,7 @@ namespace eagle {
|
||||
private readonly Dictionary<EagleGameId, TaskCompletionSource<SubscriptionAck>>
|
||||
_pendingSubscriptionAcks = new();
|
||||
private const int SubscriptionAckTimeoutMs = 10000; // 10 seconds
|
||||
private const int WriteAsyncTimeoutMs = 10000; // 10 seconds for WriteAsync operations
|
||||
|
||||
public PersistentClientConnection(
|
||||
Eagle.EagleClient grpcClient,
|
||||
@@ -183,6 +224,8 @@ namespace eagle {
|
||||
var ackTcs = new TaskCompletionSource<SubscriptionAck>();
|
||||
lock (_pendingSubscriptionAcks) { _pendingSubscriptionAcks[gameId] = ackTcs; }
|
||||
|
||||
var shardokCount = shardokStatuses.Count();
|
||||
LogFlow($"SUBSCRIBE game={gameId} unfilteredCount={subscriber.LastUnfilteredResultCount} shardokGames={shardokCount}");
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SUBSCRIBE] Sending StreamGameRequest for game {gameId}, " +
|
||||
$"unfilteredCount={subscriber.LastUnfilteredResultCount}");
|
||||
@@ -222,6 +265,7 @@ namespace eagle {
|
||||
|
||||
var ack = await ackTask;
|
||||
if (ack.Success) {
|
||||
LogFlow($"SUBSCRIBE_ACK game={gameId} success=true confirmedCount={ack.ConfirmedResultCount}");
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SUBSCRIBE] Subscription confirmed for game {gameId}, " +
|
||||
$"confirmedResultCount={ack.ConfirmedResultCount}");
|
||||
@@ -233,6 +277,7 @@ namespace eagle {
|
||||
|
||||
return true;
|
||||
} else {
|
||||
LogFlow($"SUBSCRIBE_ACK game={gameId} success=false error={ack.ErrorMessage}");
|
||||
LogConnectionEvent(
|
||||
"subscribe_ack_failed",
|
||||
$"game={gameId}, error={ack.ErrorMessage}");
|
||||
@@ -257,23 +302,24 @@ namespace eagle {
|
||||
public async Task Connect() {
|
||||
// Prevent concurrent connection attempts
|
||||
if (_isConnecting) {
|
||||
_remoteEagleClientLogger.LogLine($"Connect() skipped - already connecting");
|
||||
LogFlow($"Connect() SKIPPED already_connecting=true");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check circuit breaker
|
||||
if (!_circuitBreaker.ShouldAttemptConnection()) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[CIRCUIT] Connection attempt blocked - circuit {_circuitBreaker.CurrentState}");
|
||||
LogFlow($"Connect() BLOCKED circuit={_circuitBreaker.CurrentState}");
|
||||
LogConnectionEvent("connect_blocked", $"circuit={_circuitBreaker.CurrentState}");
|
||||
return;
|
||||
}
|
||||
|
||||
_isConnecting = true;
|
||||
try {
|
||||
LogFlow($"Connect() START failures={_consecutiveFailures}");
|
||||
_lastConnectAttempt = DateTime.UtcNow;
|
||||
_currentState = _consecutiveFailures > 0 ? ConnectionState.Reconnecting
|
||||
: ConnectionState.Connecting;
|
||||
LogFlow($"State -> {_currentState}");
|
||||
NextReconnectAttempt = null;
|
||||
LogConnectionEvent("connect_attempt");
|
||||
|
||||
@@ -307,8 +353,10 @@ namespace eagle {
|
||||
|
||||
// Stream subscriptions OUTSIDE lock (can await)
|
||||
// Set state to SubscriptionPending while waiting for acks
|
||||
LogFlow($"Streaming {subscribersToStream.Count} subscriptions");
|
||||
if (subscribersToStream.Any()) {
|
||||
_currentState = ConnectionState.SubscriptionPending;
|
||||
LogFlow($"State -> {_currentState}");
|
||||
}
|
||||
|
||||
bool allSucceeded = true;
|
||||
@@ -319,6 +367,7 @@ namespace eagle {
|
||||
if (!allSucceeded) {
|
||||
// Subscription failed - this is critical because the game won't receive
|
||||
// updates. Trigger reconnect rather than proceeding to Connected state.
|
||||
LogFlow("SUBSCRIBE_FAILED triggering reconnect");
|
||||
LogConnectionEvent(
|
||||
"subscribe_failed",
|
||||
"Subscription failed - triggering reconnect");
|
||||
@@ -342,6 +391,7 @@ namespace eagle {
|
||||
_lastSuccessfulConnect = DateTime.UtcNow;
|
||||
_consecutiveFailures = 0; // Reset backoff on successful connection
|
||||
_currentState = ConnectionState.Connected;
|
||||
LogFlow($"State -> {_currentState}");
|
||||
_circuitBreaker.RecordSuccess();
|
||||
LogConnectionEvent("connect_success");
|
||||
|
||||
@@ -376,6 +426,18 @@ namespace eagle {
|
||||
_pendingCommands.Add(pendingCommands.Dequeue());
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up resources before reconnecting
|
||||
lock (this) {
|
||||
_streamingCall?.Dispose();
|
||||
_streamingCall = null;
|
||||
_threadCancellationTokenSource?.Cancel();
|
||||
}
|
||||
MarkAllShardokGamesForResync();
|
||||
CancelAllPendingSubscriptionAcks();
|
||||
|
||||
// Schedule reconnect - without this, the connection dies silently
|
||||
ScheduleReconnect($"ConnectFailed-{e.GetType().Name}");
|
||||
}
|
||||
} finally { _isConnecting = false; }
|
||||
}
|
||||
@@ -413,26 +475,35 @@ namespace eagle {
|
||||
lock (this) { _pendingCommands.Add(nextCommand); }
|
||||
}
|
||||
} else {
|
||||
// CurrentEagleToken is null - server hasn't sent us
|
||||
// commands yet, or we skipped them. Retry the pending
|
||||
// command anyway; if the server already processed it, it
|
||||
// will be ignored as duplicate.
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
"No matching command for token " + providedToken);
|
||||
$"No current token, retrying pending command with token {providedToken}");
|
||||
await PostRequest(nextCommand);
|
||||
}
|
||||
|
||||
break;
|
||||
case UniversalCommand.SealedValueOneofCase.ShardokCommand: {
|
||||
Int64 providedShardokToken =
|
||||
nextCommand.Command.ShardokCommand.ShardokToken;
|
||||
if (subscriber.CurrentShardokToken(
|
||||
nextCommand.Command.ShardokCommand.GameId)
|
||||
is Int64 shardokToken &&
|
||||
var currentShardokToken = subscriber.CurrentShardokToken(
|
||||
nextCommand.Command.ShardokCommand.GameId);
|
||||
if (currentShardokToken is Int64 shardokToken &&
|
||||
shardokToken == providedShardokToken) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
"Found a matching pending command with token " +
|
||||
shardokToken);
|
||||
await PostRequest(nextCommand);
|
||||
} else if (currentShardokToken == null) {
|
||||
// No current token - retry anyway
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"No current Shardok token, retrying pending command with token {providedShardokToken}");
|
||||
await PostRequest(nextCommand);
|
||||
} else {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
"No matching command for token " +
|
||||
providedShardokToken);
|
||||
$"Shardok token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -442,25 +513,27 @@ namespace eagle {
|
||||
Int64 providedShardokToken =
|
||||
nextCommand.Command.ShardokPlacementCommands
|
||||
.ShardokToken;
|
||||
if (subscriber.CurrentShardokToken(
|
||||
nextCommand.Command.ShardokCommand.GameId)
|
||||
is Int64 shardokToken &&
|
||||
var currentShardokToken = subscriber.CurrentShardokToken(
|
||||
nextCommand.Command.ShardokPlacementCommands.GameId);
|
||||
if (currentShardokToken is Int64 shardokToken &&
|
||||
shardokToken == providedShardokToken) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
"Found a matching pending command with token " +
|
||||
"Found a matching pending placement command with token " +
|
||||
shardokToken);
|
||||
await PostRequest(nextCommand);
|
||||
} else if (currentShardokToken == null) {
|
||||
// No current token - retry anyway
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"No current Shardok token, retrying pending placement command with token {providedShardokToken}");
|
||||
await PostRequest(nextCommand);
|
||||
} else {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
"No matching command for token " +
|
||||
providedShardokToken);
|
||||
$"Shardok placement token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await PostRequest(nextCommand);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,6 +565,9 @@ namespace eagle {
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
Thread threadToJoin = null;
|
||||
CancellationTokenSource tokenSourceToDispose = null;
|
||||
|
||||
lock (this) {
|
||||
// Dispose timers first to stop any pending callbacks
|
||||
StopIdleCheckTimer();
|
||||
@@ -514,20 +590,25 @@ namespace eagle {
|
||||
// Clear pending commands
|
||||
_pendingCommands.Clear();
|
||||
|
||||
// Cancel and dispose thread cancellation token
|
||||
// Cancel thread cancellation token (but don't dispose yet)
|
||||
_threadCancellationTokenSource?.Cancel();
|
||||
|
||||
// Give thread a chance to exit gracefully
|
||||
if (_streamingCallThread != null) {
|
||||
if (!_streamingCallThread.Join(TimeSpan.FromSeconds(2))) {
|
||||
Console.WriteLine("Warning: Streaming call thread did not exit gracefully");
|
||||
}
|
||||
_streamingCallThread = null;
|
||||
}
|
||||
|
||||
_threadCancellationTokenSource?.Dispose();
|
||||
// Capture thread and token source references for cleanup outside lock
|
||||
threadToJoin = _streamingCallThread;
|
||||
_streamingCallThread = null;
|
||||
tokenSourceToDispose = _threadCancellationTokenSource;
|
||||
_threadCancellationTokenSource = null;
|
||||
}
|
||||
|
||||
// Join thread OUTSIDE lock to avoid deadlock - the streaming thread
|
||||
// may be trying to acquire lock(this) when we're waiting for it
|
||||
if (threadToJoin != null) {
|
||||
if (!threadToJoin.Join(TimeSpan.FromSeconds(2))) {
|
||||
Console.WriteLine("Warning: Streaming call thread did not exit gracefully");
|
||||
}
|
||||
}
|
||||
|
||||
tokenSourceToDispose?.Dispose();
|
||||
}
|
||||
|
||||
public void SetLobbySubscriber(ILobbySubscriber subscriber) {
|
||||
@@ -571,13 +652,25 @@ namespace eagle {
|
||||
try {
|
||||
lock (this) { _pendingCommands.Add(request); }
|
||||
|
||||
await DoWithStreamingCall(async (streamingCall) => {
|
||||
var success = await DoWithStreamingCall(async (streamingCall) => {
|
||||
await streamingCall.RequestStream.WriteAsync(
|
||||
new UpdateStreamRequest { PostCommandRequest = request });
|
||||
return true;
|
||||
});
|
||||
lock (this) { _pendingCommands.Remove(request); }
|
||||
} catch (Exception e) { Console.WriteLine("Failed to post command: " + e); }
|
||||
|
||||
// Only remove from pending if successfully sent.
|
||||
// If connection was dead, leave in queue for retry after reconnect.
|
||||
if (success) {
|
||||
lock (this) { _pendingCommands.Remove(request); }
|
||||
} else {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[POST] Command not sent (connection dead), keeping in pending queue for retry");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Console.WriteLine("Failed to post command: " + e);
|
||||
_remoteEagleClientLogger.LogLine($"[POST] Exception posting command: {e.Message}");
|
||||
// Leave in _pendingCommands for retry after reconnect
|
||||
}
|
||||
}
|
||||
|
||||
private async Task
|
||||
@@ -654,9 +747,38 @@ namespace eagle {
|
||||
|
||||
private delegate Task<bool> WithStreamingCallDelegate(
|
||||
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> call);
|
||||
|
||||
/// <summary>
|
||||
/// Execute an action with the streaming call, with a timeout to prevent indefinite
|
||||
/// blocking. If the action takes longer than WriteAsyncTimeoutMs, we consider the
|
||||
/// connection dead and return false. This prevents thread pool exhaustion on dead
|
||||
/// connections.
|
||||
/// </summary>
|
||||
private async Task<bool> DoWithStreamingCall(WithStreamingCallDelegate action) {
|
||||
var sc = _streamingCall;
|
||||
if (sc == null) return false;
|
||||
|
||||
try {
|
||||
return await action(_streamingCall);
|
||||
using var timeoutCts = new CancellationTokenSource(WriteAsyncTimeoutMs);
|
||||
var actionTask = action(sc);
|
||||
var timeoutTask = Task.Delay(WriteAsyncTimeoutMs, timeoutCts.Token);
|
||||
|
||||
var completedTask =
|
||||
await Task.WhenAny(actionTask, timeoutTask).ConfigureAwait(false);
|
||||
|
||||
if (completedTask == timeoutTask) {
|
||||
// WriteAsync timed out - connection is likely dead
|
||||
LogConnectionEvent(
|
||||
"write_timeout",
|
||||
$"WriteAsync timed out after {WriteAsyncTimeoutMs}ms");
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[WRITE_TIMEOUT] WriteAsync timed out after {WriteAsyncTimeoutMs}ms, connection may be dead");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cancel the timeout task since action completed
|
||||
timeoutCts.Cancel();
|
||||
return await actionTask.ConfigureAwait(false);
|
||||
} catch (RpcException e) {
|
||||
if (e.StatusCode == StatusCode.Cancelled) {
|
||||
// This is expected when the connection is closed.
|
||||
@@ -664,9 +786,15 @@ namespace eagle {
|
||||
} else {
|
||||
throw;
|
||||
}
|
||||
} catch (OperationCanceledException) {
|
||||
// Timeout was triggered
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send a request on the streaming call with a timeout to prevent indefinite blocking.
|
||||
/// </summary>
|
||||
public async Task<bool> SendUpdateStreamRequestAsync(UpdateStreamRequest request) {
|
||||
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc = null;
|
||||
lock (this) {
|
||||
@@ -675,7 +803,30 @@ namespace eagle {
|
||||
}
|
||||
|
||||
try {
|
||||
await sc.RequestStream.WriteAsync(request, _cancellationToken);
|
||||
using var timeoutCts = new CancellationTokenSource(WriteAsyncTimeoutMs);
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
_cancellationToken,
|
||||
timeoutCts.Token);
|
||||
|
||||
var writeTask = sc.RequestStream.WriteAsync(request, linkedCts.Token);
|
||||
var timeoutTask = Task.Delay(WriteAsyncTimeoutMs, timeoutCts.Token);
|
||||
|
||||
var completedTask =
|
||||
await Task.WhenAny(writeTask, timeoutTask).ConfigureAwait(false);
|
||||
|
||||
if (completedTask == timeoutTask) {
|
||||
// WriteAsync timed out - connection is likely dead
|
||||
LogConnectionEvent(
|
||||
"write_timeout",
|
||||
$"SendUpdateStreamRequestAsync timed out after {WriteAsyncTimeoutMs}ms");
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[WRITE_TIMEOUT] SendUpdateStreamRequestAsync timed out after {WriteAsyncTimeoutMs}ms");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cancel the timeout task since write completed
|
||||
timeoutCts.Cancel();
|
||||
await writeTask.ConfigureAwait(false); // Observe any exception
|
||||
return true;
|
||||
} catch (RpcException e) {
|
||||
if (e.StatusCode == StatusCode.Cancelled) {
|
||||
@@ -684,6 +835,9 @@ namespace eagle {
|
||||
} else {
|
||||
throw;
|
||||
}
|
||||
} catch (OperationCanceledException) {
|
||||
// Timeout was triggered
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,6 +849,7 @@ namespace eagle {
|
||||
private void HandleGameUpdate(GameUpdate gameUpdate, DateTime receivedTime) {
|
||||
switch (gameUpdate.GameUpdateDetailsCase) {
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ErrorResponse:
|
||||
LogFlow($"UPDATE ErrorResponse game={gameUpdate.GameId}");
|
||||
_remoteEagleClientLogger.LogLine("Got an error response!");
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
lock (this) { _subscribers.Remove(gameUpdate.GameId); }
|
||||
@@ -717,16 +872,63 @@ namespace eagle {
|
||||
break;
|
||||
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
|
||||
var arResp = gameUpdate.ActionResultResponse;
|
||||
var arCount = arResp.ActionResultViews?.Count ?? 0;
|
||||
var hasStartingState = gameUpdate.StartingState != null;
|
||||
var hasCommands = arResp.AvailableCommands != null;
|
||||
var cmdToken = hasCommands ? arResp.AvailableCommands.Token : -1;
|
||||
var cmdCount = hasCommands
|
||||
? arResp.AvailableCommands.CommandsByProvince?.Count ?? 0
|
||||
: 0;
|
||||
var status = arResp.ServerGameStatus?.Status.ToString() ?? "null";
|
||||
LogFlow($"UPDATE ActionResult game={gameUpdate.GameId} countAfter={arResp.UnfilteredResultCountAfter} " +
|
||||
$"actionResults={arCount} startingState={hasStartingState} hasCommands={hasCommands} " +
|
||||
$"token={cmdToken} cmdProvinces={cmdCount} status={status}");
|
||||
|
||||
// Update result counts IMMEDIATELY on the gRPC thread, before enqueueing.
|
||||
// This ensures reconnects use accurate counts even when MainQueue is blocked
|
||||
// (e.g., when Unity is backgrounded).
|
||||
lock (this) {
|
||||
if (_subscribers.TryGetValue(gameUpdate.GameId, out var sub)) {
|
||||
sub.UpdateResultCounts(gameUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
MainQueue.Q.Enqueue(async () => {
|
||||
IClientConnectionSubscriber subscriber;
|
||||
lock (this) {
|
||||
if (_subscribers.TryGetValue(gameUpdate.GameId, out subscriber)) {
|
||||
subscriber.ReceiveGameUpdate(gameUpdate);
|
||||
} else {
|
||||
_subscribers.Remove(gameUpdate.GameId);
|
||||
}
|
||||
}
|
||||
|
||||
await TryPendingCommands();
|
||||
|
||||
var processTime = (DateTime.UtcNow - receivedTime).TotalMilliseconds;
|
||||
if (processTime > 100.0) {
|
||||
_timingsLogger.LogLine($"PROCESS {processTime} ms");
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
|
||||
var shardokResp = gameUpdate.ShardokActionResultResponse;
|
||||
var shardokGameCount = shardokResp.ShardokGameResponses?.Count ?? 0;
|
||||
// Log each Shardok game's sequence to detect missing results
|
||||
foreach (var sgr in shardokResp.ShardokGameResponses) {
|
||||
var resultCount = sgr.ActionResultViews?.Count ?? 0;
|
||||
LogFlow($"UPDATE ShardokResult game={gameUpdate.GameId} " +
|
||||
$"shardok={sgr.ShardokGameId} countAfter={sgr.NewResultViewCount} " +
|
||||
$"results={resultCount}");
|
||||
}
|
||||
|
||||
// Update result counts IMMEDIATELY on the gRPC thread, before enqueueing.
|
||||
lock (this) {
|
||||
if (_subscribers.TryGetValue(gameUpdate.GameId, out var sub2)) {
|
||||
sub2.UpdateResultCounts(gameUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
MainQueue.Q.Enqueue(async () => {
|
||||
IClientConnectionSubscriber subscriber;
|
||||
lock (this) {
|
||||
@@ -749,6 +951,7 @@ namespace eagle {
|
||||
}
|
||||
|
||||
private async void HandleStreamingCall() {
|
||||
LogFlow("HandleStreamingCall STARTED");
|
||||
try {
|
||||
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc;
|
||||
CancellationToken threadToken;
|
||||
@@ -864,6 +1067,10 @@ namespace eagle {
|
||||
waitStartTime = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// While loop exited normally (not via exception)
|
||||
var scNull = sc == null;
|
||||
var tokenCancelled = _currentThreadToken.IsCancellationRequested;
|
||||
LogFlow($"HandleStreamingCall ENDED normally: sc_null={scNull} token_cancelled={tokenCancelled}");
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
"How did we get here? This is not my beautiful wife!");
|
||||
} catch (RpcException e) {
|
||||
@@ -930,6 +1137,19 @@ namespace eagle {
|
||||
CancelAllPendingSubscriptionAcks();
|
||||
|
||||
ScheduleReconnect("ObjectDisposed");
|
||||
} catch (Exception e) {
|
||||
// Catch-all for any unexpected exceptions. Without this, exceptions in an
|
||||
// async void method can crash the process or be silently lost, leaving the
|
||||
// connection dead with no recovery.
|
||||
_lastDisconnect = DateTime.UtcNow;
|
||||
LogConnectionEvent("disconnect", $"UnexpectedException: {e.GetType().Name}");
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"UNEXPECTED exception in HandleStreamingCall: {e}");
|
||||
|
||||
MarkAllShardokGamesForResync();
|
||||
CancelAllPendingSubscriptionAcks();
|
||||
|
||||
ScheduleReconnect($"UnexpectedException-{e.GetType().Name}");
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -963,7 +1183,17 @@ namespace eagle {
|
||||
AutoReset = true,
|
||||
Interval = 5000 // Check every 5 seconds
|
||||
};
|
||||
_idleCheckTimer.Elapsed += (sender, args) => CheckForIdleTimeout();
|
||||
_idleCheckTimer.Elapsed += (sender, args) => {
|
||||
try {
|
||||
// Log BEFORE callback to confirm timer is firing
|
||||
Console.WriteLine($"[IDLE_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
|
||||
CheckForIdleTimeout();
|
||||
} catch (Exception e) {
|
||||
// Timer callbacks must not throw - it can stop the timer from firing again
|
||||
Console.WriteLine($"[IDLE_TIMER] Exception in callback: {e}");
|
||||
_remoteEagleClientLogger.LogLine($"Exception in idle check timer: {e}");
|
||||
}
|
||||
};
|
||||
_idleCheckTimer.Enabled = true;
|
||||
}
|
||||
|
||||
@@ -976,10 +1206,16 @@ namespace eagle {
|
||||
}
|
||||
|
||||
private void CheckForIdleTimeout() {
|
||||
if (_cancellationToken.IsCancellationRequested) { return; }
|
||||
|
||||
var idleTime = (DateTime.UtcNow - _lastResponseReceived).TotalSeconds;
|
||||
|
||||
// Log every check when idle > 5s to diagnose timer issues
|
||||
if (idleTime > 5.0) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[IDLE_CHECK] idle_time={idleTime:F1}s, cancelled={_cancellationToken.IsCancellationRequested}");
|
||||
}
|
||||
|
||||
if (_cancellationToken.IsCancellationRequested) { return; }
|
||||
|
||||
// Early warnings at 10s and 20s to help diagnose slow connections
|
||||
if (idleTime > 20.0 && _lastIdleWarningLevel < 2) {
|
||||
_lastIdleWarningLevel = 2;
|
||||
@@ -1002,7 +1238,7 @@ namespace eagle {
|
||||
if (toCancel != null) {
|
||||
toCancel.Dispose();
|
||||
lock (this) { _streamingCall = null; }
|
||||
Task.Run(() => Connect());
|
||||
RunConnectAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1011,7 +1247,26 @@ namespace eagle {
|
||||
StopHeartbeatTimer();
|
||||
_heartbeatTimer =
|
||||
new Timer { AutoReset = true, Interval = HeartbeatIntervalSeconds * 1000 };
|
||||
_heartbeatTimer.Elapsed += (sender, args) => Task.Run(() => SendHeartbeat());
|
||||
_heartbeatTimer.Elapsed += (sender, args) => {
|
||||
try {
|
||||
// Log BEFORE Task.Run to confirm timer is firing
|
||||
Console.WriteLine(
|
||||
$"[HEARTBEAT_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
|
||||
Task.Run(async () => {
|
||||
try {
|
||||
await SendHeartbeat();
|
||||
} catch (Exception e) {
|
||||
// Log but don't rethrow - exceptions in Task.Run are silently lost
|
||||
Console.WriteLine($"[HEARTBEAT] Exception in SendHeartbeat: {e}");
|
||||
_remoteEagleClientLogger.LogLine($"Exception in SendHeartbeat: {e}");
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
// Timer callbacks must not throw
|
||||
Console.WriteLine($"[HEARTBEAT_TIMER] Exception in callback: {e}");
|
||||
_remoteEagleClientLogger.LogLine($"Exception in heartbeat timer: {e}");
|
||||
}
|
||||
};
|
||||
_heartbeatTimer.Enabled = true;
|
||||
}
|
||||
|
||||
@@ -1024,8 +1279,16 @@ namespace eagle {
|
||||
}
|
||||
|
||||
private async Task SendHeartbeat() {
|
||||
if (_cancellationToken.IsCancellationRequested) { return; }
|
||||
if (_currentState != ConnectionState.Connected) { return; }
|
||||
LogFlow($"HEARTBEAT_START state={_currentState}");
|
||||
|
||||
if (_cancellationToken.IsCancellationRequested) {
|
||||
LogFlow("HEARTBEAT_SKIP reason=cancellation_requested");
|
||||
return;
|
||||
}
|
||||
if (_currentState != ConnectionState.Connected) {
|
||||
LogFlow($"HEARTBEAT_SKIP reason=not_connected state={_currentState}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build sync status for all subscribed games
|
||||
var heartbeatRequest = new HeartbeatRequest {
|
||||
@@ -1055,56 +1318,84 @@ namespace eagle {
|
||||
|
||||
var sent = await SendUpdateStreamRequestAsync(request);
|
||||
if (sent) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[HEARTBEAT] Sent heartbeat with {heartbeatRequest.GameSyncStatuses.Count} games");
|
||||
var gameInfo = string.Join(
|
||||
", ",
|
||||
heartbeatRequest.GameSyncStatuses.Select(
|
||||
g => $"{g.GameId}:{g.UnfilteredResultCount}"));
|
||||
LogFlow($"HEARTBEAT_SENT games={heartbeatRequest.GameSyncStatuses.Count} ({gameInfo})");
|
||||
} else {
|
||||
LogFlow("HEARTBEAT_FAILED stream_unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
// Grace period after connect before sync mismatch triggers reconnect.
|
||||
// Allows time to receive and process historical results after fresh subscribe.
|
||||
private const double SyncMismatchGracePeriodSeconds = 60.0;
|
||||
|
||||
private void HandleHeartbeatResponse(HeartbeatResponse response) {
|
||||
LogFlow($"HEARTBEAT_RESPONSE server_ts={response.ServerTimestamp}");
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[HEARTBEAT] Got response, server_timestamp={response.ServerTimestamp}");
|
||||
|
||||
// Check for sync mismatches reported by server
|
||||
if (response.GameSyncResults.Count > 0) {
|
||||
foreach (var syncResult in response.GameSyncResults) {
|
||||
if (!syncResult.EagleInSync) {
|
||||
bool hasMismatch = false;
|
||||
foreach (var syncResult in response.GameSyncResults) {
|
||||
if (!syncResult.EagleInSync) {
|
||||
LogFlow($"SYNC_MISMATCH game={syncResult.GameId} server_count={syncResult.ServerUnfilteredResultCount}");
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SYNC_MISMATCH] Game {syncResult.GameId}: Eagle out of sync, " +
|
||||
$"server has {syncResult.ServerUnfilteredResultCount} results");
|
||||
LogConnectionEvent(
|
||||
"sync_mismatch_eagle",
|
||||
$"game={syncResult.GameId}, server_count={syncResult.ServerUnfilteredResultCount}");
|
||||
hasMismatch = true;
|
||||
}
|
||||
|
||||
foreach (var shardokResult in syncResult.ShardokSyncResults) {
|
||||
if (!shardokResult.InSync) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SYNC_MISMATCH] Game {syncResult.GameId}: Eagle out of sync, " +
|
||||
$"server has {syncResult.ServerUnfilteredResultCount} results");
|
||||
$"[SYNC_MISMATCH] Game {syncResult.GameId}, Shardok {shardokResult.ShardokGameId}: " +
|
||||
$"out of sync, server has {shardokResult.ServerFilteredResultCount} results");
|
||||
LogConnectionEvent(
|
||||
"sync_mismatch_eagle",
|
||||
$"game={syncResult.GameId}, server_count={syncResult.ServerUnfilteredResultCount}");
|
||||
}
|
||||
|
||||
foreach (var shardokResult in syncResult.ShardokSyncResults) {
|
||||
if (!shardokResult.InSync) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SYNC_MISMATCH] Game {syncResult.GameId}, Shardok {shardokResult.ShardokGameId}: " +
|
||||
$"out of sync, server has {shardokResult.ServerFilteredResultCount} results");
|
||||
LogConnectionEvent(
|
||||
"sync_mismatch_shardok",
|
||||
$"game={syncResult.GameId}, shardok={shardokResult.ShardokGameId}, " +
|
||||
$"server_count={shardokResult.ServerFilteredResultCount}");
|
||||
}
|
||||
"sync_mismatch_shardok",
|
||||
$"game={syncResult.GameId}, shardok={shardokResult.ShardokGameId}, " +
|
||||
$"server_count={shardokResult.ServerFilteredResultCount}");
|
||||
hasMismatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger resync by reconnecting - this will re-subscribe with current counts
|
||||
// and the server will send missing updates
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
"[SYNC_MISMATCH] Detected sync mismatch, triggering reconnect to resync");
|
||||
LogConnectionEvent("sync_mismatch_reconnect", "Triggering reconnect to resync");
|
||||
|
||||
// Schedule reconnect to resync
|
||||
lock (this) {
|
||||
_streamingCall?.Dispose();
|
||||
_streamingCall = null;
|
||||
_threadCancellationTokenSource?.Cancel();
|
||||
}
|
||||
MarkAllShardokGamesForResync();
|
||||
CancelAllPendingSubscriptionAcks();
|
||||
ScheduleReconnect("SyncMismatch");
|
||||
}
|
||||
|
||||
if (!hasMismatch) return;
|
||||
|
||||
// Grace period after connect - allow time to receive historical results
|
||||
if (_lastSuccessfulConnect.HasValue) {
|
||||
var timeSinceConnect =
|
||||
(DateTime.UtcNow - _lastSuccessfulConnect.Value).TotalSeconds;
|
||||
if (timeSinceConnect < SyncMismatchGracePeriodSeconds) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SYNC_MISMATCH] Ignoring mismatch during grace period ({timeSinceConnect:F1}s < {SyncMismatchGracePeriodSeconds}s)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger resync by reconnecting - this will re-subscribe with current counts
|
||||
// and the server will send missing updates
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
"[SYNC_MISMATCH] Detected sync mismatch, triggering reconnect to resync");
|
||||
LogConnectionEvent("sync_mismatch_reconnect", "Triggering reconnect to resync");
|
||||
|
||||
// Schedule reconnect to resync
|
||||
// Stop timers FIRST to prevent them firing during reconnection
|
||||
StopHeartbeatTimer();
|
||||
StopIdleCheckTimer();
|
||||
lock (this) {
|
||||
_streamingCall?.Dispose();
|
||||
_streamingCall = null;
|
||||
_threadCancellationTokenSource?.Cancel();
|
||||
}
|
||||
MarkAllShardokGamesForResync();
|
||||
CancelAllPendingSubscriptionAcks();
|
||||
ScheduleReconnect("SyncMismatch");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Auth;
|
||||
using Cysharp.Net.Http;
|
||||
using eagle;
|
||||
using Grpc.Core;
|
||||
@@ -28,11 +29,7 @@ public class EagleConnection : IDisposable {
|
||||
_channel = GrpcChannel.ForAddress(
|
||||
"https://" + url,
|
||||
new GrpcChannelOptions {
|
||||
HttpHandler =
|
||||
new YetAnotherHttpHandler {
|
||||
Http2Only = true,
|
||||
Http2KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveSeconds)
|
||||
},
|
||||
HttpHandler = CreateHttpHandler(),
|
||||
DisposeHttpClient = true,
|
||||
LoggerFactory = _loggerFactory,
|
||||
MaxReceiveMessageSize = null
|
||||
@@ -42,6 +39,44 @@ public class EagleConnection : IDisposable {
|
||||
return invoker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create HTTP handler with timeouts configured for reliable connection management.
|
||||
/// These settings help detect and recover from dead connections, especially on Windows
|
||||
/// where firewalls may silently drop HTTP/2 keep-alive pings.
|
||||
/// </summary>
|
||||
private static YetAnotherHttpHandler CreateHttpHandler() {
|
||||
return new YetAnotherHttpHandler {
|
||||
Http2Only = true,
|
||||
// Send keep-alive pings every 15 seconds
|
||||
Http2KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveSeconds),
|
||||
// Close connection if ping not acknowledged within 5 seconds
|
||||
Http2KeepAliveTimeout = TimeSpan.FromSeconds(5),
|
||||
// Continue pinging even when idle to detect dead connections
|
||||
Http2KeepAliveWhileIdle = true,
|
||||
// Don't wait forever for initial connection
|
||||
ConnectTimeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
}
|
||||
|
||||
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 } };
|
||||
@@ -53,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
@@ -511,6 +511,7 @@ public class HexGrid : MonoBehaviour {
|
||||
}
|
||||
|
||||
public void ClearCellLabels() {
|
||||
if (cells == null) return;
|
||||
for (int i = 0; i < width * height; i++) {
|
||||
cells[i].UpperUnitInfoLabel.text = "";
|
||||
cells[i].LowerUnitInfoLabel.text = "";
|
||||
|
||||
+94
-94
@@ -385,98 +385,99 @@ namespace Shardok {
|
||||
Model.MyUncommittedUnits.Where(uv => uv.Location.Row == -1).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the ShardokGameModel is updated. This is invoked from UpdateAction,
|
||||
/// which is called from ShardokGameModel.HandleUpdates, which runs on MainQueue.
|
||||
/// No need to re-enqueue - we're already on the main thread.
|
||||
/// </summary>
|
||||
void ModelUpdated() {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
if (Model == null) { return; }
|
||||
SetHeroLabels();
|
||||
SetModifiers();
|
||||
UpdateReserves();
|
||||
if (Model == null) { return; }
|
||||
SetHeroLabels();
|
||||
SetModifiers();
|
||||
UpdateReserves();
|
||||
|
||||
HandleEnemyStartingPositionOverlays();
|
||||
HandleEnemyStartingPositionOverlays();
|
||||
|
||||
endTurnButton.interactable = false;
|
||||
endTurnButton.interactable = false;
|
||||
|
||||
if (Model.GameStatus != null &&
|
||||
(Model.GameStatus.State == GameStatus.Types.State.Victory)) {
|
||||
turnStatusLabel.text = "Game Over!";
|
||||
if (Model.GameStatus != null &&
|
||||
(Model.GameStatus.State == GameStatus.Types.State.Victory)) {
|
||||
turnStatusLabel.text = "Game Over!";
|
||||
|
||||
gameOverText.text = Model.GameStatus.Description;
|
||||
gameOverCanvas.gameObject.SetActive(true);
|
||||
gameOverText.text = Model.GameStatus.Description;
|
||||
gameOverCanvas.gameObject.SetActive(true);
|
||||
|
||||
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Back to Eagle";
|
||||
endTurnButton.interactable = true;
|
||||
} else if (Model.GameStatus != null && Model.MyTurn) {
|
||||
gameOverCanvas.gameObject.SetActive(false);
|
||||
turnStatusLabel.text = "Your Turn";
|
||||
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Back to Eagle";
|
||||
endTurnButton.interactable = true;
|
||||
} else if (Model.GameStatus != null && Model.MyTurn) {
|
||||
gameOverCanvas.gameObject.SetActive(false);
|
||||
turnStatusLabel.text = "Your Turn";
|
||||
|
||||
if (Model.InSetUp) {
|
||||
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Commit";
|
||||
if (Model.InSetUp) {
|
||||
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Commit";
|
||||
|
||||
var unplacedUnitsWithLocations =
|
||||
Model.MyUncommittedUnits
|
||||
.Where(u => u.Location.Row >= 0 && u.Location.Column >= 0)
|
||||
.ToList();
|
||||
var unplacedUnitsWithLocations =
|
||||
Model.MyUncommittedUnits
|
||||
.Where(u => u.Location.Row >= 0 && u.Location.Column >= 0)
|
||||
.ToList();
|
||||
|
||||
if (!Model.MyTurn) {
|
||||
endTurnButton.interactable = false;
|
||||
} else if (unplacedUnitsWithLocations.Count() < 1) {
|
||||
endTurnButton.GetComponentInChildren<TMP_Text>().text =
|
||||
"No units placed";
|
||||
endTurnButton.interactable = false;
|
||||
} else if (unplacedUnitsWithLocations.Count() > 10) {
|
||||
endTurnButton.GetComponentInChildren<TMP_Text>().text =
|
||||
"Too many units";
|
||||
endTurnButton.interactable = false;
|
||||
} else {
|
||||
endTurnButton.interactable = true;
|
||||
}
|
||||
|
||||
SetDisplayedCommandGroup(0);
|
||||
if (!Model.MyTurn) {
|
||||
endTurnButton.interactable = false;
|
||||
} else if (unplacedUnitsWithLocations.Count() < 1) {
|
||||
endTurnButton.GetComponentInChildren<TMP_Text>().text = "No units placed";
|
||||
endTurnButton.interactable = false;
|
||||
} else if (unplacedUnitsWithLocations.Count() > 10) {
|
||||
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Too many units";
|
||||
endTurnButton.interactable = false;
|
||||
} else {
|
||||
endTurnButton.GetComponentInChildren<TMP_Text>().text = "End Turn";
|
||||
}
|
||||
|
||||
if (Model.HasAvailableCommandWhere(
|
||||
command => commandTypeUIManager.CommandGroupForType(command.Type) ==
|
||||
CommandTypeUIManager.EndTurnCommandGroup)) {
|
||||
endTurnButton.interactable = true;
|
||||
}
|
||||
|
||||
SelectAppropriateDefaultCommand();
|
||||
SetDisplayedCommandGroup(0);
|
||||
} else {
|
||||
turnStatusLabel.text = $"{Model.CurrentPlayerName}'s Turn";
|
||||
endTurnButton.GetComponentInChildren<TMP_Text>().text = "End Turn";
|
||||
}
|
||||
|
||||
locationNameText.text = Model.LocationName;
|
||||
|
||||
if (Model.History.Count > 0) {
|
||||
string monthString = new DateTime(777, Model.Month, 1)
|
||||
.ToString("MMMM", CultureInfo.InvariantCulture);
|
||||
roundInfoText.text = $"{monthString} {Model.CurrentRound}";
|
||||
Weather weather = Model.Weather;
|
||||
if (weather != null) {
|
||||
roundInfoText.text += ", " + ProtoExtensions.WeatherToString(weather);
|
||||
}
|
||||
if (Model.HasAvailableCommandWhere(
|
||||
command => commandTypeUIManager.CommandGroupForType(command.Type) ==
|
||||
CommandTypeUIManager.EndTurnCommandGroup)) {
|
||||
endTurnButton.interactable = true;
|
||||
}
|
||||
|
||||
if (Model.History.Count == 0) {
|
||||
turnHistoryButtonText.text = NoHistoryText;
|
||||
} else if (Model.History.Count > _lastRetrievedHistoryCount) {
|
||||
for (int i = _lastRetrievedHistoryCount; i < Model.History.Count; i++) {
|
||||
var historyEntry = Model.History[i];
|
||||
turnHistoryPanel.AddLine(GetActionResultDescription(historyEntry));
|
||||
SelectAppropriateDefaultCommand();
|
||||
} else {
|
||||
turnStatusLabel.text = $"{Model.CurrentPlayerName}'s Turn";
|
||||
}
|
||||
|
||||
ActionType type = historyEntry.Type;
|
||||
var thisSound = soundManager.SoundForType(type);
|
||||
if (thisSound != null) { audioClipSource.PlayOneShot(thisSound, 1.0f); }
|
||||
}
|
||||
_lastRetrievedHistoryCount = Model.History.Count;
|
||||
turnHistoryButtonText.text = GetActionResultDescription(Model.History.Last());
|
||||
SetModifiers();
|
||||
locationNameText.text = Model.LocationName;
|
||||
|
||||
if (Model.History.Count > 0) {
|
||||
string monthString = new DateTime(777, Model.Month, 1)
|
||||
.ToString("MMMM", CultureInfo.InvariantCulture);
|
||||
roundInfoText.text = $"{monthString} {Model.CurrentRound}";
|
||||
Weather weather = Model.Weather;
|
||||
if (weather != null) {
|
||||
roundInfoText.text += ", " + ProtoExtensions.WeatherToString(weather);
|
||||
}
|
||||
}
|
||||
|
||||
SetupArmiesTable();
|
||||
});
|
||||
if (Model.History.Count == 0) {
|
||||
turnHistoryButtonText.text = NoHistoryText;
|
||||
} else if (Model.History.Count > _lastRetrievedHistoryCount) {
|
||||
for (int i = _lastRetrievedHistoryCount; i < Model.History.Count; i++) {
|
||||
var historyEntry = Model.History[i];
|
||||
turnHistoryPanel.AddLine(GetActionResultDescription(historyEntry));
|
||||
|
||||
ActionType type = historyEntry.Type;
|
||||
var thisSound = soundManager.SoundForType(type);
|
||||
if (thisSound != null) { audioClipSource.PlayOneShot(thisSound, 1.0f); }
|
||||
}
|
||||
_lastRetrievedHistoryCount = Model.History.Count;
|
||||
turnHistoryButtonText.text = GetActionResultDescription(Model.History.Last());
|
||||
SetModifiers();
|
||||
}
|
||||
|
||||
SetupArmiesTable();
|
||||
}
|
||||
|
||||
private void SetupArmiesTable() {
|
||||
@@ -707,34 +708,33 @@ namespace Shardok {
|
||||
}
|
||||
|
||||
void SetModifiers() {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
hexGrid.ClearCellModifierImages();
|
||||
// Called from ModelUpdated which runs on MainQueue - no need to re-enqueue
|
||||
hexGrid.ClearCellModifierImages();
|
||||
|
||||
for (byte row = 0; row < Model.Map.RowCount; row++) {
|
||||
for (byte column = 0; column < Model.Map.ColumnCount; column++) {
|
||||
Coords coords = new Coords();
|
||||
coords.Row = row;
|
||||
coords.Column = column;
|
||||
for (byte row = 0; row < Model.Map.RowCount; row++) {
|
||||
for (byte column = 0; column < Model.Map.ColumnCount; column++) {
|
||||
Coords coords = new Coords();
|
||||
coords.Row = row;
|
||||
coords.Column = column;
|
||||
|
||||
var terrain = Model.Map.TerrainAt(coords);
|
||||
int cellIndex = MapCoordsToGridIndex(coords);
|
||||
int numberForCell = _randomNumberForCellIndex[cellIndex];
|
||||
hexGrid.SetCellTerrainImage(
|
||||
cellIndex,
|
||||
_imageForTerrainTracker
|
||||
.GetImageForTerrain(terrain, numberForCell, Model.Month));
|
||||
if (terrain.Modifier?.Fire != null) {
|
||||
hexGrid.SetCellModifierEffect(cellIndex, fireEffectPrefab);
|
||||
} else {
|
||||
hexGrid.SetCellModifierEffect(cellIndex, null);
|
||||
}
|
||||
var terrain = Model.Map.TerrainAt(coords);
|
||||
int cellIndex = MapCoordsToGridIndex(coords);
|
||||
int numberForCell = _randomNumberForCellIndex[cellIndex];
|
||||
hexGrid.SetCellTerrainImage(
|
||||
cellIndex,
|
||||
_imageForTerrainTracker
|
||||
.GetImageForTerrain(terrain, numberForCell, Model.Month));
|
||||
if (terrain.Modifier?.Fire != null) {
|
||||
hexGrid.SetCellModifierEffect(cellIndex, fireEffectPrefab);
|
||||
} else {
|
||||
hexGrid.SetCellModifierEffect(cellIndex, null);
|
||||
}
|
||||
|
||||
if (terrain.Modifier?.Bridge != null) {
|
||||
hexGrid.SetCellModifierImage(cellIndex, bridgeImage);
|
||||
}
|
||||
if (terrain.Modifier?.Bridge != null) {
|
||||
hexGrid.SetCellModifierImage(cellIndex, bridgeImage);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void HandleButton() {
|
||||
|
||||
@@ -82,7 +82,7 @@ public class ShardokGameModel {
|
||||
public int Month { get; private set; }
|
||||
private List<PlayerTotals> PlayerTotals { get; set; }
|
||||
|
||||
public List<CommandDescriptor> AvailableCommands { get; private set; }
|
||||
public List<CommandDescriptor> AvailableCommands { get; private set; } = new();
|
||||
public Action UpdateAction { get; set; }
|
||||
public List<PlayerWithHostility> players;
|
||||
public String GetPlayerName(PlayerId playerId) {
|
||||
@@ -217,6 +217,8 @@ public class ShardokGameModel {
|
||||
? newCommands.CurrentCommand.ToList()
|
||||
: newCommands.PreviewCommand.ToList();
|
||||
}
|
||||
// Trigger UI refresh after commands are updated so unit action indicators reflect new state
|
||||
UpdateAction?.Invoke();
|
||||
}
|
||||
|
||||
public bool HasTargetedCommand(Coords start, Coords target, List<CommandType> possibleTypes) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
+3
-3
@@ -138,9 +138,9 @@ namespace common {
|
||||
// Use TMP's calculated preferred height - this is the actual text size
|
||||
contentHeight = tmpText.preferredHeight;
|
||||
|
||||
// Ensure Content RectTransform is tall enough to contain the text
|
||||
// This is necessary for ScrollRect to scroll properly
|
||||
if (scrollRect.content.rect.height < contentHeight) {
|
||||
// Sync Content RectTransform height with actual text size.
|
||||
// Must both grow AND shrink to prevent blank space when scrolling to bottom.
|
||||
if (Mathf.Abs(scrollRect.content.rect.height - contentHeight) > 1f) {
|
||||
scrollRect.content.SetSizeWithCurrentAnchors(
|
||||
RectTransform.Axis.Vertical,
|
||||
contentHeight);
|
||||
|
||||
+33
-8
@@ -54,18 +54,43 @@ namespace common.GUIUtils {
|
||||
Type = type,
|
||||
Time = DateTime.UtcNow
|
||||
});
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
errorTextField.text = AllMessageText;
|
||||
panel.gameObject.SetActive(true);
|
||||
});
|
||||
|
||||
// Try to display immediately if possible, otherwise queue for later
|
||||
try {
|
||||
if (MainQueue.Q != null) {
|
||||
MainQueue.Q.Enqueue(ShowErrorPanel);
|
||||
} else {
|
||||
_pendingShow = true;
|
||||
}
|
||||
} catch {
|
||||
// MainQueue not ready yet - will show in Update
|
||||
_pendingShow = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool _pendingShow = false;
|
||||
|
||||
private void ShowErrorPanel() {
|
||||
if (errorTextField != null && panel != null) {
|
||||
errorTextField.text = AllMessageText;
|
||||
panel.gameObject.SetActive(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Register for log messages as early as possible
|
||||
void Awake() { Application.logMessageReceivedThreaded += HandleLog; }
|
||||
|
||||
void Update() {
|
||||
// Handle errors that occurred before MainQueue was ready
|
||||
if (_pendingShow) {
|
||||
_pendingShow = false;
|
||||
ShowErrorPanel();
|
||||
}
|
||||
}
|
||||
|
||||
// Use this for initialization
|
||||
void Start() {
|
||||
panel.gameObject.SetActive(false);
|
||||
Application.logMessageReceivedThreaded += HandleLog;
|
||||
}
|
||||
void Start() { panel.gameObject.SetActive(false); }
|
||||
|
||||
public void DismissClicked() {
|
||||
lock (_messages) {
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using UnityEngine;
|
||||
|
||||
namespace common {
|
||||
/// <summary>
|
||||
/// Non-blocking logger that queues messages and writes them on a dedicated background thread.
|
||||
/// This ensures that file I/O issues (antivirus, cloud sync, slow disk) never block the caller.
|
||||
/// </summary>
|
||||
public sealed class Logger : IDisposable {
|
||||
private readonly StreamWriter _sw;
|
||||
private readonly ConcurrentQueue<string> _messageQueue = new ConcurrentQueue<string>();
|
||||
private readonly Thread _writerThread;
|
||||
private readonly AutoResetEvent _messageAvailable = new AutoResetEvent(false);
|
||||
private volatile bool _disposed;
|
||||
private readonly string _name;
|
||||
|
||||
private static Dictionary<String, Logger> _loggers = new Dictionary<String, Logger>();
|
||||
private static readonly object _loggersLock = new object();
|
||||
|
||||
public static Logger GetLogger(string name) {
|
||||
if (!_loggers.ContainsKey(name)) { _loggers[name] = new Logger(name); }
|
||||
return _loggers[name];
|
||||
lock (_loggersLock) {
|
||||
if (!_loggers.ContainsKey(name)) { _loggers[name] = new Logger(name); }
|
||||
return _loggers[name];
|
||||
}
|
||||
}
|
||||
|
||||
private string CurrentTimeString() {
|
||||
private static string CurrentTimeString() {
|
||||
return DateTime.UtcNow.ToString("yyyyMMdd_HHmmss.fff");
|
||||
}
|
||||
|
||||
private Logger(string name) {
|
||||
_name = name;
|
||||
var directoryPath =
|
||||
Path.Combine(Application.persistentDataPath, "eagle0", "Resources", name);
|
||||
|
||||
@@ -26,17 +41,94 @@ namespace common {
|
||||
|
||||
var logFilePath = Path.Combine(directoryPath, $"{name}_{CurrentTimeString()}.txt");
|
||||
_sw = new StreamWriter(logFilePath, append: true);
|
||||
|
||||
// Start dedicated writer thread
|
||||
_writerThread = new Thread(WriterLoop) {
|
||||
Name = $"Logger-{name}",
|
||||
IsBackground = true // Won't prevent app exit
|
||||
};
|
||||
_writerThread.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queue a log message. This method never blocks on file I/O.
|
||||
/// </summary>
|
||||
public void LogLine(string line) {
|
||||
_sw.WriteLine(CurrentTimeString() + " " + line);
|
||||
_sw.Flush();
|
||||
if (_disposed) return;
|
||||
|
||||
// Format the message with timestamp immediately (captures accurate time)
|
||||
var formattedMessage = CurrentTimeString() + " " + line;
|
||||
_messageQueue.Enqueue(formattedMessage);
|
||||
_messageAvailable.Set(); // Signal the writer thread
|
||||
}
|
||||
|
||||
public void Close() { _sw.Close(); }
|
||||
/// <summary>
|
||||
/// Background thread that processes the message queue and writes to file.
|
||||
/// File I/O blocking only affects this thread, not the callers of LogLine().
|
||||
/// </summary>
|
||||
private void WriterLoop() {
|
||||
while (!_disposed) {
|
||||
try {
|
||||
// Wait for messages with timeout so we can check _disposed periodically
|
||||
_messageAvailable.WaitOne(1000);
|
||||
|
||||
// Process all queued messages
|
||||
while (_messageQueue.TryDequeue(out var message)) {
|
||||
try {
|
||||
_sw.WriteLine(message);
|
||||
} catch (Exception ex) {
|
||||
// File write failed - log to Console as fallback
|
||||
Console.WriteLine($"[Logger-{_name}] Write failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Flush after processing batch
|
||||
try {
|
||||
_sw.Flush();
|
||||
} catch (Exception ex) {
|
||||
Console.WriteLine($"[Logger-{_name}] Flush failed: {ex.Message}");
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
// Don't let any exception kill the writer thread
|
||||
Console.WriteLine($"[Logger-{_name}] WriterLoop error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Drain remaining messages on shutdown
|
||||
while (_messageQueue.TryDequeue(out var message)) {
|
||||
try {
|
||||
_sw.WriteLine(message);
|
||||
} catch { /* ignore on shutdown */
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
_sw.Flush();
|
||||
} catch { /* ignore on shutdown */
|
||||
}
|
||||
}
|
||||
|
||||
public void Close() { Dispose(); }
|
||||
|
||||
public void Dispose() {
|
||||
if (_sw != null) { _sw.Dispose(); }
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
// Signal writer thread to exit and wait for it
|
||||
_messageAvailable.Set();
|
||||
if (_writerThread != null && _writerThread.IsAlive) {
|
||||
_writerThread.Join(5000); // Wait up to 5 seconds for clean shutdown
|
||||
}
|
||||
|
||||
try {
|
||||
_sw?.Dispose();
|
||||
} catch { /* ignore */
|
||||
}
|
||||
|
||||
try {
|
||||
_messageAvailable?.Dispose();
|
||||
} catch { /* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Cysharp.Net.Http;
|
||||
using eagle;
|
||||
using Google.Protobuf;
|
||||
using Net.Eagle0.Eagle.Api;
|
||||
@@ -45,6 +46,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) {
|
||||
@@ -55,9 +60,17 @@ namespace common {
|
||||
relativeLocalPath);
|
||||
Directory.CreateDirectory(_localPath);
|
||||
|
||||
// Create a client that doesn't follow redirects automatically,
|
||||
// so we can retry each hop independently
|
||||
var handler = new HttpClientHandler { AllowAutoRedirect = false };
|
||||
// Use HTTP/2 handler for better connection reuse and multiplexing.
|
||||
// YetAnotherHttpHandler doesn't follow redirects automatically,
|
||||
// which is what we want so we can retry each hop independently.
|
||||
var handler = new YetAnotherHttpHandler {
|
||||
Http2Only = true,
|
||||
// Keep connections alive to improve reliability on poor networks
|
||||
Http2KeepAliveInterval = TimeSpan.FromSeconds(15),
|
||||
Http2KeepAliveTimeout = TimeSpan.FromSeconds(5),
|
||||
Http2KeepAliveWhileIdle = true,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(RequestTimeoutSeconds)
|
||||
};
|
||||
_noRedirectClient = new HttpClient(
|
||||
handler) { Timeout = TimeSpan.FromSeconds(RequestTimeoutSeconds) };
|
||||
// Store auth header to add per-request only for eagle0.net
|
||||
@@ -103,32 +116,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.
|
||||
@@ -181,9 +207,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]);
|
||||
}
|
||||
@@ -291,10 +317,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); }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"com.cysharp.yetanotherhttphandler": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.5.3",
|
||||
"com.cysharp.yetanotherhttphandler": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.11.4",
|
||||
"com.unity.2d.sprite": "1.0.0",
|
||||
"com.unity.2d.tilemap": "1.0.0",
|
||||
"com.unity.ai.navigation": "2.0.9",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"com.cysharp.yetanotherhttphandler": {
|
||||
"version": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.5.3",
|
||||
"version": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.11.4",
|
||||
"depth": 0,
|
||||
"source": "git",
|
||||
"dependencies": {},
|
||||
"hash": "ceb2f24f343c93d66c8a784a283be65b9627efe1"
|
||||
"hash": "5719afbc6b1d8cfa8729f24f0fe6a0e9dfa70f3a"
|
||||
},
|
||||
"com.unity.2d.sprite": {
|
||||
"version": "1.0.0",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,6 +3,10 @@ load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
|
||||
go_library(
|
||||
name = "admin_server_lib",
|
||||
srcs = ["admin_server.go"],
|
||||
embedsrcs = glob([
|
||||
"static/*",
|
||||
"templates/*.html",
|
||||
]),
|
||||
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/admin_server",
|
||||
visibility = ["//visibility:private"],
|
||||
deps = [
|
||||
@@ -15,5 +19,24 @@ go_library(
|
||||
go_binary(
|
||||
name = "admin_server",
|
||||
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
|
||||
go_binary(
|
||||
name = "admin_server_linux_amd64",
|
||||
embed = [":admin_server_lib"],
|
||||
goarch = "amd64",
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user