mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 11:55:42 +00:00
Compare commits
241
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 | ||
|
|
8e2575be50 | ||
|
|
913d927902 | ||
|
|
066381e24e | ||
|
|
ced52b0195 | ||
|
|
262ba36436 | ||
|
|
d16aa63c00 | ||
|
|
636bf8f9f3 | ||
|
|
b84df05953 | ||
|
|
dcf0261ac3 | ||
|
|
7afe4e788a | ||
|
|
2e4fc0d230 | ||
|
|
a5b608d18a | ||
|
|
e6519fef20 | ||
|
|
94d49e61d7 | ||
|
|
f3e2873f34 | ||
|
|
c74ddb8983 |
@@ -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
|
||||
+86
-17
@@ -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
|
||||
@@ -161,6 +212,13 @@ maven.install(
|
||||
# Other
|
||||
"org.reactivestreams:reactive-streams:1.0.4",
|
||||
"javax.xml.bind:jaxb-api:2.3.1",
|
||||
|
||||
# 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,
|
||||
@@ -169,7 +227,6 @@ maven.install(
|
||||
"https://repo1.maven.org/maven2",
|
||||
],
|
||||
)
|
||||
|
||||
use_repo(maven, "maven", "unpinned_maven")
|
||||
|
||||
#
|
||||
@@ -177,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"
|
||||
@@ -205,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
|
||||
#
|
||||
@@ -217,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?
|
||||
+111
-42
@@ -106,7 +106,7 @@ Action.execute()
|
||||
```
|
||||
Action.execute()
|
||||
→ ActionResultT
|
||||
→ ActionResultTApplier.applyActionResults()
|
||||
→ ActionResultApplier.applyActionResults()
|
||||
→ GameStateC
|
||||
|
||||
(Proto conversion only at boundaries)
|
||||
@@ -114,11 +114,11 @@ Action.execute()
|
||||
|
||||
### Key Files to Convert
|
||||
|
||||
**Tier 1 - Core Applier:**
|
||||
**Tier 1 - Core Applier:** ✅ **Complete**
|
||||
```
|
||||
src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultProtoApplierImpl.scala
|
||||
src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImpl.scala
|
||||
```
|
||||
Create `ActionResultApplier` that applies `ActionResultT` directly to Scala `GameState`.
|
||||
`ActionResultApplier` applies `ActionResultT` directly to Scala `GameState`. The legacy `ActionResultTApplierImpl` wraps it and converts to/from proto for callers that still need proto types.
|
||||
|
||||
**Tier 2 - RoundPhaseAdvancer:** ✅ **Complete**
|
||||
```
|
||||
@@ -182,38 +182,102 @@ Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versi
|
||||
|
||||
### ActionResultProto Consumer Inventory
|
||||
|
||||
| File | Usage | Target |
|
||||
| File | Usage | Status |
|
||||
|------|-------|--------|
|
||||
| `ActionResultTApplierImpl.scala` | Converts T→Proto, delegates to proto applier | Replace with `ActionResultApplier` |
|
||||
| `RoundPhaseAdvancer.scala` | ~~20 converter calls~~ | ✅ **Complete** - uses Scala GameState |
|
||||
| `RandomStateTSequencer.scala` | Converts T→Proto internally | Thread Scala GameState throughout |
|
||||
| `RandomStateProtoSequencer.scala` | Returns `Vector[ActionResultProto]` | Evaluate if still needed |
|
||||
| `VigorXPApplier.scala` | Wraps proto results | Convert to work with T |
|
||||
| `ResolveBattleAction.scala` | 2 converter calls | Convert after dependencies |
|
||||
| `PerformForcedTurnBackAction.scala` | 1 converter call | Convert after dependencies |
|
||||
| `EndFreeForAllDecisionPhaseAction.scala` | ~~fromProtoState~~ | **Complete** - now takes Scala GameState |
|
||||
| `EndBattleRequestPhaseAction.scala` | ~~fromProtoState~~ | **Complete** - now takes Scala GameState |
|
||||
| `EndDefenseDecisionPhaseAction.scala` | ~~fromProtoState~~ | **Complete** - now takes Scala GameState |
|
||||
| `EndPleaseRecruitMePhaseAction.scala` | ~~fromProtoState~~ | **Complete** - now takes Scala GameState |
|
||||
| `InMemoryHistory.scala` | Stores proto results | Vend Scala types, remove proto entirely |
|
||||
| `PersistedHistory.scala` | Stores proto results | Vend Scala types, convert internally for disk |
|
||||
| `ActionResultApplierImpl.scala` | Applies ActionResultT to Scala GameState | ✅ **Complete** |
|
||||
| `ActionResultTApplierImpl.scala` | Legacy wrapper - converts to/from proto | Keep until all callers migrated |
|
||||
| `RoundPhaseAdvancer.scala` | Uses Scala GameState | ✅ **Complete** |
|
||||
| `RandomStateSequencer.scala` | Threads Scala GameState | ✅ **Complete** |
|
||||
| `VigorXPApplier.scala` | Has both proto and Scala methods | Scala method exists, delete proto method when unused |
|
||||
| `PerformForcedTurnBackAction.scala` | Fully protoless | ✅ **Complete** |
|
||||
| `ResolveBattleAction.scala` | Heavy proto usage | Blocked by proto dependencies |
|
||||
| `InMemoryHistory.scala` | Stores proto results | Pending - vend Scala types |
|
||||
| `PersistedHistory.scala` | Stores proto results | Pending - vend Scala types, convert for disk |
|
||||
| `GameController.scala` | Uses proto for client communication | Keep proto (gRPC boundary) |
|
||||
|
||||
### Estimated Effort
|
||||
### Remaining Proto Usage in Actions
|
||||
|
||||
| Component | Lines | Complexity |
|
||||
|-----------|-------|------------|
|
||||
| `ActionResultApplier` | ~900 | High (port of proto applier) |
|
||||
| `RandomStateTSequencer` refactor | ~150 | Medium |
|
||||
| `RoundPhaseAdvancer` updates | ~100 | Medium |
|
||||
| History API updates | ~100 | Low |
|
||||
| Action/utility updates | ~200 | Low |
|
||||
| **Total** | **~1450** | |
|
||||
**Progress: 47 of 52 action files (90%) are fully protoless.**
|
||||
|
||||
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 | 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
|
||||
- [ ] `ActionResultApplier` created and tested
|
||||
- [ ] `RandomStateTSequencer` threads Scala GameState throughout
|
||||
- [ ] `RoundPhaseAdvancer` uses T-types internally
|
||||
- [x] `ActionResultApplier` created and tested
|
||||
- [x] `RandomStateSequencer` threads Scala GameState throughout
|
||||
- [x] `RoundPhaseAdvancer` uses T-types internally
|
||||
- [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
|
||||
- [ ] All tests pass
|
||||
@@ -236,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
+154
-12
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
|
||||
"__INPUT_ARTIFACTS_HASH": 571423113,
|
||||
"__RESOLVED_ARTIFACTS_HASH": 438039003,
|
||||
"__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",
|
||||
"io.netty:netty-buffer:4.1.110.Final": "io.netty:netty-buffer:4.1.112.Final",
|
||||
"io.netty:netty-codec-http2:4.1.110.Final": "io.netty:netty-codec-http2:4.1.112.Final",
|
||||
"io.netty:netty-codec-http:4.1.110.Final": "io.netty:netty-codec-http:4.1.112.Final",
|
||||
@@ -47,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"
|
||||
@@ -155,6 +162,24 @@
|
||||
},
|
||||
"version": "1.4.2"
|
||||
},
|
||||
"com.nimbusds:nimbus-jose-jwt": {
|
||||
"shasums": {
|
||||
"jar": "12ae4a3a260095d7aeba2adea7ae396e8b9570db8b7b409e09a824c219cc0444"
|
||||
},
|
||||
"version": "9.37.3"
|
||||
},
|
||||
"com.squareup.okhttp3:okhttp": {
|
||||
"shasums": {
|
||||
"jar": "b1050081b14bb7a3a7e55a4d3ef01b5dcfabc453b4573a4fc019767191d5f4e0"
|
||||
},
|
||||
"version": "4.12.0"
|
||||
},
|
||||
"com.squareup.okhttp3:okhttp-sse": {
|
||||
"shasums": {
|
||||
"jar": "bff4fbcaef7aac2d910d4ff46dafaa4e6d15da127df6bac97216da46943a7d4c"
|
||||
},
|
||||
"version": "4.12.0"
|
||||
},
|
||||
"com.squareup.okhttp:okhttp": {
|
||||
"shasums": {
|
||||
"jar": "88ac9fd1bb51f82bcc664cc1eb9c225c90dc4389d660231b4cc737bebfe7d0aa"
|
||||
@@ -163,9 +188,15 @@
|
||||
},
|
||||
"com.squareup.okio:okio": {
|
||||
"shasums": {
|
||||
"jar": "a27f091d34aa452e37227e2cfa85809f29012a8ef2501a9b5a125a978e4fcbc1"
|
||||
"jar": "8e63292e5c53bb93c4a6b0c213e79f15990fed250c1340f1c343880e1c9c39b5"
|
||||
},
|
||||
"version": "2.10.0"
|
||||
"version": "3.6.0"
|
||||
},
|
||||
"com.squareup.okio:okio-jvm": {
|
||||
"shasums": {
|
||||
"jar": "67543f0736fc422ae927ed0e504b98bc5e269fda0d3500579337cb713da28412"
|
||||
},
|
||||
"version": "3.6.0"
|
||||
},
|
||||
"com.thesamet.scalapb:compilerplugin_3": {
|
||||
"shasums": {
|
||||
@@ -444,15 +475,27 @@
|
||||
},
|
||||
"org.jetbrains.kotlin:kotlin-stdlib": {
|
||||
"shasums": {
|
||||
"jar": "b8ab1da5cdc89cb084d41e1f28f20a42bd431538642a5741c52bbfae3fa3e656"
|
||||
"jar": "55e989c512b80907799f854309f3bc7782c5b3d13932442d0379d5c472711504"
|
||||
},
|
||||
"version": "1.4.20"
|
||||
"version": "1.9.10"
|
||||
},
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-common": {
|
||||
"shasums": {
|
||||
"jar": "a7112c9b3cefee418286c9c9372f7af992bd1e6e030691d52f60cb36dbec8320"
|
||||
"jar": "cde3341ba18a2ba262b0b7cf6c55b20c90e8d434e42c9a13e6a3f770db965a88"
|
||||
},
|
||||
"version": "1.4.20"
|
||||
"version": "1.9.10"
|
||||
},
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk7": {
|
||||
"shasums": {
|
||||
"jar": "ac6361bf9ad1ed382c2103d9712c47cdec166232b4903ed596e8876b0681c9b7"
|
||||
},
|
||||
"version": "1.9.10"
|
||||
},
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8": {
|
||||
"shasums": {
|
||||
"jar": "a4c74d94d64ce1abe53760fe0389dd941f6fc558d0dab35e47c085a11ec80f28"
|
||||
},
|
||||
"version": "1.9.10"
|
||||
},
|
||||
"org.jetbrains:annotations": {
|
||||
"shasums": {
|
||||
@@ -779,12 +822,26 @@
|
||||
"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"
|
||||
],
|
||||
"com.squareup.okhttp3:okhttp-sse": [
|
||||
"com.squareup.okhttp3:okhttp",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
||||
],
|
||||
"com.squareup.okhttp:okhttp": [
|
||||
"com.squareup.okio:okio"
|
||||
],
|
||||
"com.squareup.okio:okio": [
|
||||
"org.jetbrains.kotlin:kotlin-stdlib",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-common"
|
||||
"com.squareup.okio:okio-jvm"
|
||||
],
|
||||
"com.squareup.okio:okio-jvm": [
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-common",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
||||
],
|
||||
"com.thesamet.scalapb:compilerplugin_3": [
|
||||
"com.google.protobuf:protobuf-java",
|
||||
@@ -992,6 +1049,13 @@
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-common",
|
||||
"org.jetbrains:annotations"
|
||||
],
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk7": [
|
||||
"org.jetbrains.kotlin:kotlin-stdlib"
|
||||
],
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8": [
|
||||
"org.jetbrains.kotlin:kotlin-stdlib",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk7"
|
||||
],
|
||||
"org.json4s:json4s-ast_3": [
|
||||
"org.scala-lang:scala3-library_3"
|
||||
],
|
||||
@@ -1306,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"
|
||||
],
|
||||
@@ -1451,6 +1518,61 @@
|
||||
"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",
|
||||
"okhttp3.internal.authenticator",
|
||||
"okhttp3.internal.cache",
|
||||
"okhttp3.internal.cache2",
|
||||
"okhttp3.internal.concurrent",
|
||||
"okhttp3.internal.connection",
|
||||
"okhttp3.internal.http",
|
||||
"okhttp3.internal.http1",
|
||||
"okhttp3.internal.http2",
|
||||
"okhttp3.internal.io",
|
||||
"okhttp3.internal.platform",
|
||||
"okhttp3.internal.platform.android",
|
||||
"okhttp3.internal.proxy",
|
||||
"okhttp3.internal.publicsuffix",
|
||||
"okhttp3.internal.tls",
|
||||
"okhttp3.internal.ws"
|
||||
],
|
||||
"com.squareup.okhttp3:okhttp-sse": [
|
||||
"okhttp3.internal.sse",
|
||||
"okhttp3.sse"
|
||||
],
|
||||
"com.squareup.okhttp:okhttp": [
|
||||
"com.squareup.okhttp",
|
||||
"com.squareup.okhttp.internal",
|
||||
@@ -1459,7 +1581,7 @@
|
||||
"com.squareup.okhttp.internal.io",
|
||||
"com.squareup.okhttp.internal.tls"
|
||||
],
|
||||
"com.squareup.okio:okio": [
|
||||
"com.squareup.okio:okio-jvm": [
|
||||
"okio",
|
||||
"okio.internal"
|
||||
],
|
||||
@@ -1814,6 +1936,7 @@
|
||||
"kotlin.annotation",
|
||||
"kotlin.collections",
|
||||
"kotlin.collections.builders",
|
||||
"kotlin.collections.jdk8",
|
||||
"kotlin.collections.unsigned",
|
||||
"kotlin.comparisons",
|
||||
"kotlin.concurrent",
|
||||
@@ -1822,24 +1945,36 @@
|
||||
"kotlin.coroutines.cancellation",
|
||||
"kotlin.coroutines.intrinsics",
|
||||
"kotlin.coroutines.jvm.internal",
|
||||
"kotlin.enums",
|
||||
"kotlin.experimental",
|
||||
"kotlin.internal",
|
||||
"kotlin.internal.jdk7",
|
||||
"kotlin.internal.jdk8",
|
||||
"kotlin.io",
|
||||
"kotlin.io.encoding",
|
||||
"kotlin.io.path",
|
||||
"kotlin.jdk7",
|
||||
"kotlin.js",
|
||||
"kotlin.jvm",
|
||||
"kotlin.jvm.functions",
|
||||
"kotlin.jvm.internal",
|
||||
"kotlin.jvm.internal.markers",
|
||||
"kotlin.jvm.internal.unsafe",
|
||||
"kotlin.jvm.jdk8",
|
||||
"kotlin.jvm.optionals",
|
||||
"kotlin.math",
|
||||
"kotlin.properties",
|
||||
"kotlin.random",
|
||||
"kotlin.random.jdk8",
|
||||
"kotlin.ranges",
|
||||
"kotlin.reflect",
|
||||
"kotlin.sequences",
|
||||
"kotlin.streams.jdk8",
|
||||
"kotlin.system",
|
||||
"kotlin.text",
|
||||
"kotlin.time"
|
||||
"kotlin.text.jdk8",
|
||||
"kotlin.time",
|
||||
"kotlin.time.jdk8"
|
||||
],
|
||||
"org.jetbrains:annotations": [
|
||||
"org.intellij.lang.annotations",
|
||||
@@ -2252,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",
|
||||
@@ -2270,8 +2406,12 @@
|
||||
"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",
|
||||
"com.squareup.okio:okio",
|
||||
"com.squareup.okio:okio-jvm",
|
||||
"com.thesamet.scalapb:compilerplugin_3",
|
||||
"com.thesamet.scalapb:lenses_3",
|
||||
"com.thesamet.scalapb:protoc-bridge_2.13",
|
||||
@@ -2324,6 +2464,8 @@
|
||||
"org.hamcrest:hamcrest-core",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-common",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk7",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8",
|
||||
"org.jetbrains:annotations",
|
||||
"org.json4s:json4s-ast_3",
|
||||
"org.json4s:json4s-core_3",
|
||||
|
||||
@@ -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,12 +148,15 @@
|
||||
<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" />
|
||||
<Compile Include="Assets/common/GUIUtils/AutoScrollingText.cs" />
|
||||
<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" />
|
||||
@@ -257,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" />
|
||||
@@ -362,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(); }
|
||||
}
|
||||
|
||||
+70
-16
@@ -19,35 +19,86 @@ namespace eagle {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe provider for streaming LLM text content.
|
||||
///
|
||||
/// HandleNewStreamingText can be called from any thread (e.g., gRPC thread).
|
||||
/// ProcessPendingUpdates must be called from the main thread (once per frame)
|
||||
/// to notify listeners of changes.
|
||||
/// </summary>
|
||||
public class ClientTextProvider {
|
||||
public static readonly ClientTextProvider Provider = new();
|
||||
|
||||
// Lock for thread-safe access to text dictionary
|
||||
// Using a lock instead of ConcurrentDictionary because HandleNewStreamingText
|
||||
// does a read-modify-write that must be atomic
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<String, TextEntry> _streamingTexts = new();
|
||||
|
||||
// Track which text IDs have pending updates
|
||||
private readonly HashSet<String> _pendingUpdates = new();
|
||||
|
||||
// Listeners are only added/removed from main thread
|
||||
private readonly HashSet<IClientTextListener> _listeners = new();
|
||||
|
||||
public void Clear() { _streamingTexts.Clear(); }
|
||||
|
||||
public Dictionary<String, TextEntry> All() {
|
||||
return new Dictionary<string, TextEntry>(_streamingTexts);
|
||||
public void Clear() {
|
||||
lock (_lock) {
|
||||
_streamingTexts.Clear();
|
||||
_pendingUpdates.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<String, TextEntry> All() {
|
||||
lock (_lock) { return new Dictionary<string, TextEntry>(_streamingTexts); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the text dictionary. Thread-safe - can be called from any thread.
|
||||
/// Listeners are NOT notified here; call ProcessPendingUpdates from main thread.
|
||||
/// </summary>
|
||||
public String
|
||||
HandleNewStreamingText(String llmId, String newText, Int32 knownByteCount, bool completed) {
|
||||
var currentText = "";
|
||||
if (_streamingTexts.TryGetValue(llmId, out var entry)) { currentText = entry.Text; }
|
||||
lock (_lock) {
|
||||
var currentText = "";
|
||||
if (_streamingTexts.TryGetValue(llmId, out var entry)) { currentText = entry.Text; }
|
||||
|
||||
var currentTextBytes = Encoding.UTF8.GetBytes(currentText);
|
||||
var truncatedBytes = currentTextBytes.Take(knownByteCount).ToArray();
|
||||
var currentTextBytes = Encoding.UTF8.GetBytes(currentText);
|
||||
var truncatedBytes = currentTextBytes.Take(knownByteCount).ToArray();
|
||||
|
||||
var updatedText = Encoding.UTF8.GetString(truncatedBytes) + newText;
|
||||
_streamingTexts[llmId] = new TextEntry(updatedText, completed);
|
||||
var updatedText = Encoding.UTF8.GetString(truncatedBytes) + newText;
|
||||
_streamingTexts[llmId] = new TextEntry(updatedText, completed);
|
||||
|
||||
_listeners.Where(x => x.TextId() == llmId)
|
||||
.ToList()
|
||||
.ForEach(x => x.OnTextUpdate(updatedText, completed));
|
||||
// Mark this text ID as having pending updates
|
||||
_pendingUpdates.Add(llmId);
|
||||
|
||||
return updatedText;
|
||||
return updatedText;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process pending updates and notify listeners. Must be called from main thread.
|
||||
/// This batches multiple updates to the same text ID into a single notification per frame.
|
||||
/// </summary>
|
||||
public void ProcessPendingUpdates() {
|
||||
List<(String llmId, TextEntry entry)> updates;
|
||||
|
||||
lock (_lock) {
|
||||
if (_pendingUpdates.Count == 0) return;
|
||||
|
||||
// Collect pending updates and their current values
|
||||
updates = _pendingUpdates.Where(id => _streamingTexts.ContainsKey(id))
|
||||
.Select(id => (id, _streamingTexts[id]))
|
||||
.ToList();
|
||||
|
||||
_pendingUpdates.Clear();
|
||||
}
|
||||
|
||||
// Notify listeners outside the lock to avoid potential deadlocks
|
||||
foreach (var (llmId, textEntry) in updates) {
|
||||
foreach (var listener in _listeners.Where(x => x.TextId() == llmId)) {
|
||||
listener.OnTextUpdate(textEntry.Text, textEntry.Completed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public TextEntry GetTextEntry(string streamId) {
|
||||
@@ -57,7 +108,10 @@ namespace eagle {
|
||||
return new TextEntry(text, true);
|
||||
}
|
||||
|
||||
return _streamingTexts.GetValueOrDefault(streamId, null);
|
||||
lock (_lock) {
|
||||
_streamingTexts.TryGetValue(streamId, out var entry);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
public void AddListener(IClientTextListener listener) {
|
||||
@@ -70,4 +124,4 @@ namespace eagle {
|
||||
|
||||
public void RemoveListener(IClientTextListener listener) { _listeners.Remove(listener); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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})";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+49
-37
@@ -224,13 +224,14 @@ namespace eagle {
|
||||
tp => tp.TypeId == battalionTypeId && tp.MeetsRequirements);
|
||||
}
|
||||
|
||||
private void MaybeActivateRow(EventBasedTable table, BattalionTypeId battalionTypeId) {
|
||||
private void MaybeActivateRow(
|
||||
EventBasedTable table,
|
||||
BattalionTypeId battalionTypeId,
|
||||
int[] extraTroopsByType) {
|
||||
var parent = table.gameObject.transform.parent;
|
||||
|
||||
var allowed = TypeIsAllowed(battalionTypeId) ||
|
||||
extraTroops.Where(tfb => tfb.type == battalionTypeId)
|
||||
.Select(tfb => tfb.count)
|
||||
.Sum() > 0;
|
||||
var allowed =
|
||||
TypeIsAllowed(battalionTypeId) || extraTroopsByType[(int)battalionTypeId] > 0;
|
||||
|
||||
table.gameObject.GetComponent<OrganizeExtrasTable>().Set(
|
||||
allowed,
|
||||
@@ -251,12 +252,12 @@ namespace eagle {
|
||||
parent.GetComponentInChildren<RawImage>().color = color;
|
||||
}
|
||||
|
||||
private void MaybeActivateRows() {
|
||||
MaybeActivateRow(lightInfantryTable, BattalionTypeId.LightInfantry);
|
||||
MaybeActivateRow(heavyInfantryTable, BattalionTypeId.HeavyInfantry);
|
||||
MaybeActivateRow(longbowmenTable, BattalionTypeId.Longbowmen);
|
||||
MaybeActivateRow(dragoonsTable, BattalionTypeId.LightCavalry);
|
||||
MaybeActivateRow(knightsTable, BattalionTypeId.HeavyCavalry);
|
||||
private void MaybeActivateRows(int[] extraTroopsByType) {
|
||||
MaybeActivateRow(lightInfantryTable, BattalionTypeId.LightInfantry, extraTroopsByType);
|
||||
MaybeActivateRow(heavyInfantryTable, BattalionTypeId.HeavyInfantry, extraTroopsByType);
|
||||
MaybeActivateRow(longbowmenTable, BattalionTypeId.Longbowmen, extraTroopsByType);
|
||||
MaybeActivateRow(dragoonsTable, BattalionTypeId.LightCavalry, extraTroopsByType);
|
||||
MaybeActivateRow(knightsTable, BattalionTypeId.HeavyCavalry, extraTroopsByType);
|
||||
}
|
||||
|
||||
protected override void SetUpUI() {
|
||||
@@ -330,7 +331,8 @@ namespace eagle {
|
||||
} else
|
||||
return false;
|
||||
|
||||
eb.Update(existingBattalions);
|
||||
// Note: We don't call Update() here - the caller (UpdateTable or MaxClickedImpl)
|
||||
// will call it when needed. This avoids redundant recalculations.
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -367,7 +369,6 @@ namespace eagle {
|
||||
}
|
||||
// Remove original troops
|
||||
else {
|
||||
var updated = eb.Update(existingBattalions);
|
||||
var availableToRemove = eb.Original.Size - eb.troopsRemoved;
|
||||
var newlyRemovedCount = Math.Min(KeyModifiedAmount.Amount(), availableToRemove);
|
||||
|
||||
@@ -464,7 +465,8 @@ namespace eagle {
|
||||
} else
|
||||
return false;
|
||||
|
||||
newB.Update(existingBattalions);
|
||||
// Note: We don't call Update() here - the caller (UpdateTable or MaxClickedImpl)
|
||||
// will call it when needed. This avoids redundant recalculations.
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -639,8 +641,6 @@ namespace eagle {
|
||||
}
|
||||
|
||||
public void UpdateTable() {
|
||||
battalionsTable.RowCount = 0;
|
||||
|
||||
maxAllButton.gameObject.SetActive(false);
|
||||
mergeButton.gameObject.SetActive(false);
|
||||
|
||||
@@ -653,23 +653,30 @@ namespace eagle {
|
||||
{ BattalionTypeId.Longbowmen, 0 }
|
||||
};
|
||||
|
||||
maxAllButton.gameObject.SetActive(false);
|
||||
foreach (var eb in existingBattalions) {
|
||||
if (eb.dismissed) continue;
|
||||
// Cache extra troop counts by type to avoid repeated LINQ queries
|
||||
// Use array indexed by enum value for O(1) access without hashing
|
||||
var battalionTypeCount = Enum.GetValues(typeof(BattalionTypeId)).Length;
|
||||
var extraTroopsByType = new int[battalionTypeCount];
|
||||
foreach (var et in extraTroops) { extraTroopsByType[(int)et.type] += et.count; }
|
||||
|
||||
var newRow = battalionsTable.AddRowWithComponent<OrganizeTroopsTableRow>();
|
||||
// Count total rows needed and set RowCount to reuse existing rows
|
||||
var activeExisting = existingBattalions.Where(eb => !eb.dismissed).ToList();
|
||||
var totalRows = activeExisting.Count + newBattalions.Count;
|
||||
battalionsTable.RowCount = totalRows;
|
||||
|
||||
int rowIndex = 0;
|
||||
foreach (var eb in activeExisting) {
|
||||
var row = battalionsTable.ComponentAt<OrganizeTroopsTableRow>(rowIndex);
|
||||
eb.Update(existingBattalions);
|
||||
newRow.BattalionInfo = eb;
|
||||
row.BattalionInfo = eb;
|
||||
|
||||
newRow.PlusButtonClickedCallback = () => PlusClicked(eb);
|
||||
newRow.MinusButtonClickedCallback = () => MinusClicked(eb);
|
||||
newRow.MaxButtonClickedCallback = () => MaxClicked(eb);
|
||||
newRow.DismissButtonClickedCallback = () => DismissClicked(eb);
|
||||
row.PlusButtonClickedCallback = () => PlusClicked(eb);
|
||||
row.MinusButtonClickedCallback = () => MinusClicked(eb);
|
||||
row.MaxButtonClickedCallback = () => MaxClicked(eb);
|
||||
row.DismissButtonClickedCallback = () => DismissClicked(eb);
|
||||
|
||||
bool canAugment =
|
||||
TypeIsAllowed(eb.TypeId) ||
|
||||
extraTroops.Where(tfb => tfb.type == eb.TypeId).Sum(tfb => tfb.count) > 0;
|
||||
newRow.CanAugment = canAugment;
|
||||
bool canAugment = TypeIsAllowed(eb.TypeId) || extraTroopsByType[(int)eb.TypeId] > 0;
|
||||
row.CanAugment = canAugment;
|
||||
|
||||
if (eb.Count < eb.Capacity) {
|
||||
// Enable MaxAll button if we could add new troops to this battalion type
|
||||
@@ -681,18 +688,19 @@ namespace eagle {
|
||||
mergeButton.gameObject.SetActive(true);
|
||||
}
|
||||
}
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
foreach (var newB in newBattalions) {
|
||||
var newRow = battalionsTable.AddRowWithComponent<OrganizeTroopsTableRow>();
|
||||
var row = battalionsTable.ComponentAt<OrganizeTroopsTableRow>(rowIndex);
|
||||
|
||||
newB.Update(existingBattalions);
|
||||
newRow.BattalionInfo = newB;
|
||||
row.BattalionInfo = newB;
|
||||
|
||||
newRow.PlusButtonClickedCallback = () => PlusClicked(newB);
|
||||
newRow.MinusButtonClickedCallback = () => MinusClicked(newB);
|
||||
newRow.MaxButtonClickedCallback = () => MaxClicked(newB);
|
||||
newRow.DismissButtonClickedCallback = () => DismissClicked(newB);
|
||||
row.PlusButtonClickedCallback = () => PlusClicked(newB);
|
||||
row.MinusButtonClickedCallback = () => MinusClicked(newB);
|
||||
row.MaxButtonClickedCallback = () => MaxClicked(newB);
|
||||
row.DismissButtonClickedCallback = () => DismissClicked(newB);
|
||||
|
||||
if (newB.Count < newB.Capacity) {
|
||||
maxAllButton.gameObject.SetActive(true);
|
||||
@@ -701,6 +709,7 @@ namespace eagle {
|
||||
mergeButton.gameObject.SetActive(true);
|
||||
}
|
||||
}
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
lightInfantryTable.RowCount = 0;
|
||||
@@ -739,15 +748,18 @@ namespace eagle {
|
||||
if (!sufficient) { _disabledReason = "Not enough gold"; }
|
||||
|
||||
// Also check that something has changed
|
||||
// Check newBattalion fields directly instead of calling Update() which is expensive
|
||||
bool somethingChanged =
|
||||
(newBattalions.Exists(b => b.Update(existingBattalions).Size > 0) ||
|
||||
(newBattalions.Exists(
|
||||
b => b.newBattalion.NewTroops > 0 ||
|
||||
b.newBattalion.TroopsFromOtherBattalion.Count > 0) ||
|
||||
existingBattalions.Exists(eb => eb.changed != null || eb.troopsRemoved > 0));
|
||||
if (!somethingChanged) { _disabledReason = "No battalions have changed"; }
|
||||
|
||||
_enableCommit = sufficient && somethingChanged;
|
||||
resetAllButton.gameObject.SetActive(somethingChanged);
|
||||
|
||||
MaybeActivateRows();
|
||||
MaybeActivateRows(extraTroopsByType);
|
||||
}
|
||||
|
||||
public override AvailableCommand.SealedValueOneofCase CommandType =>
|
||||
|
||||
+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:
|
||||
+15
-9
@@ -188,6 +188,9 @@ namespace eagle {
|
||||
}
|
||||
|
||||
void Update() {
|
||||
// Process batched streaming text updates (thread-safe, once per frame)
|
||||
ClientTextProvider.Provider.ProcessPendingUpdates();
|
||||
|
||||
ArrangeLayout();
|
||||
if (_newModel != null) { SwapModel(); }
|
||||
|
||||
@@ -226,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
|
||||
@@ -241,7 +243,8 @@ namespace eagle {
|
||||
public void SetUpGame(
|
||||
long gameId,
|
||||
int? playerId,
|
||||
PersistentClientConnection persistentClientConnection) {
|
||||
PersistentClientConnection persistentClientConnection,
|
||||
string environmentName = null) {
|
||||
ModelUpdater = new GameModelUpdater(
|
||||
gameId,
|
||||
playerId,
|
||||
@@ -267,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:
|
||||
+390
-87
@@ -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,19 +265,19 @@ 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}");
|
||||
|
||||
// Clear resync flags ONLY after successful acknowledgment
|
||||
if (subscriber is GameModelUpdater updater) {
|
||||
foreach (var status in shardokStatuses.Where(s => s.requestFullResync)) {
|
||||
updater.ClearShardokResyncFlag(status.shardokGameId);
|
||||
}
|
||||
}
|
||||
// Note: Shardok resync flags are cleared in EagleGameModel.HandleOneGameUpdate
|
||||
// AFTER updates are actually received, not here. This ensures that if the
|
||||
// connection drops between acknowledgment and update delivery, the resync
|
||||
// will be requested again on the next reconnect.
|
||||
|
||||
return true;
|
||||
} else {
|
||||
LogFlow($"SUBSCRIBE_ACK game={gameId} success=false error={ack.ErrorMessage}");
|
||||
LogConnectionEvent(
|
||||
"subscribe_ack_failed",
|
||||
$"game={gameId}, error={ack.ErrorMessage}");
|
||||
@@ -259,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");
|
||||
|
||||
@@ -309,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;
|
||||
@@ -321,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");
|
||||
@@ -344,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");
|
||||
|
||||
@@ -378,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; }
|
||||
}
|
||||
@@ -415,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;
|
||||
@@ -444,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,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();
|
||||
@@ -516,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) {
|
||||
@@ -573,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
|
||||
@@ -656,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.
|
||||
@@ -666,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) {
|
||||
@@ -677,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) {
|
||||
@@ -686,6 +835,9 @@ namespace eagle {
|
||||
} else {
|
||||
throw;
|
||||
}
|
||||
} catch (OperationCanceledException) {
|
||||
// Timeout was triggered
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -697,24 +849,86 @@ 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); }
|
||||
});
|
||||
break;
|
||||
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.StreamingTextResponse:
|
||||
// Handle streaming text directly on gRPC thread - dictionary is thread-safe.
|
||||
// Listeners are notified later via ProcessPendingUpdates() on main thread.
|
||||
var str = gameUpdate.StreamingTextResponse;
|
||||
if (str != null) {
|
||||
ClientTextProvider.Provider.HandleNewStreamingText(
|
||||
str.LlmIdentifier,
|
||||
str.NewText,
|
||||
str.StartingByteCount,
|
||||
str.Completed);
|
||||
}
|
||||
// No MainQueue enqueue needed - ProcessPendingUpdates handles listener
|
||||
// notification
|
||||
break;
|
||||
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
|
||||
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) {
|
||||
@@ -737,6 +951,7 @@ namespace eagle {
|
||||
}
|
||||
|
||||
private async void HandleStreamingCall() {
|
||||
LogFlow("HandleStreamingCall STARTED");
|
||||
try {
|
||||
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc;
|
||||
CancellationToken threadToken;
|
||||
@@ -852,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) {
|
||||
@@ -918,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;
|
||||
@@ -951,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;
|
||||
}
|
||||
|
||||
@@ -964,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;
|
||||
@@ -990,7 +1238,7 @@ namespace eagle {
|
||||
if (toCancel != null) {
|
||||
toCancel.Dispose();
|
||||
lock (this) { _streamingCall = null; }
|
||||
Task.Run(() => Connect());
|
||||
RunConnectAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -999,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;
|
||||
}
|
||||
|
||||
@@ -1012,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 {
|
||||
@@ -1043,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
@@ -24,11 +24,20 @@ public class MainQueue : MonoBehaviour {
|
||||
|
||||
// Update is called once per frame
|
||||
void Update() {
|
||||
int actionsProcessed = 0;
|
||||
int queueDepthBefore;
|
||||
|
||||
lock (_actionQueue) { queueDepthBefore = _actionQueue.Count; }
|
||||
|
||||
// Fast path: skip processing if queue is empty
|
||||
if (queueDepthBefore == 0) {
|
||||
lock (_nextUpdateQueue) {
|
||||
if (_nextUpdateQueue.Count > 0) {
|
||||
foreach (Action action in _nextUpdateQueue) { Enqueue(action); }
|
||||
_nextUpdateQueue.Clear();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Log when queue has built up (e.g., after resuming from background)
|
||||
if (queueDepthBefore > 100 && queueDepthBefore != _lastLoggedQueueDepth) {
|
||||
Debug.Log($"[MainQueue] Processing backlog: {queueDepthBefore} actions queued");
|
||||
@@ -45,10 +54,7 @@ public class MainQueue : MonoBehaviour {
|
||||
if (_actionQueue.Count > 0) { possibleAction = _actionQueue.Dequeue(); }
|
||||
}
|
||||
|
||||
if (possibleAction != null) {
|
||||
possibleAction.Invoke();
|
||||
actionsProcessed++;
|
||||
}
|
||||
if (possibleAction != null) { possibleAction.Invoke(); }
|
||||
} while (possibleAction != null && stopwatch.ElapsedMilliseconds < MaxMillisecondsPerFrame);
|
||||
|
||||
lock (_nextUpdateQueue) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using common;
|
||||
using eagle;
|
||||
using Net.Eagle0.Shardok.Api;
|
||||
using TMPro;
|
||||
@@ -27,6 +28,9 @@ public class SettingsPanelController : MonoBehaviour {
|
||||
public Toggle tooltipHugsCursorToggle;
|
||||
public HoveringTooltip hoveringTooltip;
|
||||
|
||||
public Slider autoScrollSpeedSlider;
|
||||
public TMP_Text autoScrollSpeedLabel;
|
||||
|
||||
public HexGrid hexGrid;
|
||||
|
||||
private bool _active = false;
|
||||
@@ -54,6 +58,11 @@ public class SettingsPanelController : MonoBehaviour {
|
||||
|
||||
tooltipHugsCursorToggle.isOn = PlayerPrefs.GetInt(TooltipHugsCursorKey, 0) == 1;
|
||||
hoveringTooltip.HugsCursor = tooltipHugsCursorToggle.isOn;
|
||||
|
||||
if (autoScrollSpeedSlider != null) {
|
||||
autoScrollSpeedSlider.value = AutoScrollingText.GlobalScrollSpeedMultiplier;
|
||||
UpdateAutoScrollSpeedLabel(autoScrollSpeedSlider.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Update is called once per frame
|
||||
@@ -107,6 +116,21 @@ public class SettingsPanelController : MonoBehaviour {
|
||||
hoveringTooltip.HugsCursor = val;
|
||||
}
|
||||
|
||||
public void OnAutoScrollSpeedSliderChange(float val) {
|
||||
AutoScrollingText.GlobalScrollSpeedMultiplier = val;
|
||||
UpdateAutoScrollSpeedLabel(val);
|
||||
}
|
||||
|
||||
private void UpdateAutoScrollSpeedLabel(float val) {
|
||||
if (autoScrollSpeedLabel != null) {
|
||||
if (val < 0.01f) {
|
||||
autoScrollSpeedLabel.text = "Paused";
|
||||
} else {
|
||||
autoScrollSpeedLabel.text = $"{val:F2}x";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void OnResourcesFolderClick() {
|
||||
Process.Start(Path.Combine(Application.persistentDataPath, "eagle0", "Resources"));
|
||||
}
|
||||
|
||||
@@ -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
+278
@@ -0,0 +1,278 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace common {
|
||||
/// <summary>
|
||||
/// Automatically scrolls text content that overflows its container.
|
||||
/// Shows a fade gradient at the bottom when content overflows, then begins
|
||||
/// auto-scrolling after a delay. Useful for tooltips where the user can't
|
||||
/// manually scroll.
|
||||
///
|
||||
/// Also supports dynamic height sizing: the ScrollRect will grow to fit its
|
||||
/// content up to available screen space, only enabling scrolling when needed.
|
||||
/// </summary>
|
||||
public class AutoScrollingText : MonoBehaviour {
|
||||
/// <summary>
|
||||
/// Global scroll speed multiplier controlled by Settings.
|
||||
/// Default is 1.0 (use instance scrollSpeed as-is).
|
||||
/// Range: 0.0 (paused) to 2.0 (double speed).
|
||||
/// </summary>
|
||||
public static float GlobalScrollSpeedMultiplier {
|
||||
get => PlayerPrefs.GetFloat(ScrollSpeedKey, 1.0f);
|
||||
set => PlayerPrefs.SetFloat(ScrollSpeedKey, Mathf.Clamp(value, 0f, 2f));
|
||||
}
|
||||
|
||||
private const string ScrollSpeedKey = "autoScrollSpeedMultiplier";
|
||||
|
||||
[Header("Scroll Rect")]
|
||||
[Tooltip("The ScrollRect containing the text content")]
|
||||
public ScrollRect scrollRect;
|
||||
|
||||
[Tooltip("Optional gradient image to show when content overflows (fades at bottom)")]
|
||||
public GameObject fadeGradient;
|
||||
|
||||
[Header("Auto-Scroll Timing")]
|
||||
[Tooltip("Seconds to wait before starting to scroll")]
|
||||
public float scrollDelaySeconds = 1.5f;
|
||||
|
||||
[Tooltip("Scroll speed in normalized units per second (0-1 range)")]
|
||||
public float scrollSpeed = 0.15f;
|
||||
|
||||
[Tooltip("Seconds to pause at the bottom before resetting")]
|
||||
public float pauseAtBottomSeconds = 1.0f;
|
||||
|
||||
[Header("Dynamic Sizing")]
|
||||
[Tooltip("If true, resize the ScrollRect to fit content up to available space")]
|
||||
public bool dynamicHeight = true;
|
||||
|
||||
[Tooltip("Margin from top of screen in pixels")]
|
||||
public float topMargin = 20f;
|
||||
|
||||
[Tooltip("LayoutElement to adjust for dynamic height (usually on ScrollRect)")]
|
||||
public LayoutElement layoutElement;
|
||||
|
||||
[Tooltip("Other content in the panel (e.g., hero details). Height will be preserved.")]
|
||||
public RectTransform otherContent;
|
||||
|
||||
[Tooltip("Optional: Panel to hide until layout is complete (prevents jumpy resize)")]
|
||||
public CanvasGroup panelCanvasGroup;
|
||||
|
||||
private float _visibleTime = 0f;
|
||||
private bool _isScrolling = false;
|
||||
private bool _isPausedAtBottom = false;
|
||||
private float _pauseTimer = 0f;
|
||||
private RectTransform _scrollRectTransform;
|
||||
private Canvas _rootCanvas;
|
||||
|
||||
private void OnEnable() {
|
||||
// Hide panel until layout is complete to prevent jumpy resize
|
||||
if (panelCanvasGroup != null) { panelCanvasGroup.alpha = 0f; }
|
||||
|
||||
if (scrollRect != null) {
|
||||
_scrollRectTransform = scrollRect.GetComponent<RectTransform>();
|
||||
_rootCanvas = scrollRect.GetComponentInParent<Canvas>()?.rootCanvas;
|
||||
|
||||
// Immediately reset scroll position to prevent visual jump on first frame
|
||||
// Use normalizedPosition to reset both horizontal and vertical
|
||||
scrollRect.normalizedPosition = new Vector2(0f, 1f);
|
||||
|
||||
// Also reset the content's anchored position to prevent any offset
|
||||
if (scrollRect.content != null) {
|
||||
var contentRect = scrollRect.content;
|
||||
contentRect.anchoredPosition = new Vector2(0f, contentRect.anchoredPosition.y);
|
||||
}
|
||||
}
|
||||
|
||||
// Delay layout update to next frame so content has time to rebuild
|
||||
StartCoroutine(DelayedInitialize());
|
||||
}
|
||||
|
||||
private System.Collections.IEnumerator DelayedInitialize() {
|
||||
yield return null; // Wait one frame for layout to rebuild
|
||||
yield return null; // Extra frame for ContentSizeFitter
|
||||
|
||||
UpdateDynamicHeight();
|
||||
ResetScroll();
|
||||
|
||||
// Show panel now that layout is complete
|
||||
if (panelCanvasGroup != null) { panelCanvasGroup.alpha = 1f; }
|
||||
}
|
||||
|
||||
private void OnDisable() { ResetScroll(); }
|
||||
|
||||
/// <summary>
|
||||
/// Resets scroll position to top and restarts the delay timer.
|
||||
/// Call this when the text content changes.
|
||||
/// </summary>
|
||||
public void ResetScroll() {
|
||||
_visibleTime = 0f;
|
||||
_isScrolling = false;
|
||||
_isPausedAtBottom = false;
|
||||
_pauseTimer = 0f;
|
||||
|
||||
if (scrollRect != null) {
|
||||
scrollRect.verticalNormalizedPosition = 1f; // Top
|
||||
}
|
||||
|
||||
UpdateFadeGradient();
|
||||
}
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable debug logging to console")]
|
||||
public bool debugLogging = false;
|
||||
|
||||
/// <summary>
|
||||
/// Updates the ScrollRect height to fit content, up to available screen space.
|
||||
/// </summary>
|
||||
private void UpdateDynamicHeight() {
|
||||
if (!dynamicHeight || layoutElement == null || scrollRect == null) return;
|
||||
if (_scrollRectTransform == null || _rootCanvas == null) return;
|
||||
|
||||
// Get content height - prefer TMP_Text.preferredHeight over rect.height
|
||||
// because rect.height may not reflect actual text size without ContentSizeFitter
|
||||
float contentHeight = 0f;
|
||||
float rectHeight = scrollRect.content != null ? scrollRect.content.rect.height : 0f;
|
||||
var tmpText = scrollRect.content?.GetComponentInChildren<TMP_Text>();
|
||||
if (tmpText != null) {
|
||||
// Use TMP's calculated preferred height - this is the actual text size
|
||||
contentHeight = tmpText.preferredHeight;
|
||||
|
||||
// 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);
|
||||
}
|
||||
} else {
|
||||
contentHeight = rectHeight;
|
||||
}
|
||||
|
||||
// Get the bottom of the ScrollRect in screen space
|
||||
Vector3[] corners = new Vector3[4];
|
||||
_scrollRectTransform.GetWorldCorners(corners);
|
||||
|
||||
Camera cam = _rootCanvas.renderMode == RenderMode.ScreenSpaceOverlay
|
||||
? null
|
||||
: _rootCanvas.worldCamera;
|
||||
Vector2 scrollRectBottomScreen =
|
||||
RectTransformUtility.WorldToScreenPoint(cam, corners[0]);
|
||||
|
||||
// Calculate how much space we have from ScrollRect bottom to top of screen
|
||||
float spaceToTop = Screen.height - scrollRectBottomScreen.y - topMargin;
|
||||
|
||||
// Account for canvas scaling
|
||||
float scaleFactor = _rootCanvas.scaleFactor;
|
||||
if (scaleFactor > 0) { spaceToTop /= scaleFactor; }
|
||||
|
||||
// Get height of other content (hero details) if specified
|
||||
float otherContentHeight = 0f;
|
||||
if (otherContent != null) { otherContentHeight = otherContent.rect.height; }
|
||||
|
||||
// Max height for ScrollRect = space to top minus other content
|
||||
float maxScrollRectHeight = spaceToTop - otherContentHeight;
|
||||
|
||||
// Clamp to reasonable bounds
|
||||
maxScrollRectHeight = Mathf.Max(maxScrollRectHeight, 50f);
|
||||
|
||||
// Set preferred height to content or max, whichever is smaller
|
||||
float targetHeight = Mathf.Min(contentHeight, maxScrollRectHeight);
|
||||
layoutElement.preferredHeight = targetHeight;
|
||||
|
||||
if (debugLogging) {
|
||||
Debug.Log(
|
||||
$"[AutoScrollingText] contentHeight={contentHeight:F0} (rectHeight={rectHeight:F0}), " +
|
||||
$"spaceToTop={spaceToTop:F0}, otherContent={otherContentHeight:F0}, " +
|
||||
$"maxHeight={maxScrollRectHeight:F0}, targetHeight={targetHeight:F0}");
|
||||
}
|
||||
|
||||
// Force layout rebuild
|
||||
RectTransform parentRect = _scrollRectTransform.parent as RectTransform;
|
||||
if (parentRect != null) { LayoutRebuilder.ForceRebuildLayoutImmediate(parentRect); }
|
||||
}
|
||||
|
||||
private void Update() {
|
||||
if (scrollRect == null) return;
|
||||
|
||||
bool hasOverflow = HasContentOverflow();
|
||||
UpdateFadeGradient();
|
||||
|
||||
if (!hasOverflow) {
|
||||
_isScrolling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Hold Shift to pause scrolling (lets user read at their own pace)
|
||||
bool isPausedByUser =
|
||||
Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift);
|
||||
if (isPausedByUser) return;
|
||||
|
||||
// Handle pause at bottom
|
||||
if (_isPausedAtBottom) {
|
||||
_pauseTimer += Time.deltaTime;
|
||||
if (_pauseTimer >= pauseAtBottomSeconds) {
|
||||
// Reset to top and start over
|
||||
scrollRect.verticalNormalizedPosition = 1f;
|
||||
_isPausedAtBottom = false;
|
||||
_pauseTimer = 0f;
|
||||
_visibleTime = 0f;
|
||||
_isScrolling = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Track time visible
|
||||
_visibleTime += Time.deltaTime;
|
||||
|
||||
// Start scrolling after delay
|
||||
if (!_isScrolling && _visibleTime >= scrollDelaySeconds) { _isScrolling = true; }
|
||||
|
||||
// Perform scrolling (apply global speed multiplier from settings)
|
||||
if (_isScrolling) {
|
||||
float effectiveSpeed = scrollSpeed * GlobalScrollSpeedMultiplier;
|
||||
float newPosition =
|
||||
scrollRect.verticalNormalizedPosition - (effectiveSpeed * Time.deltaTime);
|
||||
|
||||
if (newPosition <= 0f) {
|
||||
// Reached bottom
|
||||
scrollRect.verticalNormalizedPosition = 0f;
|
||||
_isScrolling = false;
|
||||
_isPausedAtBottom = true;
|
||||
_pauseTimer = 0f;
|
||||
} else {
|
||||
scrollRect.verticalNormalizedPosition = newPosition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasContentOverflow() {
|
||||
if (scrollRect == null || scrollRect.content == null) return false;
|
||||
|
||||
// Use TMP_Text.preferredHeight for accurate content measurement
|
||||
float contentHeight;
|
||||
var tmpText = scrollRect.content.GetComponentInChildren<TMP_Text>();
|
||||
if (tmpText != null) {
|
||||
contentHeight = tmpText.preferredHeight;
|
||||
} else {
|
||||
contentHeight = scrollRect.content.rect.height;
|
||||
}
|
||||
|
||||
float viewportHeight = scrollRect.viewport != null
|
||||
? scrollRect.viewport.rect.height
|
||||
: scrollRect.GetComponent<RectTransform>().rect.height;
|
||||
|
||||
return contentHeight > viewportHeight + 1f; // Small buffer for floating point
|
||||
}
|
||||
|
||||
private void UpdateFadeGradient() {
|
||||
if (fadeGradient == null) return;
|
||||
|
||||
bool hasOverflow = HasContentOverflow();
|
||||
bool notAtBottom = scrollRect != null && scrollRect.verticalNormalizedPosition > 0.01f;
|
||||
|
||||
// Show gradient when there's overflow and we're not at the bottom
|
||||
fadeGradient.SetActive(hasOverflow && notAtBottom);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ac5d868c54ceb4893b1c95894cd4ec6f
|
||||
+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); }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user