mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 09:55:41 +00:00
Compare commits
191
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f89e49bee8 | ||
|
|
a8e474f95d | ||
|
|
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 | ||
|
|
c05e5f7f37 | ||
|
|
9d3967c58d | ||
|
|
07f27ea0ff | ||
|
|
b73d834fab | ||
|
|
deecd5a9ca | ||
|
|
314ff83d24 | ||
|
|
9adfd84498 | ||
|
|
63901e24e5 | ||
|
|
a4a128fe34 | ||
|
|
9cee497886 | ||
|
|
42294da2f6 | ||
|
|
75a129fb4c | ||
|
|
71a1858168 | ||
|
|
52f0cbe180 | ||
|
|
31dc53cc9e | ||
|
|
2a0654f884 | ||
|
|
1dd6eabc15 | ||
|
|
fcdc7d80b8 | ||
|
|
54db688c4e | ||
|
|
792c4f2b53 | ||
|
|
5aad32f5d9 | ||
|
|
78a833c086 | ||
|
|
f7c382446e | ||
|
|
a380eca47e | ||
|
|
90f239c696 | ||
|
|
be393a4cdd | ||
|
|
9e97f71bb9 | ||
|
|
113d54b936 | ||
|
|
5d6c2fef90 | ||
|
|
5e2e7a454c | ||
|
|
914141aed1 | ||
|
|
2ae235e933 | ||
|
|
14d83def79 | ||
|
|
170e998324 | ||
|
|
0dcdac1719 | ||
|
|
164933dbdd | ||
|
|
bf4db493ab | ||
|
|
89cabe9d17 | ||
|
|
dde7a58b44 | ||
|
|
486e99a02d | ||
|
|
2982927200 | ||
|
|
0551453536 | ||
|
|
ce357c612e | ||
|
|
f9e69b6f75 | ||
|
|
f43e914720 | ||
|
|
6c50c0da24 | ||
|
|
d265b76607 | ||
|
|
09a51e4280 | ||
|
|
5593effe69 | ||
|
|
44c268de93 | ||
|
|
0a40acb84d | ||
|
|
9603b497d2 | ||
|
|
0551dd0f13 | ||
|
|
45c4cf783d | ||
|
|
72c52e0b0d | ||
|
|
dffd569ed7 | ||
|
|
a1ffae91a8 | ||
|
|
45a32af435 | ||
|
|
1df8ec68e8 | ||
|
|
53d6e6f63d | ||
|
|
bc84cf6871 | ||
|
|
865a34d00a | ||
|
|
1a751cea6d | ||
|
|
5a8a343bcc | ||
|
|
5979dc7372 | ||
|
|
87474888f9 | ||
|
|
1b3697a40c | ||
|
|
a45b5dadd8 | ||
|
|
9f910bf849 | ||
|
|
c5466e38a8 | ||
|
|
958104b238 | ||
|
|
95e1d80e78 | ||
|
|
0dce9f47b0 | ||
|
|
6e788f4388 | ||
|
|
90d0918233 | ||
|
|
e6038927f1 | ||
|
|
acad796662 | ||
|
|
83c4ac7d38 | ||
|
|
7db07dc371 | ||
|
|
f1b843873a | ||
|
|
e8aefbb6ee | ||
|
|
1c51cc080f | ||
|
|
3946f2eb2d | ||
|
|
49bbdb1d2c | ||
|
|
bbdc30a4af | ||
|
|
19f5cf9e89 | ||
|
|
9033571110 | ||
|
|
e9e557f8f6 | ||
|
|
b32d252df3 | ||
|
|
d4723db2d1 | ||
|
|
7ce3cca731 | ||
|
|
24d21d402d | ||
|
|
e9fb1c5a87 | ||
|
|
b8b7d3a980 | ||
|
|
e503a8af9d | ||
|
|
9c4f46b6ca | ||
|
|
da453bb353 | ||
|
|
618cd18f44 | ||
|
|
429725c4e1 | ||
|
|
54bdefd75c | ||
|
|
98baf7ec66 | ||
|
|
fe4332c107 | ||
|
|
dfa18cef70 | ||
|
|
7845a54b5e | ||
|
|
3106fd9a40 | ||
|
|
19a14174c5 | ||
|
|
1f460a2777 | ||
|
|
12244fb1d4 | ||
|
|
b87910dcf5 | ||
|
|
214790c5e8 | ||
|
|
ffd4ff29d3 | ||
|
|
63e0334ef8 | ||
|
|
4aae50d72c | ||
|
|
0bd6e5b5d2 | ||
|
|
0560f15d1c | ||
|
|
428b91f337 | ||
|
|
d489857692 | ||
|
|
93e6771ded | ||
|
|
430b16bc86 | ||
|
|
8fb518ccad | ||
|
|
bfd4fcebbf | ||
|
|
7c312eb2ef | ||
|
|
209fab050b | ||
|
|
0bc0cbc738 | ||
|
|
48b561a999 |
@@ -29,6 +29,9 @@ common --javacopt="-Xlint:-options"
|
||||
common --linkopt=-Wl
|
||||
common:macos --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
|
||||
|
||||
common --java_language_version=17
|
||||
common --java_runtime_version=remotejdk_17
|
||||
common --tool_java_language_version=17
|
||||
|
||||
@@ -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,64 @@
|
||||
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: |
|
||||
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
|
||||
unzip -q awscliv2.zip
|
||||
sudo ./aws/install
|
||||
|
||||
- name: Upload to DigitalOcean Spaces
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: |
|
||||
# Upload sysroot tarball to DO Spaces
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
s3://eagle0/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/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.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.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
|
||||
echo ")"
|
||||
@@ -0,0 +1,72 @@
|
||||
name: Docker Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/main/cpp/**'
|
||||
- 'src/main/scala/**'
|
||||
- 'src/main/protobuf/**'
|
||||
- 'src/main/resources/**'
|
||||
- 'ci/BUILD.bazel'
|
||||
- 'MODULE.bazel'
|
||||
- '.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
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Eagle Docker image
|
||||
run: bazel build //ci:eagle_server_image
|
||||
|
||||
- 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
|
||||
|
||||
- name: Push Eagle image to DO registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
run: bazel run //ci:eagle_server_push
|
||||
|
||||
build-shardok:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Shardok Docker image (cross-compile for Linux)
|
||||
run: bazel build --platforms=//:linux_x86_64 //ci:shardok_server_image
|
||||
|
||||
- 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
|
||||
|
||||
- name: Push Shardok image to DO registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
run: bazel run --platforms=//:linux_x86_64 //ci:shardok_server_push
|
||||
@@ -37,3 +37,4 @@ scripts/refresh_name_layers/refresh_name_layers.zip
|
||||
.metals
|
||||
api_keys.txt
|
||||
|
||||
src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/Packages/com.unity.dedicated-server/
|
||||
|
||||
@@ -32,8 +32,9 @@ repos:
|
||||
- id: gazelle
|
||||
name: gazelle
|
||||
language: system
|
||||
entry: bazel run //:gazelle
|
||||
entry: ./scripts/pre-commit-gazelle.sh
|
||||
files: '(\.go|\.proto|BUILD\.bazel|BUILD|WORKSPACE|WORKSPACE\.bazel|\.bzl)$'
|
||||
pass_filenames: false
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: update-action-result-types
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -85,6 +85,16 @@ bazel run gazelle # Update Go build files
|
||||
./scripts/updateActionResultTypes.sh # Update protocol buffer mappings
|
||||
```
|
||||
|
||||
### Pre-Commit Checklist
|
||||
|
||||
**MANDATORY: Before running `git commit`, verify:**
|
||||
|
||||
1. **If you modified any BUILD.bazel file:** Run `bazel run gazelle` and stage any changes it makes
|
||||
2. **If you modified C++ or C# files:** Run `clang-format -i` on the modified files
|
||||
3. **If you modified Scala files:** scalafmt will run automatically via pre-commit hook
|
||||
|
||||
The pre-commit hook runs gazelle but only checks if it succeeds - it does NOT verify the BUILD files are in canonical format. The `gazelle_test` will fail if deps are not alphabetically sorted. **Always run gazelle manually after BUILD file changes.**
|
||||
|
||||
### Code Formatting
|
||||
|
||||
```bash
|
||||
@@ -206,6 +216,31 @@ to be used for different players or game situations within the same server proce
|
||||
- Map validation tests ensure game content integrity
|
||||
- Use `GameSettings_test_utils.cpp` and `ShardokEngineBasedTestData.cpp` for C++ test helpers
|
||||
|
||||
### Scala Testing Patterns
|
||||
|
||||
**Use `inside()` instead of `asInstanceOf` for type matching in tests:**
|
||||
|
||||
Never use `asInstanceOf` in tests. Instead, use ScalaTest's `inside()` pattern for safe type matching:
|
||||
|
||||
```scala
|
||||
// BAD - don't do this
|
||||
val changedHero = result.changedHeroes.head.asInstanceOf[ChangedHeroC]
|
||||
changedHero.heroId shouldBe 19
|
||||
|
||||
// GOOD - use inside() pattern
|
||||
import org.scalatest.Inside.inside
|
||||
|
||||
inside(result.changedHeroes.head) { case changedHero: ChangedHeroC =>
|
||||
changedHero.heroId shouldBe 19
|
||||
changedHero.vigorChange shouldBe StatDelta(17.2)
|
||||
}
|
||||
```
|
||||
|
||||
The `inside()` pattern:
|
||||
- Provides better error messages when the type doesn't match
|
||||
- Is idiomatic ScalaTest
|
||||
- Works with pattern matching for more complex assertions
|
||||
|
||||
## Performance Testing
|
||||
|
||||
When making performance-related changes to the AI or engine:
|
||||
@@ -244,6 +279,32 @@ done
|
||||
- **Always test performance changes** - what seems like an optimization may sometimes have unexpected overhead or
|
||||
behavior changes.
|
||||
|
||||
## Troubleshooting Scala Build Errors
|
||||
|
||||
### MissingType Errors
|
||||
|
||||
When you see errors like:
|
||||
```
|
||||
dotty.tools.dotc.core.MissingType: Cannot resolve reference to type net.eagle0.eagle.internal.game_state.type.GameState
|
||||
```
|
||||
|
||||
**This is NOT a Scala compiler crash.** This is a missing dependency in BUILD.bazel.
|
||||
|
||||
**How to fix:**
|
||||
1. Identify the missing type from the error message (e.g., `game_state.GameState`)
|
||||
2. Find the Bazel target that provides this type (e.g., `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`)
|
||||
3. Add it to the `deps` of the failing target
|
||||
4. If the type appears in a public method signature, also add it to `exports` so downstream targets can see it
|
||||
|
||||
**Common pattern:** When adding a method to a class that takes or returns a proto type, the proto dependency often needs to be added to both `deps` AND `exports`.
|
||||
|
||||
### Bazel Clean
|
||||
|
||||
**NEVER run `bazel clean` without asking first.** It rarely fixes actual issues and wastes significant rebuild time. The issues that seem like they need `bazel clean` are usually:
|
||||
- Missing imports in Scala code
|
||||
- Missing dependencies in BUILD.bazel
|
||||
- Missing exports for types used in public signatures
|
||||
|
||||
## Game Content
|
||||
|
||||
**Maps:** `.e0mj` files in `/src/main/resources/net/eagle0/shardok/maps/`
|
||||
|
||||
+66
-17
@@ -26,56 +26,76 @@ 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",
|
||||
# TODO: Update sha256 after running sysroot build workflow with version v2
|
||||
sha256 = "PLACEHOLDER_RUN_SYSROOT_WORKFLOW_FIRST",
|
||||
urls = ["https://eagle0.sfo3.digitaloceanspaces.com/sysroot/v2/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",
|
||||
"com_github_aws_aws_sdk_go_v2_config",
|
||||
"com_github_aws_aws_sdk_go_v2_credentials",
|
||||
"com_github_aws_aws_sdk_go_v2_service_s3",
|
||||
"org_golang_google_grpc",
|
||||
"org_golang_google_protobuf",
|
||||
)
|
||||
|
||||
@@ -83,15 +103,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")
|
||||
@@ -102,6 +122,32 @@ 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)
|
||||
oci.pull(
|
||||
name = "eclipse_temurin_17",
|
||||
digest = "sha256:d286b5352d98777bbf727f54038b04f0145cd9b76ca83f38a67aa111d4303748",
|
||||
image = "docker.io/library/eclipse-temurin",
|
||||
platforms = ["linux/amd64"],
|
||||
)
|
||||
|
||||
# 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",
|
||||
)
|
||||
use_repo(oci, "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64")
|
||||
|
||||
#
|
||||
# Java/Scala Dependencies
|
||||
#
|
||||
@@ -109,7 +155,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
|
||||
@@ -160,6 +205,10 @@ 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)
|
||||
"com.squareup.okhttp3:okhttp:4.12.0",
|
||||
"com.squareup.okhttp3:okhttp-sse:4.12.0",
|
||||
],
|
||||
duplicate_version_warning = "error",
|
||||
fail_if_repin_required = True,
|
||||
@@ -168,7 +217,6 @@ maven.install(
|
||||
"https://repo1.maven.org/maven2",
|
||||
],
|
||||
)
|
||||
|
||||
use_repo(maven, "maven", "unpinned_maven")
|
||||
|
||||
#
|
||||
@@ -216,5 +264,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
+275
-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,247 @@
|
||||
"recordedRepoMappingEntries": []
|
||||
}
|
||||
},
|
||||
"@@rules_oci~//oci:extensions.bzl%oci": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
|
||||
"usagesDigest": "BuciKSozbpJMD9EP+j0RG5ZgrYMeDPsQyiOnLUni2V8=",
|
||||
"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": "sha256:d286b5352d98777bbf727f54038b04f0145cd9b76ca83f38a67aa111d4303748",
|
||||
"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": "sha256:d286b5352d98777bbf727f54038b04f0145cd9b76ca83f38a67aa111d4303748",
|
||||
"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
|
||||
}
|
||||
},
|
||||
"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"
|
||||
],
|
||||
"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 +1559,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 +5170,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~"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load", "oci_push")
|
||||
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
|
||||
|
||||
#
|
||||
# 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",
|
||||
"-Xmx4g",
|
||||
"-XX:+UseG1GC",
|
||||
"-jar",
|
||||
"/app/eagle_server_deploy.jar",
|
||||
],
|
||||
env = {
|
||||
"JAVA_OPTS": "-Xmx4g -XX:+UseG1GC",
|
||||
},
|
||||
exposed_ports = ["40032/tcp"],
|
||||
tars = [
|
||||
":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
|
||||
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 = [
|
||||
":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
|
||||
oci_push(
|
||||
name = "shardok_server_push",
|
||||
image = ":shardok_server_image",
|
||||
repository = "registry.digitalocean.com/eagle0/shardok-server",
|
||||
)
|
||||
+1
-2
@@ -1,2 +1 @@
|
||||
|
||||
UNITY_VERSION='6000.2.7f2'
|
||||
UNITY_VERSION='6000.3.0f1'
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Docker Compose for local testing of production images
|
||||
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load
|
||||
# Run: docker compose -f docker-compose.prod.yml up
|
||||
|
||||
services:
|
||||
eagle:
|
||||
image: eagle0/eagle-server:latest
|
||||
container_name: eagle-server
|
||||
ports:
|
||||
- "40032:40032"
|
||||
environment:
|
||||
# Eagle server configuration
|
||||
EAGLE_GRPC_PORT: "40032"
|
||||
SHARDOK_HOST: "shardok"
|
||||
SHARDOK_PORT: "40042"
|
||||
# Resource paths (relative to /app in container)
|
||||
EAGLE_RESOURCES_PATH: "/app/resources"
|
||||
volumes:
|
||||
# Mount saves directory for persistence
|
||||
- ./saves:/app/saves
|
||||
depends_on:
|
||||
- shardok
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "localhost", "40032"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
shardok:
|
||||
image: eagle0/shardok-server:latest
|
||||
container_name: shardok-server
|
||||
ports:
|
||||
- "40042:40042"
|
||||
- "40052:40052"
|
||||
environment:
|
||||
# Shardok server configuration
|
||||
SHARDOK_RESOURCES_PATH: "/app/resources"
|
||||
SHARDOK_MAPS_PATH: "/app/resources/maps"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "localhost", "40042"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,334 @@
|
||||
# Deproto Migration Plan
|
||||
|
||||
## Vision
|
||||
|
||||
**Protocol buffers should only be used at the edges** — for network serialization (gRPC) and disk persistence. Inside the Eagle game engine, all logic should operate on native Scala models.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ GRPC BOUNDARY │
|
||||
│ EagleServiceImpl.scala ←→ Proto Messages ←→ Unity Client │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
GameStateConverter
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ SCALA ENGINE │
|
||||
│ │
|
||||
│ GameStateC ───→ Actions ───→ ActionResultT ───→ New GameStateC │
|
||||
│ ↑ │ │
|
||||
│ │ (Pure Scala models) │ │
|
||||
│ └───────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ HeroC, FactionC, ProvinceC, BattalionC, ArmyC, etc. │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
GameStateConverter
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ PERSISTENCE BOUNDARY │
|
||||
│ GameHistory.scala ←→ Proto Messages ←→ File/Database │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
### Completed Phases
|
||||
|
||||
| Phase | Status | Summary |
|
||||
|-------|--------|---------|
|
||||
| Phase 1: GameStateC | **Complete** | Scala `GameState` model with 22 fields |
|
||||
| Phase 2: EngineImpl | **Complete** | Holds Scala `GameState` internally |
|
||||
| Phase 3: GameHistory | **Complete** | `stateAfter` returns Scala GameState |
|
||||
| Phase 4: ActionResultT | **Complete** | All 59 actions return `ActionResultT` |
|
||||
| Phase 5: Action Base Classes | **Complete** | All `RandomSequentialResultsAction` and `DeterministicSingleResultAction` converted to T-type base classes |
|
||||
| Phase 5b: Base Class Cleanup | **Complete** | `RandomSequentialResultsAction` and `DeterministicSingleResultAction` deleted |
|
||||
| Phase 5c: RoundPhaseAdvancer Actions | **Complete** | All actions called by RoundPhaseAdvancer accept Scala GameState |
|
||||
| Phase 5d: RoundPhaseAdvancer Itself | **Complete** | RoundPhaseAdvancer.checkForPhaseAdvancement takes Scala GameState |
|
||||
|
||||
### Phase 5c/5d Progress (Complete)
|
||||
|
||||
`RoundPhaseAdvancer.checkForPhaseAdvancement` now accepts Scala `GameState` and `ActionResultApplier` directly (PR #4677).
|
||||
|
||||
| Action | PR | Status |
|
||||
|--------|-----|--------|
|
||||
| `PrisonerExchangeAction` | #4670 | ✅ Merged |
|
||||
| `PerformForcedTurnBackAction` | #4671 | ✅ Merged |
|
||||
| `PerformHeroDeparturesAction` | #4672 | ✅ Merged |
|
||||
| `RequestFreeForAllBattlesAction` | #4673 | ✅ Merged |
|
||||
| `EndPlayerCommandsPhaseAction` | #4674 | ✅ Merged |
|
||||
| `EndDiplomacyResolutionPhaseAction` | #4675 | ✅ Merged |
|
||||
| `RoundPhaseAdvancer` itself | #4677 | ✅ Merged |
|
||||
|
||||
### EngineImpl Progress
|
||||
|
||||
| Change | PR | Status |
|
||||
|--------|-----|--------|
|
||||
| `recursiveTransform` deleted | #4677 | ✅ Merged |
|
||||
| `recursiveTransformT` uses `RandomStateTSequencer` | #4677 | ✅ Merged |
|
||||
|
||||
### Current Architecture
|
||||
|
||||
**ActionResultT Production (100% Complete):**
|
||||
- All actions produce `ActionResultT`
|
||||
- Conversion to `ActionResultProto` happens via `ActionResultProtoConverter.toProto()`
|
||||
- No direct `ActionResultProto` construction outside the converter
|
||||
|
||||
**ActionResultProto Consumption (Next Target):**
|
||||
- `ActionResultProtoApplierImpl` - applies proto results to proto GameState
|
||||
- `RoundPhaseAdvancer` - calls converter, passes protos to applier
|
||||
- `InMemoryHistory` / `PersistedHistory` - stores proto results
|
||||
- Service layer (`GameController`, `GamesManager`, etc.) - uses proto for client communication
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Migrate to ActionResultT Consumers
|
||||
|
||||
### Objective
|
||||
|
||||
Eliminate internal consumption of `ActionResultProto`. Everything inside the engine should work with `ActionResultT`.
|
||||
|
||||
### Current Flow (Proto-Heavy)
|
||||
```
|
||||
Action.execute()
|
||||
→ ActionResultT
|
||||
→ ActionResultProtoConverter.toProto()
|
||||
→ ActionResultProto
|
||||
→ ActionResultProtoApplierImpl.applyActionResults()
|
||||
→ GameStateProto
|
||||
→ GameStateConverter.fromProto()
|
||||
→ GameStateC
|
||||
```
|
||||
|
||||
### Target Flow (T-Types Throughout)
|
||||
```
|
||||
Action.execute()
|
||||
→ ActionResultT
|
||||
→ ActionResultApplier.applyActionResults()
|
||||
→ GameStateC
|
||||
|
||||
(Proto conversion only at boundaries)
|
||||
```
|
||||
|
||||
### Key Files to Convert
|
||||
|
||||
**Tier 1 - Core Applier:** ✅ **Complete**
|
||||
```
|
||||
src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImpl.scala
|
||||
```
|
||||
`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**
|
||||
```
|
||||
src/main/scala/net/eagle0/eagle/library/RoundPhaseAdvancer.scala
|
||||
```
|
||||
Now accepts Scala `GameState` and `ActionResultApplier`. Only converts to proto lazily for `AvailableCommandsFactory` calls.
|
||||
|
||||
**Tier 3 - Sequencers:**
|
||||
```
|
||||
src/main/scala/net/eagle0/eagle/library/actions/impl/common/RandomStateTSequencer.scala
|
||||
src/main/scala/net/eagle0/eagle/library/actions/impl/common/RandomStateProtoSequencer.scala
|
||||
```
|
||||
Modify `RandomStateTSequencer` to thread Scala `GameState` throughout (currently converts to proto internally). Then evaluate whether `RandomStateProtoSequencer` is still needed at all.
|
||||
|
||||
**Current State**: `RandomStateTSequencer` accepts Scala `GameState` via its `apply()` method but internally converts to proto. All callback methods (`withRandomActionResult`, `withActionResults`, etc.) pass `GameStateProto` to callers, forcing actions that use the sequencer to work with proto types internally.
|
||||
|
||||
**Target State**: Create a fully protoless sequencer where:
|
||||
1. `lastState` returns Scala `GameState` (not `lastStateProto`)
|
||||
2. All callback methods pass Scala `GameState` to callers
|
||||
3. Actions using the sequencer can be fully protoless
|
||||
|
||||
**Migration Path**:
|
||||
1. Add `lastState: GameState` method alongside `lastStateProto` (non-breaking)
|
||||
2. Add parallel callback methods that pass Scala GameState (e.g., `withScalaActionResult`)
|
||||
3. Migrate actions one by one to use the new Scala-based callbacks
|
||||
4. Once all actions migrated, deprecate/remove proto-based callbacks
|
||||
5. Remove `lastStateProto` once no longer used
|
||||
|
||||
**RandomStateSequencer Migration Progress** (PR #4679 introduced protoless `RandomStateSequencer`):
|
||||
|
||||
| Action | Status |
|
||||
|--------|--------|
|
||||
| `TruceTurnBackPhaseAction` | ✅ Migrated (PR #4680) |
|
||||
| `EndHandleRiotsPhaseAction` | ✅ Migrated (PR #4684) |
|
||||
| `PerformVassalCommandsPhaseAction` | ✅ Migrated |
|
||||
| `PerformVassalDefenseDecisionsAction` | ✅ Migrated |
|
||||
| `EndVassalCommandsPhaseAction` | ✅ Migrated |
|
||||
| `PerformReconResolutionAction` | ✅ Migrated |
|
||||
| `NewRoundAction` | ✅ Migrated (PR #4698) |
|
||||
| `EndBattleAftermathPhaseAction` | ✅ Migrated (PR #4699) |
|
||||
| `EndDiplomacyResolutionPhaseAction` | ✅ Migrated |
|
||||
| `PerformUnaffiliatedHeroesAction` | ✅ Migrated |
|
||||
| `EngineImpl.recursiveTransformT` | ✅ Migrated (PR #4704) |
|
||||
| `ProtolessSequentialResultsActionWrapper` | ✅ Migrated (PR #4705) |
|
||||
| `LegacyRandomStateTSequencer` | ✅ **Deleted** (PR #4705) |
|
||||
|
||||
**TCommandFactory Extraction** (PR #4684):
|
||||
|
||||
To enable lightweight mocking of command creation in tests, `TCommandFactory` trait was extracted from `CommandFactory`. This allows tests to mock just the `makeTCommand` method without pulling in all 40+ command dependencies that `CommandFactory` requires.
|
||||
|
||||
- `TCommandFactory` - lightweight trait with just `makeTCommand`
|
||||
- `CommandFactory extends TCommandFactory` - maintains backward compatibility
|
||||
- Actions accepting command factories now use `TCommandFactory` type for better testability
|
||||
|
||||
**Tier 4 - History APIs:**
|
||||
```
|
||||
src/main/scala/net/eagle0/eagle/service/InMemoryHistory.scala
|
||||
src/main/scala/net/eagle0/eagle/service/PersistedHistory.scala
|
||||
```
|
||||
Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versions. `PersistedHistory` converts to proto internally for disk persistence; `InMemoryHistory` doesn't need proto at all.
|
||||
|
||||
### ActionResultProto Consumer Inventory
|
||||
|
||||
| File | Usage | Status |
|
||||
|------|-------|--------|
|
||||
| `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) |
|
||||
|
||||
### Remaining Proto Usage in Actions
|
||||
|
||||
**Progress: 46 of 52 action files (88%) are fully protoless.**
|
||||
|
||||
The following 6 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 |
|
||||
| `NewRoundAction` | 1 | `ChronicleEventGenerator` returns proto | Medium |
|
||||
|
||||
**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) |
|
||||
| `ChronicleEventGenerator` to Scala | ~400 | Medium | 1 action |
|
||||
| History API updates | ~100 | Low | - |
|
||||
| **Total Remaining** | **~3000** | | |
|
||||
|
||||
### Progress Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Action files fully protoless | 46 / 52 (88%) |
|
||||
| Proto usages in remaining actions | 33 total |
|
||||
| Biggest blocker | `ResolveBattleAction` (24 usages) |
|
||||
| Second biggest blocker | `CommandChoiceHelpers` (blocks 4 actions) |
|
||||
|
||||
### Validation
|
||||
- [x] `ActionResultApplier` created and tested
|
||||
- [x] `RandomStateSequencer` threads Scala GameState throughout
|
||||
- [x] `RoundPhaseAdvancer` uses T-types internally
|
||||
- [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
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Clean Up Legacy Utilities
|
||||
|
||||
### Objective
|
||||
Remove remaining direct proto imports from utility classes.
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File | Status |
|
||||
|------|--------|
|
||||
| `CommandChoiceHelpers.scala` | Accepts proto `GameState`; blocks full deproto of `PerformVassalCommandsPhaseAction` and `PerformVassalDefenseDecisionsAction` |
|
||||
| `LegacyProvinceUtils.scala` | Replace with `ProvinceUtils.scala` - `hasImminentRiot` added (PR #4683) |
|
||||
| `LegacyFactionUtils.scala` | Replace proto imports with `FactionT` |
|
||||
| `LegacyUnaffiliatedHeroUtils.scala` | Replace proto imports with Scala models |
|
||||
| `BattalionTypeLoader.scala` | Keep proto for file loading, convert immediately after |
|
||||
| `BeastUtils.scala` | **Complete** - now uses Scala `BeastInfo` only |
|
||||
|
||||
### View Filters (Partially Complete)
|
||||
|
||||
The view filter utilities now have Scala overloads for server-side use:
|
||||
|
||||
| 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 |
|
||||
|
||||
**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
|
||||
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Verify Boundaries
|
||||
|
||||
### Objective
|
||||
Confirm protos are used correctly at boundaries — and ONLY there.
|
||||
|
||||
### Expected Proto Usage (Keep)
|
||||
- `EagleServiceImpl.scala` - gRPC boundary
|
||||
- `InMemoryHistory.scala` / `PersistedHistory.scala` - Persistence boundary
|
||||
- `*Converter.scala` - Explicit conversion utilities
|
||||
- `*Loader.scala` - File loading utilities
|
||||
|
||||
### Expected No Proto Usage (Verify)
|
||||
- `/library/actions/impl/` - Pure Scala models
|
||||
- `/library/util/` - Pure Scala models (except loaders)
|
||||
- `/model/state/` - Pure Scala models
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Persistence Format**: Currently game state is persisted as proto. Should we keep proto for persistence (good for schema evolution) or switch to a different format?
|
||||
|
||||
2. **Shardok Integration**: `ResolveBattleAction` communicates with Shardok. Should the Shardok interface use protos (external service) or Scala models?
|
||||
|
||||
3. **View Generation**: `GameStateViewDiffer` works with view protos for client updates. Views need Scala models (`ProvinceViewT`, etc.) to allow actions like `EndBattleAftermathPhaseAction` to be fully protoless. The Scala views would be converted to proto only at the gRPC boundary when sending updates to clients.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Code Quality
|
||||
- [ ] Zero proto imports in `/library/actions/` (except boundaries)
|
||||
- [ ] Zero proto imports in `/library/` utilities (except loaders)
|
||||
- [ ] `GameStateT` used throughout engine internals
|
||||
- [ ] Proto usage limited to: `EagleServiceImpl`, loaders, converters, persistence
|
||||
|
||||
### Architecture
|
||||
- [ ] Clear separation: Scala models (internal) vs Proto (boundaries)
|
||||
- [ ] Converters as the only bridge between domains
|
||||
- [ ] No "proto creep" into business logic
|
||||
@@ -0,0 +1,940 @@
|
||||
# Eagle0 Productionization Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document outlines a plan to move Eagle0's Eagle and Shardok servers from a home Mac to cloud infrastructure while maintaining a QA environment on the Mac. The architecture uses DigitalOcean (already integrated via Spaces) with containerized deployments and GitHub Actions CI/CD.
|
||||
|
||||
## Current Architecture
|
||||
|
||||
```
|
||||
Unity Client
|
||||
│
|
||||
│ gRPC/TLS (eagle0.net:443)
|
||||
▼
|
||||
nginx (home Mac, via router port forward)
|
||||
│
|
||||
├─► Eagle Server (Scala/JVM, port 40032)
|
||||
│ │
|
||||
│ │ internal gRPC (port 40042)
|
||||
│ ▼
|
||||
└─► Shardok Server (C++, port 40042/40052)
|
||||
|
||||
Storage: DigitalOcean Spaces (sfo3.digitaloceanspaces.com)
|
||||
DNS: eagle0.net → home IP
|
||||
```
|
||||
|
||||
**Key observations:**
|
||||
- Already using DigitalOcean Spaces for S3-compatible storage
|
||||
- Eagle server is a deployable JAR (`eagle_server_deploy.jar`)
|
||||
- Shardok server is a native C++ binary
|
||||
- nginx handles TLS termination and gRPC routing
|
||||
- Self-hosted GitHub Actions runner on Mac
|
||||
|
||||
---
|
||||
|
||||
## Target Architecture
|
||||
|
||||
### Production Environment (DigitalOcean)
|
||||
|
||||
```
|
||||
Unity Client
|
||||
│
|
||||
├─► eagle0.net (Production)
|
||||
│ │
|
||||
│ ▼
|
||||
│ DigitalOcean Load Balancer (TLS termination)
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌─────────────────────────────────────┐
|
||||
│ │ DigitalOcean Droplet(s) │
|
||||
│ │ ┌─────────────┬─────────────────┐ │
|
||||
│ │ │ Eagle │ Shardok │ │
|
||||
│ │ │ (Docker) │ (Docker) │ │
|
||||
│ │ │ :40032 │ :40042 │ │
|
||||
│ │ └─────────────┴─────────────────┘ │
|
||||
│ └─────────────────────────────────────┘
|
||||
│
|
||||
└─► qa.eagle0.net (QA - home Mac, unchanged)
|
||||
│
|
||||
▼
|
||||
nginx → Eagle/Shardok (current setup)
|
||||
```
|
||||
|
||||
### Environment Switching
|
||||
|
||||
The Unity client already supports configurable server URLs via the connection screen:
|
||||
- **Production:** `eagle0.net` (default)
|
||||
- **QA:** `qa.eagle0.net`
|
||||
|
||||
No client code changes needed - users can simply type the desired URL.
|
||||
|
||||
---
|
||||
|
||||
## Cloud Provider: DigitalOcean
|
||||
|
||||
### Why DigitalOcean
|
||||
|
||||
1. **Already integrated** - S3 Spaces storage at `sfo3.digitaloceanspaces.com` with credentials configured
|
||||
2. **Simple pricing** - Predictable monthly costs, no surprise bills
|
||||
3. **Good performance** - SFO3 datacenter is geographically close
|
||||
4. **Managed services** - Load balancers, managed databases if needed later
|
||||
5. **Not AWS/GCP** - Per your requirements
|
||||
|
||||
### Alternative Considered: Hetzner
|
||||
|
||||
- Cheaper for compute ($3.29/mo for 2 vCPU/4GB vs DO's $24/mo)
|
||||
- Good EU presence but less US coverage
|
||||
- No managed load balancer (need to run HAProxy/nginx yourself)
|
||||
- **Recommendation:** Start with DigitalOcean for simplicity; migrate to Hetzner later if cost becomes a concern
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure Components
|
||||
|
||||
### 1. Compute: DigitalOcean Droplets
|
||||
|
||||
#### Resource Characteristics
|
||||
|
||||
- **Eagle server:** CPU-light, possibly RAM-heavy (JVM). Should be always available.
|
||||
- **Shardok server:** Very CPU-heavy (AI algorithms). Only needed during tactical battles. Startup time <1 second.
|
||||
|
||||
#### Recommended: On-Demand Shardok (Same Droplet)
|
||||
|
||||
For current low player count, optimize for cost while maintaining availability:
|
||||
|
||||
| Component | Config | Monthly Cost |
|
||||
|-----------|--------|--------------|
|
||||
| Droplet | s-2vcpu-4gb | $24/mo |
|
||||
| Eagle | Always running | - |
|
||||
| Shardok | Started on-demand by Eagle, stopped after idle | - |
|
||||
|
||||
**How it works:**
|
||||
1. Eagle server runs 24/7 - game is always available
|
||||
2. When battle starts, Eagle launches Shardok container/process (<1s startup)
|
||||
3. After battle ends, idle timer starts (e.g., 5 minutes)
|
||||
4. On timeout, Eagle stops Shardok to free CPU
|
||||
5. If new battle starts during idle period, Shardok is already warm
|
||||
|
||||
**Shardok lifecycle management** (implement in Eagle):
|
||||
```scala
|
||||
// Pseudocode for Eagle's Shardok management
|
||||
object ShardokManager {
|
||||
private var process: Option[Process] = None
|
||||
private var idleTimer: Option[Timer] = None
|
||||
|
||||
def ensureRunning(): Unit = {
|
||||
cancelIdleTimer()
|
||||
if (process.isEmpty) {
|
||||
process = Some(startShardokProcess())
|
||||
waitForHealthCheck()
|
||||
}
|
||||
}
|
||||
|
||||
def onBattleEnd(): Unit = {
|
||||
idleTimer = Some(scheduleShutdown(5.minutes))
|
||||
}
|
||||
|
||||
private def shutdown(): Unit = {
|
||||
process.foreach(_.destroy())
|
||||
process = None
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Future Scaling Options
|
||||
|
||||
As player count grows and Shardok needs more CPU:
|
||||
|
||||
**Option A: Upgrade Droplet (Simplest)**
|
||||
|
||||
| Droplet | vCPU | RAM | Cost | Use Case |
|
||||
|---------|------|-----|------|----------|
|
||||
| s-2vcpu-4gb | 2 shared | 4 GB | $24/mo | Current (few players) |
|
||||
| s-4vcpu-8gb | 4 shared | 8 GB | $48/mo | Moderate usage |
|
||||
| c-4 | 4 dedicated | 8 GB | $84/mo | CPU-intensive battles |
|
||||
| c-8 | 8 dedicated | 16 GB | $168/mo | Multiple concurrent battles |
|
||||
|
||||
**Option B: Separate Shardok Droplet (API-driven)**
|
||||
|
||||
For heavy Shardok usage with cost optimization:
|
||||
|
||||
- **Eagle droplet:** s-1vcpu-2gb ($12/mo) - always on
|
||||
- **Shardok droplet:** Created on-demand via DigitalOcean API
|
||||
- c-4 CPU-optimized: $0.125/hour
|
||||
- c-8 CPU-optimized: $0.25/hour
|
||||
- Created when battle starts, destroyed after idle
|
||||
- 30-60s droplet creation time (acceptable if battles are requested in advance)
|
||||
|
||||
**Option C: Fly.io for Shardok (Scale-to-Zero)**
|
||||
|
||||
For true pay-per-use with fast cold starts:
|
||||
|
||||
- **Eagle:** DigitalOcean or Fly.io (~$10/mo)
|
||||
- **Shardok:** Fly.io with auto-scaling
|
||||
- Performance VMs: ~$0.0000022/second when running
|
||||
- Cold start: 2-5 seconds
|
||||
- Scales to zero when idle
|
||||
|
||||
Requires learning Fly.io but offers best cost efficiency for sporadic usage.
|
||||
|
||||
**Option D: Multiple Shardok Instances (High Scale)**
|
||||
|
||||
For many concurrent battles:
|
||||
|
||||
- **Eagle:** Dedicated droplet with more RAM
|
||||
- **Shardok pool:** Multiple Shardok containers/droplets
|
||||
- Eagle routes battles to available Shardok instances
|
||||
- Could use Kubernetes or Docker Swarm for orchestration
|
||||
|
||||
This is overkill for now but documented for future reference.
|
||||
|
||||
### 2. Load Balancer
|
||||
|
||||
**DigitalOcean Load Balancer:** $12/mo
|
||||
|
||||
- TLS termination with Let's Encrypt
|
||||
- Health checks
|
||||
- Sticky sessions (if needed)
|
||||
- Can add more droplets later for HA
|
||||
|
||||
**Alternative:** Run nginx on the droplet for $0 extra, but lose automatic failover.
|
||||
|
||||
### 3. Storage
|
||||
|
||||
**Already configured:** DigitalOcean Spaces
|
||||
- Bucket: `eagle0`
|
||||
- Region: `sfo3`
|
||||
- Used for game saves and assets
|
||||
- Cost: $5/mo base + $0.02/GB storage + $0.01/GB transfer
|
||||
|
||||
### 4. DNS
|
||||
|
||||
**Option A: DigitalOcean DNS (Recommended)**
|
||||
- Free with droplets
|
||||
- Easy integration
|
||||
- API for automated updates
|
||||
|
||||
**Option B: Keep current DNS provider**
|
||||
- Update A records manually or via script
|
||||
|
||||
**DNS Records:**
|
||||
```
|
||||
eagle0.net A <DO Load Balancer IP>
|
||||
qa.eagle0.net A <Home IP> (unchanged)
|
||||
*.eagle0.net A <DO Load Balancer IP> (wildcard for future)
|
||||
```
|
||||
|
||||
### 5. Firewall
|
||||
|
||||
**DigitalOcean Cloud Firewall:** Free
|
||||
|
||||
```
|
||||
Inbound Rules:
|
||||
- TCP 443 (HTTPS/gRPC) from anywhere → Load Balancer
|
||||
- TCP 22 (SSH) from your IP only → Droplets
|
||||
- TCP 40032 (Eagle) from Load Balancer only
|
||||
- TCP 40042 (Shardok) from Load Balancer only
|
||||
|
||||
Outbound Rules:
|
||||
- All traffic allowed (for external APIs, Spaces, etc.)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Container Strategy
|
||||
|
||||
### Dockerfiles
|
||||
|
||||
**Eagle Server** (`ci/eagle_run.Dockerfile` - already exists, needs enhancement):
|
||||
|
||||
```dockerfile
|
||||
FROM eclipse-temurin:17-jre-alpine
|
||||
|
||||
# Add non-root user
|
||||
RUN addgroup -S eagle && adduser -S eagle -G eagle
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the deploy JAR
|
||||
COPY --chown=eagle:eagle deploy/eagle_server_deploy.jar ./
|
||||
|
||||
# Copy game resources
|
||||
COPY --chown=eagle:eagle src/main/resources/net/eagle0/eagle/ ./resources/
|
||||
|
||||
USER eagle
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:40032/health || exit 1
|
||||
|
||||
EXPOSE 40032
|
||||
|
||||
ENTRYPOINT ["java", "-Xmx4g", "-jar", "eagle_server_deploy.jar"]
|
||||
CMD ["--eagle-grpc-port", "40032", "--shardok-interface-remote-address", "shardok:40042", "--gpt-model-name", "gpt-4"]
|
||||
```
|
||||
|
||||
**Shardok Server** (new: `ci/shardok_run.Dockerfile`):
|
||||
|
||||
```dockerfile
|
||||
FROM ubuntu:22.04
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libstdc++6 \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Add non-root user
|
||||
RUN useradd -r -s /bin/false shardok
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the Shardok binary and dependencies
|
||||
COPY --chown=shardok:shardok bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server ./
|
||||
COPY --chown=shardok:shardok src/main/resources/net/eagle0/shardok/maps/ ./maps/
|
||||
|
||||
USER shardok
|
||||
|
||||
# Health check (need to implement gRPC health endpoint)
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||
CMD ./shardok-server --health-check || exit 1
|
||||
|
||||
EXPOSE 40042 40052
|
||||
|
||||
ENTRYPOINT ["./shardok-server"]
|
||||
```
|
||||
|
||||
**Docker Compose** (new: `docker-compose.prod.yml`):
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
eagle:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ci/eagle_run.Dockerfile
|
||||
image: eagle0/eagle-server:${VERSION:-latest}
|
||||
ports:
|
||||
- "40032:40032"
|
||||
environment:
|
||||
- SHARDOK_ADDRESS=shardok:40042
|
||||
- GPT_MODEL_NAME=${GPT_MODEL_NAME:-gpt-4}
|
||||
- JAVA_OPTS=-Xmx4g -XX:+UseG1GC
|
||||
depends_on:
|
||||
- shardok
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "5"
|
||||
|
||||
shardok:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ci/shardok_run.Dockerfile
|
||||
image: eagle0/shardok-server:${VERSION:-latest}
|
||||
ports:
|
||||
- "40042:40042"
|
||||
- "40052:40052"
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "5"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build Pipeline
|
||||
|
||||
### Build Artifacts
|
||||
|
||||
The build process produces:
|
||||
1. **Eagle JAR:** `bazel-bin/src/main/scala/net/eagle0/eagle/eagle_server_deploy.jar`
|
||||
2. **Shardok binary:** `bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server`
|
||||
|
||||
### Cross-Compilation for Linux
|
||||
|
||||
Current builds target macOS (self-hosted runner). For production Linux deployment:
|
||||
|
||||
**Option A: Build in Docker (Recommended)**
|
||||
```bash
|
||||
# Build Eagle (JVM - platform independent)
|
||||
bazel build //src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar
|
||||
|
||||
# Build Shardok in Linux container
|
||||
docker run --rm -v $(pwd):/workspace -w /workspace \
|
||||
ubuntu:22.04 \
|
||||
bash -c "apt-get update && apt-get install -y build-essential && bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server"
|
||||
```
|
||||
|
||||
**Option B: Use GitHub-hosted Linux runner**
|
||||
- Add `runs-on: ubuntu-latest` workflow
|
||||
- Builds directly on Linux
|
||||
- May need to cache Bazel to avoid long build times
|
||||
|
||||
**Option C: Cross-compile on Mac**
|
||||
- Configure Bazel for Linux cross-compilation
|
||||
- More complex setup but faster iteration
|
||||
|
||||
**Recommendation:** Option A (Docker build) for Shardok, since Eagle's JAR is platform-independent.
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
### GitHub Actions Workflows
|
||||
|
||||
**New workflow:** `.github/workflows/deploy_production.yml`
|
||||
|
||||
```yaml
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/main/scala/**'
|
||||
- 'src/main/cpp/**'
|
||||
- 'src/main/protobuf/**'
|
||||
- 'ci/*.Dockerfile'
|
||||
- 'docker-compose.prod.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Deployment environment'
|
||||
required: true
|
||||
default: 'production'
|
||||
type: choice
|
||||
options:
|
||||
- production
|
||||
- staging
|
||||
|
||||
env:
|
||||
REGISTRY: registry.digitalocean.com
|
||||
EAGLE_IMAGE: eagle0/eagle-server
|
||||
SHARDOK_IMAGE: eagle0/shardok-server
|
||||
|
||||
jobs:
|
||||
build-eagle:
|
||||
runs-on: self-hosted
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Set version
|
||||
id: version
|
||||
run: echo "version=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build Eagle server JAR
|
||||
run: bazel build //src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar
|
||||
|
||||
- name: Copy artifacts
|
||||
run: |
|
||||
mkdir -p deploy
|
||||
cp bazel-bin/src/main/scala/net/eagle0/eagle/eagle_server_deploy.jar deploy/
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build -f ci/eagle_run.Dockerfile -t ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:${{ steps.version.outputs.version }} .
|
||||
docker tag ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:${{ steps.version.outputs.version }} ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:latest
|
||||
|
||||
- name: Push to registry
|
||||
run: |
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.DO_REGISTRY_TOKEN }} --password-stdin
|
||||
docker push ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:${{ steps.version.outputs.version }}
|
||||
docker push ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:latest
|
||||
|
||||
build-shardok:
|
||||
runs-on: ubuntu-latest
|
||||
needs: []
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Set version
|
||||
id: version
|
||||
run: echo "version=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Bazel
|
||||
uses: bazelbuild/setup-bazelisk@v2
|
||||
|
||||
- name: Cache Bazel
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/bazel
|
||||
key: bazel-linux-${{ hashFiles('MODULE.bazel', 'WORKSPACE') }}
|
||||
|
||||
- name: Build Shardok server
|
||||
run: bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
mkdir -p bazel-bin/src/main/cpp/net/eagle0/shardok/
|
||||
cp bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server bazel-bin/src/main/cpp/net/eagle0/shardok/
|
||||
docker build -f ci/shardok_run.Dockerfile -t ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:${{ steps.version.outputs.version }} .
|
||||
docker tag ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:${{ steps.version.outputs.version }} ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:latest
|
||||
|
||||
- name: Push to registry
|
||||
run: |
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.DO_REGISTRY_TOKEN }} --password-stdin
|
||||
docker push ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:${{ steps.version.outputs.version }}
|
||||
docker push ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:latest
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-eagle, build-shardok]
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to DigitalOcean
|
||||
uses: appleboy/ssh-action@v1.0.0
|
||||
with:
|
||||
host: ${{ secrets.DO_DROPLET_IP }}
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
script: |
|
||||
cd /opt/eagle0
|
||||
|
||||
# Pull latest images
|
||||
docker-compose -f docker-compose.prod.yml pull
|
||||
|
||||
# Rolling restart (zero-downtime if load balancer configured)
|
||||
docker-compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||
|
||||
# Wait for health checks
|
||||
sleep 30
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
|
||||
# Cleanup old images
|
||||
docker image prune -f
|
||||
|
||||
- name: Verify deployment
|
||||
run: |
|
||||
# Health check endpoint
|
||||
curl -f https://eagle0.net/health || exit 1
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
run: |
|
||||
# Add Slack/Discord notification here
|
||||
echo "Deployment failed!"
|
||||
```
|
||||
|
||||
### Deployment Steps
|
||||
|
||||
1. **On push to main:**
|
||||
- Build Eagle JAR (self-hosted Mac runner - for consistency)
|
||||
- Build Shardok binary (GitHub-hosted Ubuntu runner)
|
||||
- Build Docker images
|
||||
- Push to DigitalOcean Container Registry
|
||||
|
||||
2. **Deploy to droplet:**
|
||||
- SSH to production server
|
||||
- Pull new images
|
||||
- `docker-compose up -d` (rolling update)
|
||||
- Verify health checks
|
||||
|
||||
3. **Rollback:**
|
||||
```bash
|
||||
# On server
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml pull eagle0/eagle-server:<previous-version>
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Setup
|
||||
|
||||
### Initial Droplet Setup
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Run on fresh DigitalOcean droplet (Ubuntu 22.04)
|
||||
|
||||
# Update system
|
||||
apt-get update && apt-get upgrade -y
|
||||
|
||||
# Install Docker
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
|
||||
# Install Docker Compose
|
||||
apt-get install -y docker-compose-plugin
|
||||
|
||||
# Create deploy user
|
||||
useradd -m -s /bin/bash -G docker deploy
|
||||
mkdir -p /home/deploy/.ssh
|
||||
# Add your SSH public key to /home/deploy/.ssh/authorized_keys
|
||||
|
||||
# Create app directory
|
||||
mkdir -p /opt/eagle0
|
||||
chown deploy:deploy /opt/eagle0
|
||||
|
||||
# Configure Docker to use DigitalOcean Container Registry
|
||||
docker login registry.digitalocean.com
|
||||
|
||||
# Create systemd service for auto-start
|
||||
cat > /etc/systemd/system/eagle0.service << 'EOF'
|
||||
[Unit]
|
||||
Description=Eagle0 Game Servers
|
||||
Requires=docker.service
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=/opt/eagle0
|
||||
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
|
||||
Group=deploy
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl enable eagle0
|
||||
```
|
||||
|
||||
### Load Balancer Configuration
|
||||
|
||||
**DigitalOcean Load Balancer settings:**
|
||||
|
||||
- **Forwarding Rules:**
|
||||
- HTTPS 443 → HTTP 40032 (Eagle)
|
||||
- gRPC is HTTP/2, handled automatically
|
||||
|
||||
- **Health Checks:**
|
||||
- Protocol: HTTP
|
||||
- Port: 40032
|
||||
- Path: `/health` (need to implement)
|
||||
|
||||
- **SSL:**
|
||||
- Let's Encrypt certificate for `eagle0.net`
|
||||
|
||||
- **Settings:**
|
||||
- Sticky sessions: Disabled (gRPC streams handle this)
|
||||
- Proxy protocol: Disabled
|
||||
|
||||
### nginx on Droplet (Alternative to LB)
|
||||
|
||||
If using nginx instead of managed LB:
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/eagle0
|
||||
upstream eagle_backend {
|
||||
server 127.0.0.1:40032;
|
||||
keepalive 100;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name eagle0.net;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/eagle0.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/eagle0.net/privkey.pem;
|
||||
|
||||
# gRPC settings
|
||||
location /net.eagle0.eagle.api.Eagle {
|
||||
grpc_pass grpc://eagle_backend;
|
||||
grpc_read_timeout 1200s;
|
||||
grpc_send_timeout 1200s;
|
||||
grpc_socket_keepalive on;
|
||||
|
||||
# Rate limiting
|
||||
limit_req zone=eagle burst=50 nodelay;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:40032/health;
|
||||
}
|
||||
}
|
||||
|
||||
# Rate limit zone
|
||||
limit_req_zone $binary_remote_addr zone=eagle:10m rate=100r/s;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Secrets Management
|
||||
|
||||
**GitHub Secrets (for CI/CD):**
|
||||
- `DO_REGISTRY_TOKEN` - DigitalOcean Container Registry token
|
||||
- `DO_DROPLET_IP` - Production server IP
|
||||
- `DO_SSH_KEY` - SSH private key for deployment
|
||||
- `OPENAI_API_KEY` - For LLM integration (if used in production)
|
||||
- `DO_SPACES_KEY` - Already exists for S3
|
||||
|
||||
**On Server (environment variables):**
|
||||
```bash
|
||||
# /opt/eagle0/.env
|
||||
GPT_MODEL_NAME=gpt-4
|
||||
OPENAI_API_KEY=sk-...
|
||||
DO_SPACES_KEY=...
|
||||
DO_SPACES_SECRET=...
|
||||
JAVA_OPTS=-Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
|
||||
```
|
||||
|
||||
### Configuration Files
|
||||
|
||||
**Production config** (`/opt/eagle0/config/eagle0.conf`):
|
||||
```
|
||||
monteCarloIterations = 150000
|
||||
monteCarloThreads = 4
|
||||
grpcAddress = 0.0.0.0:40042
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
### Logging
|
||||
|
||||
**Docker logging driver:** json-file with rotation
|
||||
- Logs stored in `/var/lib/docker/containers/<id>/`
|
||||
- Max 5 files of 100MB each
|
||||
|
||||
**Log aggregation options:**
|
||||
1. **DigitalOcean Logs** - $0 for basic, integrates with Droplets
|
||||
2. **Papertrail** - $7/mo for 1GB, good search
|
||||
3. **Self-hosted Loki** - Free, more complex
|
||||
|
||||
### Metrics
|
||||
|
||||
**Prometheus + Grafana** (optional, for advanced monitoring):
|
||||
```yaml
|
||||
# Add to docker-compose.prod.yml
|
||||
prometheus:
|
||||
image: prom/prometheus
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
ports:
|
||||
- "9090:9090"
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
|
||||
```
|
||||
|
||||
**Key metrics to track:**
|
||||
- Connection count
|
||||
- Request latency (p50, p95, p99)
|
||||
- Error rate
|
||||
- CPU/memory usage
|
||||
- Shardok AI search depth/time
|
||||
|
||||
### Alerting
|
||||
|
||||
**DigitalOcean Monitoring Alerts:**
|
||||
- CPU > 80% for 5 minutes
|
||||
- Memory > 90%
|
||||
- Disk > 85%
|
||||
- Droplet unreachable
|
||||
|
||||
**Uptime monitoring:**
|
||||
- Use UptimeRobot (free tier) or Better Uptime
|
||||
- Check `https://eagle0.net/health` every minute
|
||||
|
||||
---
|
||||
|
||||
## Cost Estimate
|
||||
|
||||
### Recommended Starting Configuration
|
||||
|
||||
| Component | Monthly Cost | Notes |
|
||||
|-----------|-------------|-------|
|
||||
| Droplet (s-2vcpu-4gb) | $24 | Eagle always-on, Shardok on-demand |
|
||||
| Spaces | ~$5 | Already paying |
|
||||
| Container Registry | $5 | For Docker images |
|
||||
| DNS | $0 | Included |
|
||||
| Bandwidth | ~$0 | 1TB free, then $0.01/GB |
|
||||
| **Total** | **~$34/mo** | |
|
||||
|
||||
### Optional Add-ons
|
||||
|
||||
| Component | Monthly Cost | Notes |
|
||||
|-----------|-------------|-------|
|
||||
| Load Balancer | +$12 | Only if need HA/failover |
|
||||
| Monitoring (Papertrail) | +$7 | Better log search |
|
||||
|
||||
### Scaling Costs
|
||||
|
||||
| Scenario | Droplet | Monthly Cost |
|
||||
|----------|---------|--------------|
|
||||
| Current (few players) | s-2vcpu-4gb | $24 |
|
||||
| Growing usage | s-4vcpu-8gb | $48 |
|
||||
| CPU-intensive battles | c-4 (dedicated) | $84 |
|
||||
| High concurrency | c-8 (dedicated) | $168 |
|
||||
| Separate Shardok (on-demand) | s-1vcpu-2gb + c-4 hourly | $12 + usage |
|
||||
|
||||
---
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Infrastructure Setup (Day 1)
|
||||
|
||||
1. Create DigitalOcean resources:
|
||||
- Droplet in SFO3 region
|
||||
- Container Registry
|
||||
- (Optional) Load Balancer
|
||||
|
||||
2. Configure DNS:
|
||||
- Point `eagle0.net` to new infrastructure
|
||||
- Keep `qa.eagle0.net` pointing to home IP
|
||||
|
||||
3. Set up server:
|
||||
- Run initial setup script
|
||||
- Configure Docker and docker-compose
|
||||
- Test SSH access
|
||||
|
||||
### Phase 2: Build Pipeline (Day 2)
|
||||
|
||||
1. Create Dockerfiles:
|
||||
- Enhance `ci/eagle_run.Dockerfile`
|
||||
- Create `ci/shardok_run.Dockerfile`
|
||||
|
||||
2. Create `docker-compose.prod.yml`
|
||||
|
||||
3. Set up GitHub Actions:
|
||||
- Add deployment workflow
|
||||
- Configure secrets
|
||||
- Test build pipeline
|
||||
|
||||
### Phase 3: Deployment (Day 3)
|
||||
|
||||
1. Deploy to production:
|
||||
- Push first images
|
||||
- Start containers
|
||||
- Verify health checks
|
||||
|
||||
2. Configure TLS:
|
||||
- Set up Let's Encrypt
|
||||
- Update nginx/LB configuration
|
||||
|
||||
3. Update DNS:
|
||||
- Switch `eagle0.net` to production
|
||||
- Verify client can connect
|
||||
|
||||
### Phase 4: Validation (Day 4)
|
||||
|
||||
1. Test gameplay:
|
||||
- Connect from Unity client
|
||||
- Play through Eagle gameplay
|
||||
- Test Shardok combat
|
||||
|
||||
2. Monitor:
|
||||
- Check logs for errors
|
||||
- Verify resource usage
|
||||
- Test reconnection behavior
|
||||
|
||||
3. Document:
|
||||
- Update runbooks
|
||||
- Document rollback procedures
|
||||
|
||||
### Phase 5: QA Environment (Day 5)
|
||||
|
||||
1. Configure `qa.eagle0.net`:
|
||||
- Keep pointing to home Mac
|
||||
- Ensure nginx routes correctly
|
||||
|
||||
2. Test environment switching:
|
||||
- Connect to production
|
||||
- Switch to QA
|
||||
- Verify different game states
|
||||
|
||||
---
|
||||
|
||||
## Rollback Procedure
|
||||
|
||||
### Quick Rollback (< 5 minutes)
|
||||
|
||||
```bash
|
||||
# SSH to production server
|
||||
ssh deploy@<droplet-ip>
|
||||
|
||||
# Roll back to previous version
|
||||
cd /opt/eagle0
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker pull registry.digitalocean.com/eagle0/eagle-server:<previous-tag>
|
||||
docker pull registry.digitalocean.com/eagle0/shardok-server:<previous-tag>
|
||||
VERSION=<previous-tag> docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Full Rollback to Home Mac
|
||||
|
||||
1. Update DNS: Point `eagle0.net` back to home IP
|
||||
2. Ensure home Mac servers are running
|
||||
3. Wait for DNS propagation (5-30 minutes)
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] SSH key authentication only (disable password)
|
||||
- [ ] Firewall configured (only 443, 22 from trusted IPs)
|
||||
- [ ] TLS 1.3 enforced
|
||||
- [ ] Secrets in environment variables, not in code
|
||||
- [ ] Container runs as non-root user
|
||||
- [ ] Rate limiting on gRPC endpoints
|
||||
- [ ] Regular security updates (`unattended-upgrades`)
|
||||
- [ ] Remove Shardok internal interface from public nginx (per CONNECTION_ARCHITECTURE.md)
|
||||
|
||||
---
|
||||
|
||||
## Resolved Questions
|
||||
|
||||
1. **Game state migration:** No migration needed. Saves are currently local only. The codebase has a `Persister` pattern (with existing AWS support) that could be adapted to save to DO Spaces in the future.
|
||||
|
||||
2. **LLM API keys:** Yes, OpenAI API keys are needed on the Eagle server. Add `OPENAI_API_KEY` to the `.env` file on the production droplet.
|
||||
|
||||
3. **Multiple regions:** Not needed for now. Single SFO3 region is sufficient.
|
||||
|
||||
## Remaining Open Questions
|
||||
|
||||
1. **Backup strategy:** Should we add automated backups for local persistence, or migrate to DO Spaces persistence first?
|
||||
|
||||
2. **Autoscaling:** Is traffic predictable enough to use fixed instance, or need autoscaling later?
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Phase 1: Infrastructure
|
||||
1. **Approve this plan** - Review and discuss any changes
|
||||
2. **Create DigitalOcean resources** - Droplet (s-2vcpu-4gb), Container Registry
|
||||
3. **Set up droplet** - Docker, deploy user, firewall, nginx with TLS
|
||||
|
||||
### Phase 2: Containerization
|
||||
4. **Implement Dockerfiles** - Eagle and Shardok containers
|
||||
5. **Implement Shardok lifecycle management** - Eagle starts/stops Shardok on-demand
|
||||
- Add `ShardokProcessManager` to Eagle server
|
||||
- Start Shardok when battle requested
|
||||
- Stop after idle timeout (5 min)
|
||||
- Health check before routing traffic
|
||||
|
||||
### Phase 3: CI/CD
|
||||
6. **Set up GitHub Actions** - Build and push Docker images
|
||||
7. **Add deployment workflow** - SSH deploy to droplet
|
||||
|
||||
### Phase 4: Migration
|
||||
8. **Deploy to production** - Push images, start services
|
||||
9. **Update DNS** - Point eagle0.net to droplet
|
||||
10. **Validate** - Test gameplay, monitor logs
|
||||
11. **Configure QA** - Ensure qa.eagle0.net still works (home Mac)
|
||||
Binary file not shown.
@@ -9,6 +9,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/config v1.28.10
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.51
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
|
||||
google.golang.org/grpc v1.68.0
|
||||
google.golang.org/protobuf v1.36.3
|
||||
)
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=
|
||||
google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
|
||||
google.golang.org/protobuf v1.26.0-rc.1 h1:7QnIQpGRHE5RnLKnESfDoxm2dTapTZua5a0kS0A+VXQ=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
|
||||
|
||||
+102
-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": 289080209,
|
||||
"__RESOLVED_ARTIFACTS_HASH": -131178107,
|
||||
"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",
|
||||
@@ -155,6 +156,18 @@
|
||||
},
|
||||
"version": "1.4.2"
|
||||
},
|
||||
"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 +176,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 +463,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 +810,23 @@
|
||||
"org.checkerframework:checker-qual",
|
||||
"org.ow2.asm:asm"
|
||||
],
|
||||
"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 +1034,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"
|
||||
],
|
||||
@@ -1451,6 +1500,29 @@
|
||||
"com.google.truth:truth": [
|
||||
"com.google.common.truth"
|
||||
],
|
||||
"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 +1531,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 +1886,7 @@
|
||||
"kotlin.annotation",
|
||||
"kotlin.collections",
|
||||
"kotlin.collections.builders",
|
||||
"kotlin.collections.jdk8",
|
||||
"kotlin.collections.unsigned",
|
||||
"kotlin.comparisons",
|
||||
"kotlin.concurrent",
|
||||
@@ -1822,24 +1895,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",
|
||||
@@ -2270,8 +2355,11 @@
|
||||
"com.google.protobuf:protobuf-java",
|
||||
"com.google.re2j:re2j",
|
||||
"com.google.truth:truth",
|
||||
"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 +2412,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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1pv-WMXReccddPwev_YG9IXEGznuGHrYjNNEZ0Rb-ZhM/export?gid=0&format=tsv" > src/main/resources/net/eagle0/shardok/settings.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1p6I5nUMcoAPHIcqikVgbBCFVnqN9dpOEVClbS_wOI7M/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/settings.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1pv-WMXReccddPwev_YG9IXEGznuGHrYjNNEZ0Rb-ZhM/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/shardok/settings.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1p6I5nUMcoAPHIcqikVgbBCFVnqN9dpOEVClbS_wOI7M/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/settings.tsv
|
||||
|
||||
bazel run //src/main/go/net/eagle0/build/settings_generator:settings_generator -- \
|
||||
${PWD}/src/main/resources/net/eagle0/eagle/settings.tsv \
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1DHEsiv4cY4gE6AX3sVH82K__mpBD1aznIYCQwQxA_F0/export?gid=0&format=tsv" > /tmp/names.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1DHEsiv4cY4gE6AX3sVH82K__mpBD1aznIYCQwQxA_F0/export?gid=0&format=tsv" | tr -d '\r' > /tmp/names.tsv
|
||||
bazel run //src/main/scala/net/eagle0/util:name_list_checker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.tsv
|
||||
bazel run //src/main/scala/net/eagle0/util:name_list_json_maker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.json
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1NhvG73HKyVE36yGpkV2oJiSIXoNqQOYTr5ArLnucYL0/export?gid=0&format=tsv" > src/main/resources/net/eagle0/shardok/battalionTypes.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1pNWiyxIks2wJ1v7jRLFD24zrKHG2AfhC-nkWmQKQGN4/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/heroes.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1RUguq5eAQprsZwOOqiCc-1dg4Urc_6iJ6awZsFU4MeI/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/beasts.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1NhvG73HKyVE36yGpkV2oJiSIXoNqQOYTr5ArLnucYL0/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/shardok/battalionTypes.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1pNWiyxIks2wJ1v7jRLFD24zrKHG2AfhC-nkWmQKQGN4/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/heroes.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1RUguq5eAQprsZwOOqiCc-1dg4Urc_6iJ6awZsFU4MeI/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/beasts.tsv
|
||||
#curl -L "https://docs.google.com/spreadsheets/d/1Z-60cJ_N1IasvqpVb5awKEkIYznEeR2IZSdli47oW88/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/province_map.tsv
|
||||
|
||||
${PWD}/scripts/dlSettings.sh
|
||||
|
||||
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
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Pre-commit hook wrapper for gazelle that fails if files are modified.
|
||||
# This ensures BUILD files are in canonical format before committing.
|
||||
|
||||
set -e
|
||||
|
||||
# Run gazelle
|
||||
bazel run //:gazelle 2>/dev/null
|
||||
|
||||
# Check if any BUILD files were modified
|
||||
if ! git diff --quiet -- '*.bazel' '**/BUILD' 'WORKSPACE*'; then
|
||||
echo ""
|
||||
echo "ERROR: gazelle modified BUILD files. Please stage the changes and retry:"
|
||||
echo ""
|
||||
git diff --name-only -- '*.bazel' '**/BUILD' 'WORKSPACE*'
|
||||
echo ""
|
||||
echo "Run: git add -u && git commit"
|
||||
exit 1
|
||||
fi
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "AbstractMCTSAI.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <fstream>
|
||||
#include <future>
|
||||
#include <iomanip>
|
||||
@@ -84,7 +85,7 @@ auto AbstractMCTSAI::Search(
|
||||
LogSearchResults(rootNode.get(), bestChild, result);
|
||||
}
|
||||
|
||||
// Dump tree if requested
|
||||
// Dump tree if explicitly requested via config
|
||||
if (!config_.debugDumpPath.empty()) { DumpTreeToFile(rootNode.get(), config_.debugDumpPath); }
|
||||
|
||||
return result;
|
||||
@@ -129,6 +130,37 @@ auto AbstractMCTSAI::BuildMCTSTree(
|
||||
// Initialize action counter
|
||||
root->totalActions = rootActions.size();
|
||||
|
||||
// CRITICAL: Do at least one expansion before entering the time-bounded loop.
|
||||
// This ensures we always have at least one child to return, even if the deadline
|
||||
// has already passed (e.g., due to debugger pause, system load, etc.)
|
||||
{
|
||||
auto* selected = MCTSSelection(root.get());
|
||||
const bool selectedIsRoot = (selected == root.get());
|
||||
const size_t childrenBeforeExpansion = root->children.size();
|
||||
|
||||
if (selected) {
|
||||
auto* expanded = MCTSExpansion(selected, engine);
|
||||
const double reward =
|
||||
MCTSSimulation(engine, *expanded->gameState, playerId_, expanded->playerFlips);
|
||||
MCTSBackpropagation(expanded, reward, config_.backpropagationPolicy);
|
||||
}
|
||||
|
||||
// Verify we actually have at least one child after the initial expansion
|
||||
if (root->children.empty()) {
|
||||
throw MCTSInternalError(
|
||||
"MCTS BuildMCTSTree: Initial expansion failed to produce any children. "
|
||||
"totalActions=" +
|
||||
std::to_string(root->totalActions) +
|
||||
", selected=" + (selected ? "non-null" : "null") +
|
||||
", selectedIsRoot=" + (selectedIsRoot ? "true" : "false") +
|
||||
", childrenBefore=" + std::to_string(childrenBeforeExpansion) +
|
||||
", childrenAfter=" + std::to_string(root->children.size()) +
|
||||
", root->CanExpand()=" + (root->CanExpand() ? "true" : "false") +
|
||||
", root->nextUntriedActionIndex=" +
|
||||
std::to_string(root->nextUntriedActionIndex));
|
||||
}
|
||||
}
|
||||
|
||||
std::atomic<int> iterations{0};
|
||||
|
||||
if (config_.useMultithreading && config_.numThreads > 1) {
|
||||
@@ -205,10 +237,19 @@ auto AbstractMCTSAI::BuildMCTSTree(
|
||||
auto AbstractMCTSAI::MCTSSelection(MCTSNode* root) const -> MCTSNode* {
|
||||
MCTSNode* current = root;
|
||||
|
||||
while (!current->isTerminal && current->depth < config_.maxTreeDepth) {
|
||||
while (current->depth < config_.maxTreeDepth) {
|
||||
// Check expansion FIRST - allows expanding "terminal" nodes that still have
|
||||
// untried actions (e.g., final round where we need to pick an action)
|
||||
if (current->CanExpand()) {
|
||||
return current; // Node has untried actions/outcomes
|
||||
} else if (!current->children.empty()) {
|
||||
}
|
||||
|
||||
// Only after expansion check: stop if terminal and fully expanded
|
||||
if (current->isTerminal) {
|
||||
break; // Terminal and no more actions to try
|
||||
}
|
||||
|
||||
if (!current->children.empty()) {
|
||||
// Choose child based on node type
|
||||
if (current->IsChanceNode()) {
|
||||
// Chance nodes: select outcome proportional to probability
|
||||
@@ -228,7 +269,9 @@ auto AbstractMCTSAI::MCTSSelection(MCTSNode* root) const -> MCTSNode* {
|
||||
|
||||
auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine) const
|
||||
-> MCTSNode* {
|
||||
if (!node->CanExpand() || node->isTerminal) {
|
||||
// Only skip if we truly can't expand. Allow expansion even if "terminal" as long as
|
||||
// there are untried actions (e.g., final round where we need to pick an action).
|
||||
if (!node->CanExpand()) {
|
||||
return node; // Nothing to expand
|
||||
}
|
||||
|
||||
@@ -317,8 +360,11 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
|
||||
// Normalize by total probability of expanded outcomes
|
||||
if (totalProbability > 0.0) {
|
||||
node->immediateScore = expectedImmediate / totalProbability;
|
||||
// Also update lookaheadScore if this is a fresh expansion
|
||||
if (node->children.size() == 1) { node->lookaheadScore = node->immediateScore; }
|
||||
// CRITICAL: Always update lookaheadScore to the expected value.
|
||||
// Without this, chance nodes keep their initial lookaheadScore from the parent
|
||||
// state (before the action), while regular actions use the child state (after).
|
||||
// This gives chance nodes an unfair initial UCB advantage.
|
||||
node->lookaheadScore = node->immediateScore;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,8 +431,14 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
|
||||
chanceNode->parent = node;
|
||||
node->children.push_back(std::move(chanceNode));
|
||||
|
||||
// Return the chance node for further expansion
|
||||
return node->children.back().get();
|
||||
// CRITICAL: Immediately expand the first outcome and return that instead.
|
||||
// If we returned the chance node itself, MCTSSimulation would run on the parent state
|
||||
// (since chance nodes have parent's gameState), which is wrong. We need to simulate
|
||||
// from an actual outcome state.
|
||||
//
|
||||
// Note: This recursion is bounded because outcome children are decision nodes,
|
||||
// not chance nodes, so the recursion goes exactly one level deep.
|
||||
return MCTSExpansion(node->children.back().get(), engine);
|
||||
}
|
||||
|
||||
// Regular (non-chance) action: create decision node directly
|
||||
|
||||
@@ -16,28 +16,39 @@
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
// Information about binary chance outcomes (success/failure)
|
||||
struct BinaryOutcomeInfo {
|
||||
double successProbability; // Probability of success (0.0 to 1.0)
|
||||
// Information about chance outcomes (supports both binary and multi-outcome)
|
||||
struct ChanceOutcomeInfo {
|
||||
std::vector<double> probabilities; // Probability of each outcome (must sum to 1.0)
|
||||
std::vector<double> rolls; // Roll values for each outcome
|
||||
|
||||
// Returns extreme roll values that guarantee success/failure against any threshold.
|
||||
//
|
||||
// NOTE: These are not truly "representative" rolls - they guarantee outcomes rather
|
||||
// than simulating typical rolls. Some commands have variance beyond success/failure
|
||||
// (e.g., BUILD_BRIDGE quality depends on roll margin). This simplification ignores
|
||||
// that variance. If outcome quality matters for AI decisions, we may need to revisit
|
||||
// this approach with actual representative rolls based on the command's threshold.
|
||||
[[nodiscard]] static std::vector<double> getRepresentativeRolls() {
|
||||
// Factory for binary success/failure outcomes (e.g., START_FIRE)
|
||||
[[nodiscard]] static ChanceOutcomeInfo binary(double successProbability) {
|
||||
// -100: triggers open-ended low sequence, succeeds against any threshold
|
||||
// 150: triggers open-ended high sequence, fails against any threshold
|
||||
return {-100.0, 150.0};
|
||||
return {{successProbability, 1.0 - successProbability}, {-100.0, 150.0}};
|
||||
}
|
||||
|
||||
[[nodiscard]] std::vector<double> getProbabilities() const {
|
||||
return {successProbability, 1.0 - successProbability};
|
||||
// Factory for multi-outcome with fixed seeds (e.g., END_TURN)
|
||||
// Uses uniformly distributed roll values to sample different random outcomes
|
||||
[[nodiscard]] static ChanceOutcomeInfo multiOutcome(int numOutcomes) {
|
||||
std::vector<double> probs(numOutcomes, 1.0 / numOutcomes);
|
||||
std::vector<double> rollValues;
|
||||
rollValues.reserve(numOutcomes);
|
||||
// Spread rolls across the percentile range: 10, 30, 50, 70, 90 for 5 outcomes
|
||||
for (int i = 0; i < numOutcomes; ++i) {
|
||||
rollValues.push_back(10.0 + (80.0 * i) / (numOutcomes - 1));
|
||||
}
|
||||
return {probs, rollValues};
|
||||
}
|
||||
|
||||
[[nodiscard]] const std::vector<double>& getRepresentativeRolls() const { return rolls; }
|
||||
|
||||
[[nodiscard]] const std::vector<double>& getProbabilities() const { return probabilities; }
|
||||
};
|
||||
|
||||
// Backward compatibility alias
|
||||
using BinaryOutcomeInfo = ChanceOutcomeInfo;
|
||||
|
||||
// Abstract interface for game engines
|
||||
class MCTSGameEngine {
|
||||
public:
|
||||
|
||||
@@ -65,11 +65,6 @@ 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,
|
||||
@@ -86,6 +81,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; }
|
||||
|
||||
@@ -32,6 +32,7 @@ cc_library(
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -67,8 +67,22 @@ bool ShardokAction::equals(const MCTSAction& other) const {
|
||||
}
|
||||
|
||||
bool ShardokAction::requiresChanceNode() const {
|
||||
// Actions with probabilistic outcomes require chance nodes
|
||||
return hasOdds_;
|
||||
// Actions with probabilistic outcomes require chance nodes:
|
||||
// 1. Binary success/failure actions (hasOdds_): START_FIRE, FEAR, etc.
|
||||
// 2. END_TURN: random effects (fire spread, weather changes)
|
||||
// 3. Combat actions: roll affects damage dealt (MELEE, ARCHERY, CHARGE, DUEL)
|
||||
if (hasOdds_) { return true; }
|
||||
|
||||
using namespace net::eagle0::shardok::common;
|
||||
switch (type_) {
|
||||
case END_TURN_COMMAND:
|
||||
case MELEE_COMMAND:
|
||||
case ARCHERY_COMMAND:
|
||||
case CHARGE_COMMAND:
|
||||
case CHALLENGE_DUEL_COMMAND:
|
||||
case REDUCE_COMMAND: return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace shardok::mcts
|
||||
@@ -140,7 +140,7 @@ std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
|
||||
|
||||
engine->PostCommand(currentPlayer, shardokAction->getIndex(), randomGen);
|
||||
|
||||
// Create and return the new state (don't cache the mutated engine)
|
||||
// Create and return the new state
|
||||
auto newState = std::make_unique<ShardokGameState>(
|
||||
engine->GetCurrentGameState(),
|
||||
scoreCalculator_,
|
||||
@@ -152,6 +152,11 @@ std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
|
||||
*alCache_,
|
||||
criticalTileCoords_);
|
||||
|
||||
// Cache the engine on the new state so score() can use it for END_TURN normalization
|
||||
// The engine's command list may be stale after the action was applied, but that's OK -
|
||||
// we'll refresh it when we call GetAvailableCommandsForAIPlayer() in score()
|
||||
newState->setCachedEngine(engine);
|
||||
|
||||
// Don't pre-compute hash - let it be computed lazily on first use
|
||||
// Many states (especially in simulation) never need their hash computed
|
||||
return newState;
|
||||
@@ -538,7 +543,7 @@ void ShardokGameEngine::resetCacheStatistics() {
|
||||
timeInLegalActionsComputation_.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
BinaryOutcomeInfo ShardokGameEngine::getBinaryOutcomeInfo(
|
||||
ChanceOutcomeInfo ShardokGameEngine::getBinaryOutcomeInfo(
|
||||
const MCTSGameState& state,
|
||||
const MCTSAction& action) const {
|
||||
const auto* shardokState = dynamic_cast<const ShardokGameState*>(&state);
|
||||
@@ -548,6 +553,33 @@ BinaryOutcomeInfo ShardokGameEngine::getBinaryOutcomeInfo(
|
||||
throw ShardokInternalErrorException("Invalid state or action type in getBinaryOutcomeInfo");
|
||||
}
|
||||
|
||||
// Check for multi-outcome commands (roll affects outcome quality, not just success/failure)
|
||||
// These use multiOutcome() with fixed seeds to sample the range of possible results
|
||||
using namespace net::eagle0::shardok::common;
|
||||
const auto commandType = static_cast<CommandType>(shardokAction->getType());
|
||||
|
||||
switch (commandType) {
|
||||
case END_TURN_COMMAND:
|
||||
// END_TURN has random effects (fire spread, weather changes)
|
||||
return ChanceOutcomeInfo::multiOutcome(5);
|
||||
|
||||
case MELEE_COMMAND:
|
||||
case ARCHERY_COMMAND:
|
||||
case CHARGE_COMMAND:
|
||||
case REDUCE_COMMAND:
|
||||
// Combat/siege commands: OpenEndedPercentile roll affects damage dealt
|
||||
// Use 5 outcomes to sample the roll distribution
|
||||
return ChanceOutcomeInfo::multiOutcome(5);
|
||||
|
||||
case CHALLENGE_DUEL_COMMAND:
|
||||
// Duels have multiple combat rounds with rolls, so outcomes vary significantly
|
||||
return ChanceOutcomeInfo::multiOutcome(5);
|
||||
|
||||
default:
|
||||
// Continue to binary outcome handling below
|
||||
break;
|
||||
}
|
||||
|
||||
const auto currentPlayer = static_cast<PlayerId>(state.currentPlayerId());
|
||||
|
||||
// Get or create the engine for this state
|
||||
@@ -577,7 +609,7 @@ BinaryOutcomeInfo ShardokGameEngine::getBinaryOutcomeInfo(
|
||||
|
||||
const auto& descriptor = descriptors->at(actionIndex);
|
||||
|
||||
// Get success probability
|
||||
// Get success probability for binary outcome actions
|
||||
if (!descriptor->HasOdds()) {
|
||||
throw ShardokInternalErrorException("Action does not have odds in getBinaryOutcomeInfo");
|
||||
}
|
||||
@@ -585,7 +617,7 @@ BinaryOutcomeInfo ShardokGameEngine::getBinaryOutcomeInfo(
|
||||
const auto successChancePercentile = descriptor->GetOddsPercentile();
|
||||
const double successProbability = static_cast<double>(successChancePercentile) / 100.0;
|
||||
|
||||
return BinaryOutcomeInfo{successProbability};
|
||||
return ChanceOutcomeInfo::binary(successProbability);
|
||||
}
|
||||
|
||||
void ShardokGameEngine::clearLegalActionsCache() { legalActionsCache_.clear(); }
|
||||
|
||||
@@ -64,7 +64,7 @@ double ShardokGameState::score(MCTSPlayerId playerId) const {
|
||||
const bool scoreFromDefenderPerspective =
|
||||
foundDefender ? requestedPlayerIsDefender : isDefender_;
|
||||
|
||||
// Call score calculator with correct perspective for the requested player
|
||||
// Score the current state directly
|
||||
return scoreCalculator_
|
||||
->GuessedStateScore(scoreFromDefenderPerspective, state_, strategy_, castleCoords_);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ vector<shared_ptr<ShardokAIClient>> ShardokGameController::MakeAIClients(
|
||||
pi.is_defender(),
|
||||
e->GetCurrentGameState()->hex_map(),
|
||||
e->GetGameSettings()->GetGetter(),
|
||||
AIAlgorithmType::MCTS,
|
||||
AIAlgorithmType::ITERATIVE_DEEPENING,
|
||||
ScoringCalculatorType::MCTS_OPTIMIZED,
|
||||
mctsConfig);
|
||||
|
||||
@@ -111,17 +111,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 +186,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 +261,7 @@ void ShardokGameController::PostPlacementCommands(
|
||||
}
|
||||
|
||||
auto ShardokGameController::GetCurrentGameStateBytes() -> byte_vector {
|
||||
scoped_lock<mutex> guard(masterLock);
|
||||
return engine->GetCurrentGameStateBytes();
|
||||
}
|
||||
|
||||
@@ -292,6 +325,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 +357,74 @@ 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
|
||||
|
||||
if (gameOver) {
|
||||
subscriber->OnGameOver(gameOverInfo);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get updates outside the lock (GetUpdates acquires masterLock internally)
|
||||
AllUpdates updates = GetUpdates(lastPushedActionId);
|
||||
lastPushedActionId = updates.newUnfilteredCount;
|
||||
|
||||
if (!updates.mainResults.empty()) {
|
||||
subscriber->OnUpdate(
|
||||
updates.mainResults,
|
||||
updates.filteredResults,
|
||||
updates.newUnfilteredCount,
|
||||
updates.currentGameState);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
#include "FireUtils.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/map/TileModifier.hpp"
|
||||
|
||||
namespace shardok {
|
||||
@@ -71,10 +73,13 @@ auto GetFireDamage(
|
||||
const double openEndedPercentileRoll1,
|
||||
const double openEndedPercentileRoll2) -> CombatDamage {
|
||||
double randomSwing = 1.0 + (2 * openEndedPercentileRoll1 - 100.0) * RANDOMNESS_FACTOR / 100.0;
|
||||
const double basicDamage = BASE_FIRE_DAMAGE_PER_TROOP * randomSwing * troops;
|
||||
// Clamp damage to minimum 0 to prevent negative damage from extreme open-ended rolls.
|
||||
// Open-ended percentile rolls can theoretically go as low as -475 (with max roll depth).
|
||||
const double basicDamage = std::max(0.0, BASE_FIRE_DAMAGE_PER_TROOP * randomSwing * troops);
|
||||
|
||||
randomSwing = 1.0 + (2 * openEndedPercentileRoll2 - 100.0) * RANDOMNESS_FACTOR / 100.0;
|
||||
const double penetratingDamage = BASE_PENETRATING_FIRE_DAMAGE_PER_TROOP * randomSwing * troops;
|
||||
const double penetratingDamage =
|
||||
std::max(0.0, BASE_PENETRATING_FIRE_DAMAGE_PER_TROOP * randomSwing * troops);
|
||||
|
||||
return CombatDamage::Builder()
|
||||
.SetFire(basicDamage)
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokException.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
auto MutatingMaybeSetMorale(Battalion *battalion, double newVal, const BattalionTypeSPtr &type)
|
||||
@@ -147,6 +149,12 @@ int32_t MutatingInternalTakeDamage(
|
||||
int32_t newNumber;
|
||||
const int32_t casualties = (int32_t)(takenDamage * baseDeadliness);
|
||||
|
||||
if (casualties < 0) {
|
||||
throw ShardokInternalErrorException(
|
||||
"Negative casualties in MutatingInternalTakeDamage: " + std::to_string(casualties) +
|
||||
" (takenDamage=" + std::to_string(takenDamage) + ")");
|
||||
}
|
||||
|
||||
if (battalion->size() > casualties) {
|
||||
newNumber = battalion->size() - casualties;
|
||||
} else {
|
||||
|
||||
@@ -19,7 +19,13 @@ using net::eagle0::shardok::api::UnitView;
|
||||
using net::eagle0::shardok::common::CommandType;
|
||||
using std::vector;
|
||||
|
||||
auto UnknownUnit(UnitId uid, PlayerId pid, BattalionTypeId bt, int size, bool hidden) -> UnitView;
|
||||
auto UnknownUnit(
|
||||
UnitId uid,
|
||||
PlayerId pid,
|
||||
BattalionTypeId bt,
|
||||
int size,
|
||||
bool hidden,
|
||||
int8_t startingPositionIndex) -> UnitView;
|
||||
|
||||
[[nodiscard]] auto UnitFilteredForPlayer(
|
||||
const SettingsGetter &settings,
|
||||
@@ -36,7 +42,8 @@ auto UnknownUnit(UnitId uid, PlayerId pid, BattalionTypeId bt, int size, bool hi
|
||||
unit->player_id(),
|
||||
unit->battalion().type(),
|
||||
unit->battalion().size(),
|
||||
true);
|
||||
true,
|
||||
unit->starting_position_index());
|
||||
}
|
||||
if (IsUnplaced(unit->location()) && !visibleToAsker) {
|
||||
return UnknownUnit(
|
||||
@@ -44,7 +51,8 @@ auto UnknownUnit(UnitId uid, PlayerId pid, BattalionTypeId bt, int size, bool hi
|
||||
unit->player_id(),
|
||||
unit->battalion().type(),
|
||||
unit->battalion().size(),
|
||||
false);
|
||||
false,
|
||||
unit->starting_position_index());
|
||||
}
|
||||
|
||||
UnitView filtered{};
|
||||
@@ -142,7 +150,8 @@ auto UnknownUnit(
|
||||
const PlayerId pid,
|
||||
BattalionTypeId bt,
|
||||
const int size,
|
||||
const bool hidden) -> UnitView {
|
||||
const bool hidden,
|
||||
const int8_t startingPositionIndex) -> UnitView {
|
||||
UnitView uv;
|
||||
uv.set_unit_id(uid);
|
||||
uv.set_player_id(pid);
|
||||
@@ -154,6 +163,11 @@ auto UnknownUnit(
|
||||
uv.mutable_battalion()->set_size(size);
|
||||
uv.set_hidden(hidden);
|
||||
|
||||
// starting_position_index is public info - defenders know attacker spawn directions
|
||||
if (startingPositionIndex != -1) {
|
||||
uv.mutable_starting_position_index()->set_value(startingPositionIndex);
|
||||
}
|
||||
|
||||
uv.set_my_knowledge(0);
|
||||
|
||||
return uv;
|
||||
|
||||
@@ -529,6 +529,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
|
||||
|
||||
@@ -33,6 +33,7 @@ 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;
|
||||
@@ -78,6 +79,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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+11
-2
@@ -174,7 +174,8 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
|
||||
});
|
||||
}
|
||||
|
||||
private void Register() { _persistentClientConnection.Subscribe(this); }
|
||||
// Fire-and-forget - subscription is awaited internally and failures are logged
|
||||
private void Register() { _ = _persistentClientConnection.Subscribe(this); }
|
||||
|
||||
public GameId? CurrentShardokToken(string shardokGameId) { return _shardokModel.History.Count; }
|
||||
|
||||
@@ -187,9 +188,14 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
|
||||
foreach (var resp in gameUpdate.ShardokActionResultResponse
|
||||
.ShardokGameResponses) {
|
||||
_shardokModel.HandleUpdates(
|
||||
var updatesOk = _shardokModel.HandleUpdates(
|
||||
resp.ActionResultViews,
|
||||
resp.NewResultViewCount);
|
||||
if (!updatesOk) {
|
||||
// In custom battle mode, just clear and continue
|
||||
_shardokModel.History.Clear();
|
||||
continue;
|
||||
}
|
||||
_shardokModel.HandleAvailableCommands(resp.AvailableCommands);
|
||||
}
|
||||
|
||||
@@ -443,4 +449,7 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
|
||||
} };
|
||||
|
||||
public List<IClientConnectionSubscriber.StreamingTextStatus> StreamingTextStatuses => new();
|
||||
|
||||
// CustomBattleHandler only handles Shardok updates, not Eagle, so no count to update
|
||||
public void UpdateResultCounts(GameUpdate update) {}
|
||||
}
|
||||
|
||||
+6
-2
@@ -24,7 +24,7 @@ namespace eagle {
|
||||
|
||||
public void OnTextUpdate(string text, bool completed) { SetUp(); }
|
||||
|
||||
public string TextId() { return CurrentEntry.GeneratedTextId; }
|
||||
public string TextId() { return _entries.Count > 0 ? CurrentEntry.GeneratedTextId : null; }
|
||||
|
||||
private void OnEnable() {
|
||||
ClientTextProvider.Provider.AddListener(this);
|
||||
@@ -41,11 +41,13 @@ namespace eagle {
|
||||
public IList<ChronicleEntry> Entries {
|
||||
get => _entries;
|
||||
set {
|
||||
var wasEmpty = _entries.Count == 0;
|
||||
_entries = value != null ? value.ToList() : new List<ChronicleEntry>();
|
||||
|
||||
if (_entries.Count == 0) return;
|
||||
|
||||
if (!gameObject.activeSelf) {
|
||||
// Jump to the last entry when first populating, or if not currently viewing
|
||||
if (wasEmpty || !gameObject.activeSelf) {
|
||||
_currentIndex = _entries.Count - 1;
|
||||
|
||||
ScrollToTop();
|
||||
@@ -60,6 +62,8 @@ namespace eagle {
|
||||
|
||||
private const string TitleSplitPattern = @"\n\s*=====\s*\n";
|
||||
private void SetUp() {
|
||||
if (_entries.Count == 0) return;
|
||||
|
||||
previousButton.interactable = _currentIndex > 0;
|
||||
nextButton.interactable = _currentIndex < _entries.Count - 1;
|
||||
|
||||
|
||||
+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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -86,5 +86,17 @@ namespace eagle {
|
||||
}
|
||||
|
||||
public void ToggleClicked(bool value) { SetCostLabel(); }
|
||||
|
||||
public override void AddTargetedHero(HeroId heroId) {
|
||||
// Find the index of the clicked hero in the divinable heroes list
|
||||
var divinableArray = DivineCommand.DivinableHeroes.ToArray();
|
||||
for (int i = 0; i < divinableArray.Length; i++) {
|
||||
if (divinableArray[i].Hero.Id == heroId) {
|
||||
_selectedHeroIndex = i;
|
||||
SetUpUI();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Net.Eagle0.Eagle.Api;
|
||||
using Net.Eagle0.Eagle.Api.Command.Util;
|
||||
@@ -9,6 +10,7 @@ using UnityEngine.UI;
|
||||
|
||||
namespace eagle {
|
||||
using ProvinceId = Int32;
|
||||
using HeroId = Int32;
|
||||
|
||||
public class ManagePrisonersCommandSelector : CommandSelector {
|
||||
// Unity accessors
|
||||
@@ -36,6 +38,26 @@ namespace eagle {
|
||||
|
||||
public override AvailableCommand.SealedValueOneofCase CommandType =>
|
||||
AvailableCommand.SealedValueOneofCase.ManagePrisonerCommand;
|
||||
|
||||
public override List<HeroId> TargetedHeroIds => new() { SelectedHero.Id };
|
||||
|
||||
public override bool HeroIsTargetable(HeroId heroId) {
|
||||
if (_availableCommand == null || _availableCommand.ManagePrisonerCommand == null) {
|
||||
return false;
|
||||
}
|
||||
return ManagePrisonersCommand.Prisoners.Any(p => p.Prisoner.Hero.Id == heroId);
|
||||
}
|
||||
|
||||
public override void AddTargetedHero(HeroId heroId) {
|
||||
// Find the prisoner with this heroId and select it
|
||||
for (int i = 0; i < ManagePrisonersCommand.Prisoners.Count; i++) {
|
||||
if (ManagePrisonersCommand.Prisoners[i].Prisoner.Hero.Id == heroId) {
|
||||
_selectedHeroIndex = i;
|
||||
DisplaySelectedHero();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
public override string HeaderString => "Manage Prisoners";
|
||||
public override string CommitButtonString =>
|
||||
$"Commit {DisplayNames.PrisonerManagementTypeName(SelectedOption)}";
|
||||
|
||||
+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 =>
|
||||
|
||||
+18
-2
@@ -30,10 +30,26 @@ namespace eagle {
|
||||
public override string HeaderString => "Recruit Heroes";
|
||||
public override string CommitButtonString => "Commit Recruit";
|
||||
|
||||
public override bool HeroIsTargetable(HeroId heroId) =>
|
||||
RecruitHeroesCommand.AvailableHeroes.Any(euh => euh.Hero.Id == heroId);
|
||||
public override bool HeroIsTargetable(HeroId heroId) {
|
||||
if (_availableCommand == null || _availableCommand.RecruitHeroesCommand == null) {
|
||||
return false;
|
||||
}
|
||||
return RecruitHeroesCommand.AvailableHeroes.Any(euh => euh.Hero.Id == heroId);
|
||||
}
|
||||
public override List<HeroId> TargetedHeroIds => new List<HeroId> { SelectedHero.Hero.Id };
|
||||
|
||||
public override void AddTargetedHero(HeroId heroId) {
|
||||
// Find the hero in available heroes and select it
|
||||
var availableHeroes = RecruitHeroesCommand.AvailableHeroes.ToList();
|
||||
for (int i = 0; i < availableHeroes.Count; i++) {
|
||||
if (availableHeroes[i].Hero.Id == heroId) {
|
||||
_selectedHeroIndex = i;
|
||||
DisplayHero();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DisplayHero() {
|
||||
heroDetails.SetHero(SelectedHero.Hero, _model);
|
||||
statusText.text = DisplayNames.UnaffiliatedHeroStatus(SelectedHero.Type);
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using common;
|
||||
|
||||
namespace eagle {
|
||||
/// <summary>
|
||||
/// Circuit breaker pattern for connection failures.
|
||||
/// Prevents cascading failures by blocking connection attempts during server outages.
|
||||
/// </summary>
|
||||
public class ConnectionCircuitBreaker {
|
||||
public enum State {
|
||||
Closed, // Normal operation, allowing connections
|
||||
Open, // Too many failures, blocking connections
|
||||
HalfOpen // Testing if service recovered
|
||||
}
|
||||
|
||||
private const int FailureThreshold = 5; // Open after 5 failures
|
||||
private const double OpenTimeoutSeconds = 60.0; // Wait 60s before test
|
||||
private const double SuccessResetThreshold = 3; // Close after 3 successes
|
||||
|
||||
private State _state = State.Closed;
|
||||
private int _failureCount = 0;
|
||||
private int _successCount = 0;
|
||||
private DateTime? _openedAt = null;
|
||||
|
||||
private readonly Logger _logger = Logger.GetLogger("ConnectionLogger");
|
||||
|
||||
public State CurrentState {
|
||||
get {
|
||||
lock (this) { return _state; }
|
||||
}
|
||||
}
|
||||
|
||||
public DateTime? NextTestAttempt {
|
||||
get {
|
||||
lock (this) {
|
||||
if (_state == State.Open && _openedAt.HasValue) {
|
||||
return _openedAt.Value.AddSeconds(OpenTimeoutSeconds);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a connection attempt should be allowed.
|
||||
/// </summary>
|
||||
public bool ShouldAttemptConnection() {
|
||||
lock (this) {
|
||||
switch (_state) {
|
||||
case State.Closed: return true; // Normal operation
|
||||
|
||||
case State.HalfOpen: return true; // Allow test attempt
|
||||
|
||||
case State.Open:
|
||||
// Check if timeout has elapsed
|
||||
if (_openedAt.HasValue) {
|
||||
var elapsed = (DateTime.UtcNow - _openedAt.Value).TotalSeconds;
|
||||
if (elapsed >= OpenTimeoutSeconds) {
|
||||
// Transition to half-open for test
|
||||
_state = State.HalfOpen;
|
||||
_logger.LogLine(
|
||||
$"[CIRCUIT] OPEN → HALF_OPEN (testing after {elapsed:F1}s)");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false; // Still in open state, block connection
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a successful connection.
|
||||
/// </summary>
|
||||
public void RecordSuccess() {
|
||||
lock (this) {
|
||||
if (_state == State.HalfOpen) {
|
||||
// Test succeeded, close circuit
|
||||
_state = State.Closed;
|
||||
_failureCount = 0;
|
||||
_successCount = 0;
|
||||
_openedAt = null;
|
||||
_logger.LogLine($"[CIRCUIT] HALF_OPEN → CLOSED (test succeeded)");
|
||||
} else if (_state == State.Closed) {
|
||||
// Normal success, increment counter
|
||||
_successCount++;
|
||||
if (_failureCount > 0) {
|
||||
_failureCount = Math.Max(0, _failureCount - 1); // Decay failures
|
||||
}
|
||||
if (_failureCount == 0 && _successCount >= SuccessResetThreshold) {
|
||||
_successCount = 0; // Reset success counter
|
||||
_logger.LogLine($"[CIRCUIT] CLOSED (connection stable)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a connection failure.
|
||||
/// </summary>
|
||||
public void RecordFailure() {
|
||||
lock (this) {
|
||||
_failureCount++;
|
||||
_successCount = 0; // Reset success counter on any failure
|
||||
|
||||
if (_state == State.HalfOpen) {
|
||||
// Test attempt failed, reopen circuit
|
||||
_state = State.Open;
|
||||
_openedAt = DateTime.UtcNow;
|
||||
_logger.LogLine(
|
||||
$"[CIRCUIT] HALF_OPEN → OPEN (test failed, failures={_failureCount})");
|
||||
} else if (_failureCount >= FailureThreshold && _state == State.Closed) {
|
||||
// Too many failures, open circuit
|
||||
_state = State.Open;
|
||||
_openedAt = DateTime.UtcNow;
|
||||
_logger.LogLine(
|
||||
$"[CIRCUIT] CLOSED → OPEN (failures={_failureCount}, threshold={FailureThreshold})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public string GetStatusMessage() {
|
||||
lock (this) {
|
||||
switch (_state) {
|
||||
case State.Closed:
|
||||
return _failureCount > 0
|
||||
? $"Connection recovering ({_failureCount} recent failures)"
|
||||
: "Connection healthy";
|
||||
|
||||
case State.HalfOpen: return "Testing connection...";
|
||||
|
||||
case State.Open:
|
||||
if (_openedAt.HasValue) {
|
||||
var timeUntilTest = OpenTimeoutSeconds -
|
||||
(DateTime.UtcNow - _openedAt.Value).TotalSeconds;
|
||||
if (timeUntilTest > 0) {
|
||||
return $"Server unavailable. Retrying in {Math.Min(10, (int)timeUntilTest)}s";
|
||||
}
|
||||
}
|
||||
return "Server unavailable. Testing...";
|
||||
|
||||
default: return "Unknown state";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1c49d026769d746aeac2ceff7c1f5dc4
|
||||
@@ -0,0 +1,169 @@
|
||||
using System;
|
||||
using Net.Eagle0.Eagle.Api;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace eagle {
|
||||
/// <summary>
|
||||
/// Provides game state information for the connection status UI.
|
||||
/// Implement this interface to show game-specific status when connected.
|
||||
/// </summary>
|
||||
public interface IGameStateProvider {
|
||||
/// <summary>Server-reported game status. Null if no status received yet.</summary>
|
||||
ServerGameStatus ServerStatus { get; }
|
||||
|
||||
/// <summary>True if a command was submitted and we're awaiting response (for >
|
||||
/// 500ms).</summary>
|
||||
bool IsProcessingCommand { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple UI component to display connection status and reconnection countdown.
|
||||
/// Attach to a TextMeshProUGUI component to display status.
|
||||
/// </summary>
|
||||
public class ConnectionStatusUI : MonoBehaviour {
|
||||
private TextMeshProUGUI _textComponent;
|
||||
private PersistentClientConnection _connection;
|
||||
private IGameStateProvider _gameStateProvider;
|
||||
|
||||
[Tooltip("Optional button to force immediate reconnection when server is down")]
|
||||
public Button retryButton;
|
||||
|
||||
// Update interval in seconds
|
||||
private const float UpdateInterval = 0.5f;
|
||||
private float _timeSinceLastUpdate = 0f;
|
||||
|
||||
void Start() {
|
||||
_textComponent = GetComponent<TextMeshProUGUI>();
|
||||
if (_textComponent == null) {
|
||||
Debug.LogError(
|
||||
"ConnectionStatusUI must be attached to a TextMeshProUGUI component");
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (retryButton != null) {
|
||||
retryButton.onClick.AddListener(OnRetryClicked);
|
||||
retryButton.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the connection to monitor. Call this after creating the connection.
|
||||
/// </summary>
|
||||
public void SetConnection(PersistentClientConnection connection) {
|
||||
_connection = connection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the game state provider for showing game-specific status.
|
||||
/// Call this when entering a game, clear it when leaving.
|
||||
/// </summary>
|
||||
public void SetGameStateProvider(IGameStateProvider provider) {
|
||||
_gameStateProvider = provider;
|
||||
}
|
||||
|
||||
void Update() {
|
||||
if (_connection == null || _textComponent == null) { return; }
|
||||
|
||||
_timeSinceLastUpdate += Time.deltaTime;
|
||||
if (_timeSinceLastUpdate < UpdateInterval) { return; }
|
||||
|
||||
_timeSinceLastUpdate = 0f;
|
||||
UpdateDisplay();
|
||||
}
|
||||
|
||||
private void UpdateDisplay() {
|
||||
// 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 = Math.Min(10, (int)Math.Ceiling(timeUntilTest.TotalSeconds));
|
||||
_textComponent.text =
|
||||
$"<color=red>●</color> Server down. Retry in {seconds}s";
|
||||
return;
|
||||
}
|
||||
}
|
||||
_textComponent.text = "<color=red>●</color> Server unavailable";
|
||||
return;
|
||||
} else if (circuitState == ConnectionCircuitBreaker.State.HalfOpen) {
|
||||
SetRetryButtonVisible(false);
|
||||
_textComponent.text = "<color=yellow>●</color> Testing connection...";
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal connection state display
|
||||
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...",
|
||||
ConnectionState.Disconnected => "<color=red>●</color> Disconnected",
|
||||
ConnectionState.Reconnecting => GetReconnectingText(nextAttempt),
|
||||
ConnectionState.SubscriptionPending => "<color=yellow>●</color> Subscribing...",
|
||||
_ => "<color=gray>●</color> Unknown"
|
||||
};
|
||||
|
||||
_textComponent.text = statusText;
|
||||
}
|
||||
|
||||
private string GetConnectedStatusText() {
|
||||
// If no game state provider, just show "Connected"
|
||||
if (_gameStateProvider == null) { return "<color=green>●</color> Connected"; }
|
||||
|
||||
// Processing takes priority (client knows it submitted a command)
|
||||
if (_gameStateProvider.IsProcessingCommand) {
|
||||
return "<color=green>●</color> Processing...";
|
||||
}
|
||||
|
||||
// Use server-reported status
|
||||
var serverStatus = _gameStateProvider.ServerStatus;
|
||||
if (serverStatus == null) {
|
||||
// No server status yet - waiting for first response
|
||||
return "<color=green>●</color> Connected";
|
||||
}
|
||||
|
||||
return serverStatus.Status switch {
|
||||
ServerGameStatus.Types.Status.YourTurn => "<color=green>●</color> Your turn",
|
||||
ServerGameStatus.Types.Status.WaitingForPlayers =>
|
||||
"<color=green>●</color> Waiting for other players",
|
||||
ServerGameStatus.Types.Status.GeneratingText =>
|
||||
"<color=green>●</color> Generating...",
|
||||
ServerGameStatus.Types.Status.ProcessingAction =>
|
||||
"<color=green>●</color> Processing...",
|
||||
_ => "<color=green>●</color> Connected"
|
||||
};
|
||||
}
|
||||
|
||||
private string GetReconnectingText(DateTime? nextAttempt) {
|
||||
if (!nextAttempt.HasValue) { return "<color=yellow>●</color> Reconnecting..."; }
|
||||
|
||||
var timeUntilRetry = nextAttempt.Value - DateTime.UtcNow;
|
||||
if (timeUntilRetry.TotalSeconds <= 0) {
|
||||
return "<color=yellow>●</color> Reconnecting...";
|
||||
}
|
||||
|
||||
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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 727a520b2deac4a28ae9c6a00e54c2e7
|
||||
+34
-11
@@ -24,8 +24,8 @@ namespace eagle {
|
||||
public RollPanelController rollPanelController;
|
||||
|
||||
public TextMeshProUGUI roundStatusLabel;
|
||||
public TextMeshProUGUI widthLabel;
|
||||
public TextMeshProUGUI heightLabel;
|
||||
public TextMeshProUGUI connectionStatusLabel;
|
||||
private bool _connectionStatusUIInitialized = false;
|
||||
|
||||
public GameObject alwaysOnLeftColumn;
|
||||
public GameObject factionsAndMovingArmiesRow;
|
||||
@@ -110,8 +110,15 @@ namespace eagle {
|
||||
|
||||
if (newWidth == _lastWidth && newHeight == _lastHeight) { return; }
|
||||
|
||||
widthLabel.text = newWidth.ToString();
|
||||
heightLabel.text = newHeight.ToString();
|
||||
// Initialize ConnectionStatusUI component once when connection is available
|
||||
if (!_connectionStatusUIInitialized &&
|
||||
errorHandler.PersistentClientConnection != null) {
|
||||
var statusUI = connectionStatusLabel.GetComponent<ConnectionStatusUI>();
|
||||
if (statusUI != null) {
|
||||
statusUI.SetConnection(errorHandler.PersistentClientConnection);
|
||||
_connectionStatusUIInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
_lastWidth = newWidth;
|
||||
_lastHeight = newHeight;
|
||||
@@ -158,6 +165,10 @@ namespace eagle {
|
||||
Model = null;
|
||||
chronicleCanvasController.Entries = new List<ChronicleEntry>();
|
||||
SetMusic();
|
||||
|
||||
// Clear game state provider when leaving game
|
||||
var statusUI = connectionStatusLabel.GetComponent<ConnectionStatusUI>();
|
||||
if (statusUI != null) { statusUI.SetGameStateProvider(null); }
|
||||
}
|
||||
|
||||
void MapControllerChangedTarget(List<ProvinceId> newTarget) {
|
||||
@@ -177,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(); }
|
||||
|
||||
@@ -215,11 +229,11 @@ namespace eagle {
|
||||
|
||||
void OnApplicationPause(bool pause) {
|
||||
if (ModelUpdater == null) { return; }
|
||||
if (pause) {
|
||||
ModelUpdater.StopListeningForUpdates();
|
||||
} else {
|
||||
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
|
||||
@@ -255,7 +269,13 @@ namespace eagle {
|
||||
|
||||
ModelUpdater.ErrorHandler = errorHandler;
|
||||
|
||||
MainQueue.Q.EnqueueForNextUpdate(() => { ModelUpdater.StartListeningForUpdates(); });
|
||||
// Set up game state provider for connection status UI
|
||||
var statusUI = connectionStatusLabel.GetComponent<ConnectionStatusUI>();
|
||||
if (statusUI != null) { statusUI.SetGameStateProvider(ModelUpdater); }
|
||||
|
||||
// Fire-and-forget - subscription is awaited internally and failures are logged
|
||||
MainQueue.Q.EnqueueForNextUpdate(
|
||||
() => { _ = ModelUpdater.StartListeningForUpdates(); });
|
||||
|
||||
#if UNITY_EDITOR
|
||||
EditorApplication.playModeStateChanged += (state) => OnPlayModeStateChanged(state);
|
||||
@@ -294,6 +314,7 @@ namespace eagle {
|
||||
movingArmiesTableController.UpdateTables();
|
||||
factionsTableController.UpdateTables();
|
||||
heroesAndBattalionsPanelController.UpdateTables();
|
||||
freeHeroesTableController.UpdateUnaffiliatedHeroSelections();
|
||||
}
|
||||
|
||||
private void UpdateButtons() {
|
||||
@@ -649,7 +670,9 @@ namespace eagle {
|
||||
var shardokModel = Model.ShardokGameModels[selectedModel.ShardokGameId];
|
||||
|
||||
shardokCanvas.gameObject.SetActive(true);
|
||||
shardokCanvas.GetComponent<ShardokGameController>().SetUpGame(shardokModel);
|
||||
var shardokController = shardokCanvas.GetComponent<ShardokGameController>();
|
||||
shardokController.SetUpGame(shardokModel);
|
||||
shardokController.SetConnection(errorHandler.PersistentClientConnection);
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
@@ -42,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
|
||||
@@ -61,7 +62,7 @@ namespace eagle {
|
||||
RollFetcher RollFetcher { get; }
|
||||
}
|
||||
|
||||
public class GameModelUpdater : IClientConnectionSubscriber {
|
||||
public class GameModelUpdater : IClientConnectionSubscriber, IGameStateProvider {
|
||||
private FactionId? PlayerId => _currentModel.PlayerId;
|
||||
public Int64? CurrentEagleToken => _currentModel.CommandToken;
|
||||
public Int64? CurrentShardokToken(string shardokGameId) {
|
||||
@@ -94,13 +95,24 @@ namespace eagle {
|
||||
|
||||
public long GameId { get; }
|
||||
|
||||
public int LastUnfilteredResultCount => _lastUnfilteredResultCount;
|
||||
public int LastUnfilteredResultCount {
|
||||
get {
|
||||
lock (_resultCountLock) { return _lastUnfilteredResultCount; }
|
||||
}
|
||||
}
|
||||
|
||||
public List<IClientConnectionSubscriber.ShardokViewStatus> ShardokViewStatuses =>
|
||||
_currentModel.ShardokGameModels
|
||||
.Select(sgm => new IClientConnectionSubscriber.ShardokViewStatus {
|
||||
shardokGameId = sgm.Key,
|
||||
filteredResultCount = sgm.Value.History.Count()
|
||||
.Select(sgm => {
|
||||
var needsResync = _shardokNeedsResync.GetValueOrDefault(sgm.Key, false);
|
||||
// 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,
|
||||
requestFullResync = needsResync
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -117,10 +129,27 @@ namespace eagle {
|
||||
|
||||
public ErrorHandler ErrorHandler;
|
||||
|
||||
// Thread-safe: updated from gRPC thread via UpdateResultCounts, read from main thread
|
||||
private int _lastUnfilteredResultCount = 0;
|
||||
private readonly object _resultCountLock = new();
|
||||
|
||||
private readonly Logger _connectionLogger = Logger.GetLogger("ConnectionLogger");
|
||||
|
||||
// Track when a command was submitted for "Processing..." display
|
||||
// Only show "Processing..." if command has been pending for > 500ms
|
||||
private DateTime? _commandSubmittedTime;
|
||||
private const double ProcessingDisplayDelayMs = 500.0;
|
||||
|
||||
// Track which Shardok games need full state resync after connection drop
|
||||
// Thread-safe: accessed from both connection thread and Unity main thread
|
||||
private readonly ConcurrentDictionary<string, bool> _shardokNeedsResync =
|
||||
new ConcurrentDictionary<string, bool>();
|
||||
|
||||
// Thread-safe Shardok result counts: updated from gRPC thread via UpdateResultCounts
|
||||
// Used by ShardokViewStatuses to report accurate counts even when MainQueue is blocked
|
||||
private readonly ConcurrentDictionary<string, int> _shardokResultCounts =
|
||||
new ConcurrentDictionary<string, int>();
|
||||
|
||||
private readonly RollFetcher _rollFetcher;
|
||||
|
||||
// State synced with server
|
||||
@@ -169,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)) {
|
||||
@@ -211,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>();
|
||||
|
||||
@@ -219,8 +250,16 @@ namespace eagle {
|
||||
}
|
||||
|
||||
private ShardokGameModel MakeGameModel(ShardokGameId shardokGameId) {
|
||||
var battleView =
|
||||
_currentModel.ShardokBattles.First(b => b.ShardokGameId == shardokGameId);
|
||||
var battleView = _currentModel.ShardokBattles.FirstOrDefault(
|
||||
b => b.ShardokGameId == shardokGameId);
|
||||
|
||||
if (battleView == null) {
|
||||
// Battle was removed (e.g., it ended) before we could create the model.
|
||||
// This is expected when Eagle's RemovedBattleIds update arrives before a
|
||||
// pending Shardok update - the UI already shows "Back to Eagle" via
|
||||
// MarkBattleEnded(), so we just skip this stale update.
|
||||
return null;
|
||||
}
|
||||
|
||||
PlayerId playerId = battleView.MyPlayerId ?? -1;
|
||||
|
||||
@@ -266,8 +305,18 @@ namespace eagle {
|
||||
|
||||
switch (updateItem.GameUpdateDetailsCase) {
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
|
||||
_lastUnfilteredResultCount =
|
||||
updateItem.ActionResultResponse.UnfilteredResultCountAfter;
|
||||
// Clear processing state - we received a response from the server
|
||||
_commandSubmittedTime = null;
|
||||
|
||||
// Store server-reported game status for UI
|
||||
if (updateItem.ActionResultResponse.ServerGameStatus != null) {
|
||||
ServerStatus = updateItem.ActionResultResponse.ServerGameStatus;
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
if (updateItem.ActionResultResponse.ActionResultViews.Any() ||
|
||||
updateItem.ActionResultResponse.AvailableCommands == null ||
|
||||
updateItem.ActionResultResponse.AvailableCommands.Token !=
|
||||
@@ -289,18 +338,45 @@ namespace eagle {
|
||||
out var shardokGameModel)) {
|
||||
shardokGameModel =
|
||||
MakeGameModel(shardokGameId: oneResponse.ShardokGameId);
|
||||
|
||||
// Battle may have ended before we could create the model - remove
|
||||
// any stale reference and skip this update
|
||||
if (shardokGameModel == null) {
|
||||
_currentModel.ShardokGameModels.TryRemove(
|
||||
oneResponse.ShardokGameId,
|
||||
out _);
|
||||
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
shardokGameModel.HandleUpdates(
|
||||
var updatesOk = shardokGameModel.HandleUpdates(
|
||||
oneResponse.ActionResultViews,
|
||||
oneResponse.NewResultViewCount);
|
||||
|
||||
if (!updatesOk) {
|
||||
// Missing results - mark for resync and clear history
|
||||
MarkShardokForResync(oneResponse.ShardokGameId);
|
||||
shardokGameModel.History.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
shardokGameModel.HandleAvailableCommands(oneResponse.AvailableCommands);
|
||||
|
||||
// Clear resync flag after successfully receiving updates
|
||||
ClearShardokResyncFlag(oneResponse.ShardokGameId);
|
||||
|
||||
if (shardokGameModel.GameStatus.State ==
|
||||
GameStatus.Types.State.GameRunning ||
|
||||
shardokGameModel.GameStatus.State == GameStatus.Types.State.SetUp) {
|
||||
_currentModel.ShardokGameModels[oneResponse.ShardokGameId] =
|
||||
shardokGameModel;
|
||||
} else {
|
||||
// Game ended - remove from active models so UI knows battle is over
|
||||
_currentModel.ShardokGameModels.TryRemove(
|
||||
oneResponse.ShardokGameId,
|
||||
out _);
|
||||
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
|
||||
}
|
||||
}
|
||||
if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
|
||||
@@ -321,10 +397,67 @@ namespace eagle {
|
||||
}
|
||||
}
|
||||
|
||||
public void StartListeningForUpdates() { PersistentConnection.Subscribe(this); }
|
||||
/// <summary>
|
||||
/// Update result counts immediately when an update is received from the server.
|
||||
/// Called from the gRPC thread BEFORE enqueueing to MainQueue, to ensure
|
||||
/// reconnects use accurate counts even when MainQueue is blocked (e.g., backgrounded).
|
||||
/// </summary>
|
||||
public void UpdateResultCounts(GameUpdate update) {
|
||||
switch (update.GameUpdateDetailsCase) {
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
|
||||
lock (_resultCountLock) {
|
||||
_lastUnfilteredResultCount =
|
||||
update.ActionResultResponse.UnfilteredResultCountAfter;
|
||||
}
|
||||
break;
|
||||
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
|
||||
foreach (var response in update.ShardokActionResultResponse
|
||||
.ShardokGameResponses) {
|
||||
_shardokResultCounts[response.ShardokGameId] = response.NewResultViewCount;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to game updates. Returns true if subscription was acknowledged by server.
|
||||
/// </summary>
|
||||
public async Task<bool> StartListeningForUpdates() {
|
||||
return await PersistentConnection.Subscribe(this);
|
||||
}
|
||||
|
||||
public void StopListeningForUpdates() { PersistentConnection.Unsubscribe(this); }
|
||||
|
||||
/// <summary>
|
||||
/// Mark a Shardok game for full state resync on next connection.
|
||||
/// Used after connection drops to ensure state consistency.
|
||||
/// </summary>
|
||||
public void MarkShardokForResync(string shardokGameId) {
|
||||
_shardokNeedsResync[shardokGameId] = true;
|
||||
_connectionLogger.LogLine(
|
||||
$"[RESYNC] Marked Shardok game {shardokGameId} for full state resync");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear resync flag after successfully receiving full state.
|
||||
/// </summary>
|
||||
public void ClearShardokResyncFlag(string shardokGameId) {
|
||||
if (_shardokNeedsResync.TryRemove(shardokGameId, out _)) {
|
||||
_connectionLogger.LogLine(
|
||||
$"[RESYNC] Cleared resync flag for Shardok game {shardokGameId}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mark all active Shardok games for resync (called on disconnect).
|
||||
/// </summary>
|
||||
public void MarkAllShardokForResync() {
|
||||
foreach (var shardokGameId in _currentModel.ShardokGameModels.Keys) {
|
||||
MarkShardokForResync(shardokGameId);
|
||||
}
|
||||
}
|
||||
|
||||
public Task PostCommand(ProvinceId provinceId, SelectedCommand command) {
|
||||
_connectionLogger.LogLine(
|
||||
$"Posting command with token {_currentModel.CommandTokenString}");
|
||||
@@ -332,6 +465,7 @@ namespace eagle {
|
||||
_currentModel.AvailableCommandsByProvince.Clear();
|
||||
_currentModel.CommandToken = null;
|
||||
_currentModel.LastPostedToken = token;
|
||||
_commandSubmittedTime = DateTime.UtcNow;
|
||||
return PersistentConnection.PostEagleCommand(
|
||||
gameId: GameId,
|
||||
token: token,
|
||||
@@ -343,10 +477,39 @@ namespace eagle {
|
||||
}
|
||||
|
||||
private void HandleStartingState(GameStateView startingState) {
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff");
|
||||
_connectionLogger.LogLine(
|
||||
$"[STATE_RESYNC] timestamp={timestamp} round={startingState.CurrentRoundId} factions={startingState.Factions.Count} heroes={startingState.Heroes.Count}");
|
||||
|
||||
_currentModel.GsView = startingState;
|
||||
_currentModel.BattalionTypes =
|
||||
startingState.BattalionTypes.ToDictionary(bt => bt.TypeId, bt => bt);
|
||||
_currentModel.ChronicleEntries = startingState.ChronicleEntries.ToList();
|
||||
|
||||
// For any outstanding battles not in ShardokGameModels, create models and mark for
|
||||
// resync. This ensures fresh clients get Shardok state for ongoing battles.
|
||||
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) {
|
||||
@@ -644,6 +807,15 @@ namespace eagle {
|
||||
foreach (ShardokBattleView bv in entry.NewBattles) _currentModel.ShardokBattles.Add(bv);
|
||||
|
||||
foreach (string rb in entry.RemovedBattleIds) {
|
||||
// If there's an active ShardokGameModel for this battle, mark it as ended
|
||||
// so the UI knows to return to Eagle. This handles the race condition where
|
||||
// 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.TryRemove(rb, out _);
|
||||
}
|
||||
_shardokResultCounts.TryRemove(rb, out _);
|
||||
|
||||
for (int i = 0; i < _currentModel.ShardokBattles.Count; i++) {
|
||||
if (_currentModel.ShardokBattles[i].ShardokGameId == rb) {
|
||||
_currentModel.ShardokBattles.RemoveAt(i);
|
||||
@@ -689,5 +861,21 @@ namespace eagle {
|
||||
Notify(result);
|
||||
}
|
||||
}
|
||||
|
||||
#region IGameStateProvider implementation
|
||||
|
||||
/// <summary>Server-reported game status from the last ActionResultResponse.</summary>
|
||||
public ServerGameStatus ServerStatus { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// True if a command was submitted and we're awaiting response.
|
||||
/// Only returns true if processing for > 500ms to avoid flashing.
|
||||
/// </summary>
|
||||
public bool IsProcessingCommand =>
|
||||
_commandSubmittedTime.HasValue &&
|
||||
(DateTime.UtcNow - _commandSubmittedTime.Value).TotalMilliseconds >
|
||||
ProcessingDisplayDelayMs;
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -59,7 +59,7 @@ namespace eagle {
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateUnaffiliatedHeroSelections() {
|
||||
public void UpdateUnaffiliatedHeroSelections() {
|
||||
UnaffiliatedHeroes.Each(
|
||||
(uh, i) => SetUnaffiliatedHeroRowSelections(
|
||||
unaffiliatedHeroesTable.ComponentAt<UnaffiliatedHeroRowController>(i),
|
||||
|
||||
+11
-3
@@ -18,10 +18,11 @@ namespace eagle {
|
||||
if (scrollRect) { scrollRect.normalizedPosition = new Vector2(0, 1); }
|
||||
}
|
||||
|
||||
// Always update the view when TextId changes to clear any stale text
|
||||
if (!String.IsNullOrEmpty(_textId)) {
|
||||
// If the text ID is set, we want to update the view immediately
|
||||
// to reflect any existing text.
|
||||
OnTextUpdate(ClientTextProvider.Provider.GetTextEntry(TextId));
|
||||
} else {
|
||||
UpdateView();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +60,14 @@ namespace eagle {
|
||||
}
|
||||
|
||||
private void OnTextUpdate(TextEntry entry) {
|
||||
if (entry != null) OnTextUpdate(entry.Text, entry.Completed);
|
||||
if (entry != null) {
|
||||
OnTextUpdate(entry.Text, entry.Completed);
|
||||
} else {
|
||||
// Entry doesn't exist yet - clear text and update view to avoid stale content
|
||||
_currentText = "";
|
||||
_currentCompleted = false;
|
||||
UpdateView();
|
||||
}
|
||||
}
|
||||
|
||||
public void OnTextUpdate(string text, bool completed) {
|
||||
|
||||
+8
@@ -11,10 +11,18 @@ namespace eagle {
|
||||
|
||||
public void ReceiveGameUpdate(GameUpdate update);
|
||||
|
||||
/// <summary>
|
||||
/// Update the known result counts immediately when an update is received.
|
||||
/// Called from the gRPC thread BEFORE enqueueing to MainQueue, to ensure
|
||||
/// reconnects don't request stale data while MainQueue is blocked.
|
||||
/// </summary>
|
||||
public void UpdateResultCounts(GameUpdate update);
|
||||
|
||||
// Used for registering for stream updates
|
||||
struct ShardokViewStatus {
|
||||
public string shardokGameId;
|
||||
public Int32 filteredResultCount;
|
||||
public bool requestFullResync; // Request full state instead of delta
|
||||
}
|
||||
|
||||
struct StreamingTextStatus {
|
||||
|
||||
@@ -21,6 +21,8 @@ namespace eagle {
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
private void SetDefaultProvinceColor(ProvinceId provinceId) {
|
||||
// Model may be null during reconnection when UI is being reset
|
||||
if (Model?.Provinces == null || !Model.Provinces.ContainsKey(provinceId)) { return; }
|
||||
SetProvinceColor(provinceId, ColorForProvince(Model.Provinces[provinceId]));
|
||||
}
|
||||
|
||||
|
||||
+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();
|
||||
|
||||
@@ -17,6 +17,10 @@ namespace eagle {
|
||||
|
||||
private readonly Queue<Notification> _notes = new();
|
||||
|
||||
// Incremented when DismissAll is clicked; pending AddNote calls check this
|
||||
// to skip adding if a dismiss happened since they were enqueued
|
||||
private int _dismissGeneration = 0;
|
||||
|
||||
private void SetPopupInfos() {
|
||||
PopupInfos = _notes.Select(note => new PopupInfo {
|
||||
titleText = note.Title,
|
||||
@@ -40,15 +44,30 @@ namespace eagle {
|
||||
}
|
||||
}
|
||||
|
||||
// Compare hero lists by ID since HeroView is a protobuf message with reference equality
|
||||
private static bool HeroListsMatch(List<HeroView> a, List<HeroView> b) {
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
if (a.Count != b.Count) return false;
|
||||
return a.Select(h => h.Id).SequenceEqual(b.Select(h => h.Id));
|
||||
}
|
||||
|
||||
public void AddNote(
|
||||
string title,
|
||||
string text,
|
||||
string llmId,
|
||||
List<ProvinceId> provinceIds,
|
||||
List<HeroView> displayedHeroes) {
|
||||
// Capture current generation - if DismissAll is clicked before this executes,
|
||||
// we'll skip adding the note
|
||||
var capturedGeneration = _dismissGeneration;
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
// Skip if DismissAll was clicked since this was enqueued
|
||||
if (capturedGeneration != _dismissGeneration) return;
|
||||
|
||||
var existingNote = _notes.FirstOrDefault(
|
||||
n => n.Title == title && n.DisplayedHeroes.SequenceEqual(displayedHeroes));
|
||||
n => n.Title == title &&
|
||||
HeroListsMatch(n.DisplayedHeroes, displayedHeroes));
|
||||
|
||||
if (existingNote == null) {
|
||||
_notes.Enqueue(new Notification(
|
||||
@@ -70,6 +89,8 @@ namespace eagle {
|
||||
}
|
||||
|
||||
public void DismissAllClicked() {
|
||||
// Increment generation immediately so pending AddNote calls will skip
|
||||
_dismissGeneration++;
|
||||
_notes.Clear();
|
||||
SetPopupInfos();
|
||||
}
|
||||
|
||||
+3
@@ -37,6 +37,9 @@ namespace eagle.Notifications {
|
||||
{ OutlawSpottedDetails, OutlawSpottedDetailsNotificationGenerator.Generator },
|
||||
{ PrisonerExchangeDetails, PrisonerExchangeDetailsNotificationGenerator.Generator },
|
||||
{ PrisonerExecutedDetails, PrisonerExecutedDetailsNotificationGenerator.Generator },
|
||||
{ PrisonerReleasedDetails, PrisonerReleasedDetailsNotificationGenerator.Generator },
|
||||
{ PrisonerExiledDetails, PrisonerExiledDetailsNotificationGenerator.Generator },
|
||||
{ PrisonerReturnedDetails, PrisonerReturnedDetailsNotificationGenerator.Generator },
|
||||
{ ProvinceHeldDetails, ProvinceHeldDetailsNotificationGenerator.Generator },
|
||||
{ QuestFailed, QuestFailedDetailsNotificationGenerator.Generator },
|
||||
{ QuestFulfilled, QuestFulfilledDetailsNotificationGenerator.Generator },
|
||||
|
||||
+3
-2
@@ -43,11 +43,12 @@ namespace eagle.Notifications.ARNNotifications {
|
||||
heroPlaceholders["ExecutedHero"] = (hero.NameTextId, "the prisoner");
|
||||
}
|
||||
|
||||
yield return new DynamicTextNotification(
|
||||
yield return DynamicTextNotification.StreamingDynamicNotification(
|
||||
title: noteTitle,
|
||||
textTemplate: textTemplate,
|
||||
heroPlaceholders: heroPlaceholders,
|
||||
singleProvinceId: note.ProvinceId,
|
||||
llmId: notification.LlmId,
|
||||
provinceIds: new List<int> { note.ProvinceId },
|
||||
displayedHeroes: new List<HeroView> { factionLeader, hero });
|
||||
}
|
||||
}
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Generic;
|
||||
using Net.Eagle0.Eagle.Views;
|
||||
|
||||
namespace eagle.Notifications.ARNNotifications {
|
||||
public static class PrisonerExiledDetailsNotificationGenerator {
|
||||
public static readonly ARNNotificationGenerator Generator = GenerateNotifications;
|
||||
|
||||
private static IEnumerable<Notification> GenerateNotifications(
|
||||
Net.Eagle0.Eagle.Common.Notification notification,
|
||||
IGameModel currentModel) {
|
||||
var note = notification.Details.PrisonerExiledDetails;
|
||||
|
||||
var hero = currentModel.Heroes[note.ExiledHeroId];
|
||||
var exilingFactionName = currentModel.FactionName(note.ExilingFactionId);
|
||||
|
||||
var factionLeader =
|
||||
currentModel.Heroes[currentModel.ActiveFactions[note.ExilingFactionId]
|
||||
.FactionHeadId];
|
||||
|
||||
string noteTitle = "Prisoner Exiled";
|
||||
string textTemplate;
|
||||
|
||||
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)>();
|
||||
|
||||
if (note.LastFactionId is {} lastFactionId) {
|
||||
var lastFaction = currentModel.MaybeDestroyedFaction(lastFactionId);
|
||||
|
||||
string victimDescription;
|
||||
if (lastFaction.FactionHeadId == hero.Id) {
|
||||
victimDescription = "faction leader {HeroName}";
|
||||
} else if (lastFaction.Leaders.Contains(hero.Id)) {
|
||||
victimDescription =
|
||||
$"{DisplayNames.ResolvedFactionString(lastFaction, currentModel)}'s sworn {DisplayNames.SiblingDescription(hero.PronounGender)} {{HeroName}}";
|
||||
} else {
|
||||
victimDescription =
|
||||
$"{DisplayNames.ResolvedFactionString(lastFaction, currentModel)}'s hero {{HeroName}}";
|
||||
}
|
||||
|
||||
textTemplate = $"{exilingFactionName} has exiled {victimDescription}!\n\n";
|
||||
heroPlaceholders["HeroName"] = (hero.NameTextId, "the hero");
|
||||
} else {
|
||||
textTemplate = $"{exilingFactionName} has exiled {{ExiledHero}}!\n\n";
|
||||
heroPlaceholders["ExiledHero"] = (hero.NameTextId, "the prisoner");
|
||||
}
|
||||
|
||||
yield return DynamicTextNotification.StreamingDynamicNotification(
|
||||
title: noteTitle,
|
||||
textTemplate: textTemplate,
|
||||
heroPlaceholders: heroPlaceholders,
|
||||
llmId: notification.LlmId,
|
||||
provinceIds: new List<int> { note.ProvinceId },
|
||||
displayedHeroes: new List<HeroView> { factionLeader, hero });
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: be877c33ab14e4b37b6402ca0826efa1
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Generic;
|
||||
using Net.Eagle0.Eagle.Views;
|
||||
|
||||
namespace eagle.Notifications.ARNNotifications {
|
||||
public static class PrisonerReleasedDetailsNotificationGenerator {
|
||||
public static readonly ARNNotificationGenerator Generator = GenerateNotifications;
|
||||
|
||||
private static IEnumerable<Notification> GenerateNotifications(
|
||||
Net.Eagle0.Eagle.Common.Notification notification,
|
||||
IGameModel currentModel) {
|
||||
var note = notification.Details.PrisonerReleasedDetails;
|
||||
|
||||
var hero = currentModel.Heroes[note.ReleasedHeroId];
|
||||
var releasingFactionName = currentModel.FactionName(note.ReleasingFactionId);
|
||||
|
||||
var factionLeader =
|
||||
currentModel.Heroes[currentModel.ActiveFactions[note.ReleasingFactionId]
|
||||
.FactionHeadId];
|
||||
|
||||
string noteTitle = "Prisoner Released";
|
||||
string textTemplate;
|
||||
|
||||
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)>();
|
||||
|
||||
if (note.LastFactionId is {} lastFactionId) {
|
||||
var lastFaction = currentModel.MaybeDestroyedFaction(lastFactionId);
|
||||
|
||||
string victimDescription;
|
||||
if (lastFaction.FactionHeadId == hero.Id) {
|
||||
victimDescription = "faction leader {HeroName}";
|
||||
} else if (lastFaction.Leaders.Contains(hero.Id)) {
|
||||
victimDescription =
|
||||
$"{DisplayNames.ResolvedFactionString(lastFaction, currentModel)}'s sworn {DisplayNames.SiblingDescription(hero.PronounGender)} {{HeroName}}";
|
||||
} else {
|
||||
victimDescription =
|
||||
$"{DisplayNames.ResolvedFactionString(lastFaction, currentModel)}'s hero {{HeroName}}";
|
||||
}
|
||||
|
||||
textTemplate = $"{releasingFactionName} has released {victimDescription}!\n\n";
|
||||
heroPlaceholders["HeroName"] = (hero.NameTextId, "the hero");
|
||||
} else {
|
||||
textTemplate = $"{releasingFactionName} has released {{ReleasedHero}}!\n\n";
|
||||
heroPlaceholders["ReleasedHero"] = (hero.NameTextId, "the prisoner");
|
||||
}
|
||||
|
||||
yield return DynamicTextNotification.StreamingDynamicNotification(
|
||||
title: noteTitle,
|
||||
textTemplate: textTemplate,
|
||||
heroPlaceholders: heroPlaceholders,
|
||||
llmId: notification.LlmId,
|
||||
provinceIds: new List<int> { note.ProvinceId },
|
||||
displayedHeroes: new List<HeroView> { factionLeader, hero });
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 21603b7e9604d459ea6141801d820f2e
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using System.Collections.Generic;
|
||||
using Net.Eagle0.Eagle.Views;
|
||||
|
||||
namespace eagle.Notifications.ARNNotifications {
|
||||
public static class PrisonerReturnedDetailsNotificationGenerator {
|
||||
public static readonly ARNNotificationGenerator Generator = GenerateNotifications;
|
||||
|
||||
private static IEnumerable<Notification> GenerateNotifications(
|
||||
Net.Eagle0.Eagle.Common.Notification notification,
|
||||
IGameModel currentModel) {
|
||||
var note = notification.Details.PrisonerReturnedDetails;
|
||||
|
||||
var hero = currentModel.Heroes[note.ReturnedHeroId];
|
||||
var returningFactionName = currentModel.FactionName(note.ReturningFactionId);
|
||||
var toFactionName = currentModel.FactionName(note.ToFactionId);
|
||||
|
||||
var factionLeader =
|
||||
currentModel.Heroes[currentModel.ActiveFactions[note.ReturningFactionId]
|
||||
.FactionHeadId];
|
||||
|
||||
string noteTitle = "Prisoner Returned";
|
||||
string textTemplate;
|
||||
|
||||
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)>();
|
||||
|
||||
if (note.LastFactionId is {} lastFactionId) {
|
||||
var lastFaction = currentModel.MaybeDestroyedFaction(lastFactionId);
|
||||
|
||||
string victimDescription;
|
||||
if (lastFaction.FactionHeadId == hero.Id) {
|
||||
victimDescription = "faction leader {HeroName}";
|
||||
} else if (lastFaction.Leaders.Contains(hero.Id)) {
|
||||
victimDescription =
|
||||
$"{DisplayNames.ResolvedFactionString(lastFaction, currentModel)}'s sworn {DisplayNames.SiblingDescription(hero.PronounGender)} {{HeroName}}";
|
||||
} else {
|
||||
victimDescription =
|
||||
$"{DisplayNames.ResolvedFactionString(lastFaction, currentModel)}'s hero {{HeroName}}";
|
||||
}
|
||||
|
||||
textTemplate =
|
||||
$"{returningFactionName} has returned {victimDescription} to {toFactionName}!\n\n";
|
||||
heroPlaceholders["HeroName"] = (hero.NameTextId, "the hero");
|
||||
} else {
|
||||
textTemplate =
|
||||
$"{returningFactionName} has returned {{ReturnedHero}} to {toFactionName}!\n\n";
|
||||
heroPlaceholders["ReturnedHero"] = (hero.NameTextId, "the prisoner");
|
||||
}
|
||||
|
||||
yield return DynamicTextNotification.StreamingDynamicNotification(
|
||||
title: noteTitle,
|
||||
textTemplate: textTemplate,
|
||||
heroPlaceholders: heroPlaceholders,
|
||||
llmId: notification.LlmId,
|
||||
provinceIds: new List<int> { note.ProvinceId },
|
||||
displayedHeroes: new List<HeroView> { factionLeader, hero });
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9c378d55ca4214d53b63c2bb14a614a4
|
||||
+152
-12
@@ -1,19 +1,134 @@
|
||||
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 Random Random = new();
|
||||
|
||||
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 profession switch { Profession.Mage => "Mage",
|
||||
Profession.Necromancer => "Necromancer",
|
||||
Profession.Engineer => "Engineer",
|
||||
Profession.Paladin => "Paladin",
|
||||
Profession.Ranger => "Ranger",
|
||||
Profession.Champion => "Champion",
|
||||
_ => "Unknown" };
|
||||
return ProfessionNames.TryGetValue(profession, out var name) ? name : "Unknown";
|
||||
}
|
||||
|
||||
private static string GetArticle(string word) {
|
||||
if (string.IsNullOrEmpty(word)) return "a";
|
||||
char firstChar = char.ToLower(word[0]);
|
||||
return (firstChar == 'a' || firstChar == 'e' || firstChar == 'i' || firstChar == 'o' ||
|
||||
firstChar == 'u')
|
||||
? "an"
|
||||
: "a";
|
||||
}
|
||||
|
||||
private static string GetProfessionTitle(Profession profession) {
|
||||
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(
|
||||
@@ -21,20 +136,45 @@ 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} gained the {professionName} profession.\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") }
|
||||
};
|
||||
|
||||
yield return DynamicTextNotification.StreamingDynamicNotification(
|
||||
title: "Profession Gained",
|
||||
title: GetProfessionTitle(details.NewProfession),
|
||||
textTemplate: textTemplate,
|
||||
heroPlaceholders: heroPlaceholders,
|
||||
llmId: notification.LlmId,
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 11e2f9347861144c8983e71df3abef74
|
||||
+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);
|
||||
}
|
||||
|
||||
+5
@@ -102,6 +102,11 @@ namespace eagle.Notifications {
|
||||
}
|
||||
|
||||
public void Append(string text, List<ProvinceId> provinceIds) {
|
||||
// Skip if this exact text is already in the notification (prevents duplicates
|
||||
// when the same update is processed multiple times, e.g., after resuming from
|
||||
// background)
|
||||
if (Text.Contains(text)) { return; }
|
||||
|
||||
if (ShouldAppend) {
|
||||
Text += "\n" + text;
|
||||
ProvinceIds.AddRange(provinceIds);
|
||||
|
||||
+569
-109
@@ -16,6 +16,14 @@ namespace eagle {
|
||||
using ShardokGameId = String;
|
||||
using ProvinceId = Int32;
|
||||
|
||||
public enum ConnectionState {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Reconnecting,
|
||||
SubscriptionPending // Connected but waiting for subscription acknowledgment
|
||||
}
|
||||
|
||||
public interface ILobbySubscriber {
|
||||
void ReceiveLobbyUpdate(LobbyResponse lobbyResponse);
|
||||
void ReceivePregeneratedTextUpdate(PregeneratedTextResponse response);
|
||||
@@ -26,20 +34,106 @@ namespace eagle {
|
||||
|
||||
public class PersistentClientConnection : IDisposable {
|
||||
private const double TimeoutSeconds = 300.0;
|
||||
private const double HeartbeatTimerSeconds = 10.0;
|
||||
// Idle timeout = 2x HTTP/2 keepalive interval (15s * 2 = 30s)
|
||||
private const double MaxIdleSeconds = 30.0;
|
||||
|
||||
private DateTime _lastResponseReceived = DateTime.UtcNow;
|
||||
private int _lastIdleWarningLevel = 0; // 0=none, 1=10s warning, 2=20s warning
|
||||
private readonly Metadata _credentials;
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
|
||||
private readonly Logger _remoteEagleClientLogger = Logger.GetLogger("ConnectionLogger");
|
||||
private readonly Logger _timingsLogger = Logger.GetLogger("ConnectionLogger");
|
||||
private Timer _timer = null;
|
||||
private volatile bool _isConnecting = false;
|
||||
private Timer _idleCheckTimer = null;
|
||||
private Timer _heartbeatTimer = null;
|
||||
private const double HeartbeatIntervalSeconds = 10.0;
|
||||
|
||||
// Connection metrics for diagnostics
|
||||
private DateTime? _lastConnectAttempt = null;
|
||||
private DateTime? _lastSuccessfulConnect = null;
|
||||
private DateTime? _lastDisconnect = null;
|
||||
private StatusCode? _lastDisconnectReason = null;
|
||||
|
||||
// Exponential backoff for reconnection
|
||||
private int _consecutiveFailures = 0;
|
||||
private const double MinBackoffSeconds = 2.0;
|
||||
private const double MaxBackoffSeconds = 32.0;
|
||||
|
||||
// Connection state for UI monitoring
|
||||
private ConnectionState _currentState = ConnectionState.Disconnected;
|
||||
public ConnectionState CurrentState => _currentState;
|
||||
public DateTime? NextReconnectAttempt { get; private set; } = null;
|
||||
|
||||
// Circuit breaker for preventing cascading failures
|
||||
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");
|
||||
Task.Run(() => Connect());
|
||||
}
|
||||
|
||||
private DateTime? GetDeadlineFromNow() {
|
||||
return DateTime.UtcNow.AddSeconds(TimeoutSeconds);
|
||||
}
|
||||
|
||||
private int GetTotalShardokGames() {
|
||||
int total = 0;
|
||||
lock (this) {
|
||||
foreach (var sub in _subscribers.Values) { total += sub.ShardokViewStatuses.Count; }
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private void
|
||||
LogConnectionEvent(string eventType, string details = null, StatusCode? statusCode = null) {
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff");
|
||||
var shardokCount = GetTotalShardokGames();
|
||||
var shardokInfo = shardokCount > 0 ? $" shardok_games={shardokCount}" : "";
|
||||
var statusInfo = statusCode.HasValue ? $" status={statusCode.Value}" : "";
|
||||
var detailsInfo = !string.IsNullOrEmpty(details) ? $" details=\"{details}\"" : "";
|
||||
var timeSinceLastConnect =
|
||||
_lastSuccessfulConnect.HasValue
|
||||
? $" seconds_since_connect={(DateTime.UtcNow - _lastSuccessfulConnect.Value).TotalSeconds:F1}"
|
||||
: "";
|
||||
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[CONNECTION] timestamp={timestamp} event={eventType}{shardokInfo}{statusInfo}{detailsInfo}{timeSinceLastConnect}");
|
||||
}
|
||||
|
||||
private double GetBackoffSeconds() {
|
||||
if (_consecutiveFailures == 0) { return MinBackoffSeconds; }
|
||||
|
||||
// Exponential backoff: 2, 4, 8, 16, 32 (capped)
|
||||
double backoff = MinBackoffSeconds * Math.Pow(2, _consecutiveFailures);
|
||||
return Math.Min(backoff, MaxBackoffSeconds);
|
||||
}
|
||||
|
||||
private void ScheduleReconnect(string reason) {
|
||||
double backoffSeconds = GetBackoffSeconds();
|
||||
_consecutiveFailures++;
|
||||
_currentState = ConnectionState.Reconnecting;
|
||||
NextReconnectAttempt = DateTime.UtcNow.AddSeconds(backoffSeconds);
|
||||
|
||||
LogConnectionEvent(
|
||||
"schedule_reconnect",
|
||||
$"{reason}, backoff={backoffSeconds:F1}s, attempt={_consecutiveFailures}");
|
||||
|
||||
_retryTimer?.Dispose();
|
||||
_retryTimer = new Timer();
|
||||
_retryTimer.AutoReset = false;
|
||||
_retryTimer.Interval = backoffSeconds * 1000;
|
||||
_retryTimer.Elapsed += (sender, args) => Task.Run(() => Connect());
|
||||
_retryTimer.Enabled = true;
|
||||
}
|
||||
|
||||
private readonly Eagle.EagleClient _grpcClient;
|
||||
private AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> _streamingCall;
|
||||
|
||||
@@ -51,6 +145,11 @@ namespace eagle {
|
||||
|
||||
private List<PostCommandRequest> _pendingCommands = new();
|
||||
|
||||
// Pending subscription acknowledgments - used to wait for server confirmation
|
||||
private readonly Dictionary<EagleGameId, TaskCompletionSource<SubscriptionAck>>
|
||||
_pendingSubscriptionAcks = new();
|
||||
private const int SubscriptionAckTimeoutMs = 10000; // 10 seconds
|
||||
|
||||
public PersistentClientConnection(
|
||||
Eagle.EagleClient grpcClient,
|
||||
Metadata credentials,
|
||||
@@ -60,17 +159,27 @@ namespace eagle {
|
||||
_cancellationToken = cancellationToken;
|
||||
}
|
||||
|
||||
private void StreamOneGame(IClientConnectionSubscriber subscriber) {
|
||||
/// <summary>
|
||||
/// Sends a subscription request for a game and awaits server acknowledgment.
|
||||
/// Returns true if the subscription was acknowledged successfully, false otherwise.
|
||||
/// IMPORTANT: Must be called outside of lock(this) to allow awaiting.
|
||||
/// </summary>
|
||||
private async Task<bool> StreamOneGameAsync(IClientConnectionSubscriber subscriber) {
|
||||
var gameId = subscriber.GameId;
|
||||
var streamGameRequest = new StreamGameRequest {
|
||||
GameId = subscriber.GameId,
|
||||
GameId = gameId,
|
||||
UnfilteredResultCount = subscriber.LastUnfilteredResultCount
|
||||
};
|
||||
|
||||
streamGameRequest.ShardokViewStatuses.AddRange(subscriber.ShardokViewStatuses.Select(
|
||||
// Get Shardok statuses (may include resync flags)
|
||||
var shardokStatuses = subscriber.ShardokViewStatuses;
|
||||
streamGameRequest.ShardokViewStatuses.AddRange(shardokStatuses.Select(
|
||||
status => new StreamGameRequest.Types.ShardokViewStatus {
|
||||
FilteredResultCount = status.filteredResultCount,
|
||||
ShardokGameId = status.shardokGameId
|
||||
ShardokGameId = status.shardokGameId,
|
||||
RequestFullResync = status.requestFullResync
|
||||
}));
|
||||
|
||||
streamGameRequest.StreamingTextStatuses.AddRange(
|
||||
subscriber.StreamingTextStatuses.Select(
|
||||
status => new StreamGameRequest.Types.StreamingTextStatus {
|
||||
@@ -81,50 +190,205 @@ namespace eagle {
|
||||
|
||||
var request = new UpdateStreamRequest { StreamGameRequest = streamGameRequest };
|
||||
|
||||
DoWithStreamingCall(async (sc) => {
|
||||
// Create a TaskCompletionSource to wait for the server's acknowledgment
|
||||
var ackTcs = new TaskCompletionSource<SubscriptionAck>();
|
||||
lock (_pendingSubscriptionAcks) { _pendingSubscriptionAcks[gameId] = ackTcs; }
|
||||
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SUBSCRIBE] Sending StreamGameRequest for game {gameId}, " +
|
||||
$"unfilteredCount={subscriber.LastUnfilteredResultCount}");
|
||||
|
||||
var sendSuccess = await DoWithStreamingCall(async (sc) => {
|
||||
await sc.RequestStream.WriteAsync(request);
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SUBSCRIBE] StreamGameRequest sent successfully for game {gameId}");
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!sendSuccess) {
|
||||
// Write failed - clean up and return
|
||||
lock (_pendingSubscriptionAcks) { _pendingSubscriptionAcks.Remove(gameId); }
|
||||
LogConnectionEvent("subscribe_send_failed", $"game={gameId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wait for server acknowledgment with timeout
|
||||
using var timeoutCts = new CancellationTokenSource();
|
||||
try {
|
||||
var ackTask = ackTcs.Task;
|
||||
var timeoutTask = Task.Delay(SubscriptionAckTimeoutMs, timeoutCts.Token);
|
||||
var completedTask = await Task.WhenAny(ackTask, timeoutTask);
|
||||
|
||||
if (completedTask == timeoutTask) {
|
||||
// Timeout waiting for ack
|
||||
lock (_pendingSubscriptionAcks) { _pendingSubscriptionAcks.Remove(gameId); }
|
||||
LogConnectionEvent("subscribe_ack_timeout", $"game={gameId}");
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SUBSCRIBE] Timeout waiting for SubscriptionAck for game {gameId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cancel the timeout task since ack was received
|
||||
timeoutCts.Cancel();
|
||||
|
||||
var ack = await ackTask;
|
||||
if (ack.Success) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SUBSCRIBE] Subscription confirmed for game {gameId}, " +
|
||||
$"confirmedResultCount={ack.ConfirmedResultCount}");
|
||||
|
||||
// Note: Shardok resync flags are cleared in EagleGameModel.HandleOneGameUpdate
|
||||
// AFTER updates are actually received, not here. This ensures that if the
|
||||
// connection drops between acknowledgment and update delivery, the resync
|
||||
// will be requested again on the next reconnect.
|
||||
|
||||
return true;
|
||||
} else {
|
||||
LogConnectionEvent(
|
||||
"subscribe_ack_failed",
|
||||
$"game={gameId}, error={ack.ErrorMessage}");
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SUBSCRIBE] Server rejected subscription for game {gameId}: {ack.ErrorMessage}");
|
||||
return false;
|
||||
}
|
||||
} catch (OperationCanceledException) {
|
||||
// Task was cancelled (e.g., connection drop) - not an error
|
||||
lock (_pendingSubscriptionAcks) { _pendingSubscriptionAcks.Remove(gameId); }
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SUBSCRIBE] Cancelled waiting for ack for game {gameId}");
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
lock (_pendingSubscriptionAcks) { _pendingSubscriptionAcks.Remove(gameId); }
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SUBSCRIBE] Exception waiting for ack for game {gameId}: {e.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Connect() {
|
||||
_remoteEagleClientLogger.LogLine($"Connect() called");
|
||||
|
||||
// Dispose existing streaming call before creating new one
|
||||
lock (this) {
|
||||
_streamingCall?.Dispose();
|
||||
_streamingCall = null;
|
||||
|
||||
// Cancel and dispose existing thread cancellation token
|
||||
_threadCancellationTokenSource?.Cancel();
|
||||
_threadCancellationTokenSource?.Dispose();
|
||||
_threadCancellationTokenSource = new CancellationTokenSource();
|
||||
_currentThreadToken = _threadCancellationTokenSource.Token;
|
||||
// Prevent concurrent connection attempts
|
||||
if (_isConnecting) {
|
||||
_remoteEagleClientLogger.LogLine($"Connect() skipped - already connecting");
|
||||
return;
|
||||
}
|
||||
|
||||
var call = _grpcClient.StreamUpdates(
|
||||
headers: _credentials,
|
||||
deadline: GetDeadlineFromNow(),
|
||||
cancellationToken: _cancellationToken);
|
||||
Queue<PostCommandRequest> pendingCommands = new Queue<PostCommandRequest>();
|
||||
try {
|
||||
lock (this) {
|
||||
_streamingCall = call;
|
||||
_streamingCallThread = new Thread(HandleStreamingCall);
|
||||
_streamingCallThread.Start();
|
||||
// Check circuit breaker
|
||||
if (!_circuitBreaker.ShouldAttemptConnection()) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[CIRCUIT] Connection attempt blocked - circuit {_circuitBreaker.CurrentState}");
|
||||
LogConnectionEvent("connect_blocked", $"circuit={_circuitBreaker.CurrentState}");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var kv in _subscribers) { StreamOneGame(kv.Value); }
|
||||
_isConnecting = true;
|
||||
try {
|
||||
_lastConnectAttempt = DateTime.UtcNow;
|
||||
_currentState = _consecutiveFailures > 0 ? ConnectionState.Reconnecting
|
||||
: ConnectionState.Connecting;
|
||||
NextReconnectAttempt = null;
|
||||
LogConnectionEvent("connect_attempt");
|
||||
|
||||
// Dispose existing streaming call before creating new one
|
||||
lock (this) {
|
||||
_streamingCall?.Dispose();
|
||||
_streamingCall = null;
|
||||
|
||||
// Cancel and dispose existing thread cancellation token
|
||||
_threadCancellationTokenSource?.Cancel();
|
||||
_threadCancellationTokenSource?.Dispose();
|
||||
_threadCancellationTokenSource = new CancellationTokenSource();
|
||||
_currentThreadToken = _threadCancellationTokenSource.Token;
|
||||
}
|
||||
|
||||
await TryPendingCommands();
|
||||
} catch (Exception e) {
|
||||
_remoteEagleClientLogger.LogLine($"Got exception {e} in Connect()");
|
||||
lock (this) {
|
||||
while (pendingCommands.Any()) {
|
||||
_pendingCommands.Add(pendingCommands.Dequeue());
|
||||
var call = _grpcClient.StreamUpdates(
|
||||
headers: _credentials,
|
||||
deadline: GetDeadlineFromNow(),
|
||||
cancellationToken: _cancellationToken);
|
||||
Queue<PostCommandRequest> pendingCommands = new Queue<PostCommandRequest>();
|
||||
try {
|
||||
List<IClientConnectionSubscriber> subscribersToStream;
|
||||
lock (this) {
|
||||
_streamingCall = call;
|
||||
_streamingCallThread = new Thread(HandleStreamingCall);
|
||||
_streamingCallThread.Start();
|
||||
|
||||
// Collect subscribers under lock
|
||||
subscribersToStream = _subscribers.Values.ToList();
|
||||
}
|
||||
|
||||
// Stream subscriptions OUTSIDE lock (can await)
|
||||
// Set state to SubscriptionPending while waiting for acks
|
||||
if (subscribersToStream.Any()) {
|
||||
_currentState = ConnectionState.SubscriptionPending;
|
||||
}
|
||||
|
||||
bool allSucceeded = true;
|
||||
foreach (var subscriber in subscribersToStream) {
|
||||
if (!await StreamOneGameAsync(subscriber)) { allSucceeded = false; }
|
||||
}
|
||||
|
||||
if (!allSucceeded) {
|
||||
// Subscription failed - this is critical because the game won't receive
|
||||
// updates. Trigger reconnect rather than proceeding to Connected state.
|
||||
LogConnectionEvent(
|
||||
"subscribe_failed",
|
||||
"Subscription failed - triggering reconnect");
|
||||
|
||||
// Cleanup resources before reconnecting (consistent with other failure
|
||||
// paths in HandleStreamingCall). This prevents the background thread from
|
||||
// triggering its own reconnect when Connect() disposes the streaming call.
|
||||
lock (this) {
|
||||
_streamingCall?.Dispose();
|
||||
_streamingCall = null;
|
||||
_threadCancellationTokenSource?.Cancel();
|
||||
}
|
||||
MarkAllShardokGamesForResync();
|
||||
CancelAllPendingSubscriptionAcks();
|
||||
|
||||
_circuitBreaker.RecordFailure();
|
||||
ScheduleReconnect("SubscriptionFailed");
|
||||
return;
|
||||
}
|
||||
|
||||
_lastSuccessfulConnect = DateTime.UtcNow;
|
||||
_consecutiveFailures = 0; // Reset backoff on successful connection
|
||||
_currentState = ConnectionState.Connected;
|
||||
_circuitBreaker.RecordSuccess();
|
||||
LogConnectionEvent("connect_success");
|
||||
|
||||
// CRITICAL: Cancel any pending scheduled reconnect to prevent it from
|
||||
// firing and killing this working connection
|
||||
if (_retryTimer != null) {
|
||||
_retryTimer.Enabled = false;
|
||||
_retryTimer.Dispose();
|
||||
_retryTimer = null;
|
||||
}
|
||||
|
||||
// CRITICAL: Reset idle timer baseline before starting idle check
|
||||
// Otherwise, on reconnect, _lastResponseReceived still has the old
|
||||
// timestamp and CheckForIdleTimeout() will immediately kill this
|
||||
// new connection thinking it's been idle for minutes
|
||||
_lastResponseReceived = DateTime.UtcNow;
|
||||
_lastIdleWarningLevel = 0;
|
||||
|
||||
// Start monitoring for idle timeout (relies on HTTP/2 keepalive)
|
||||
StartIdleCheckTimer();
|
||||
|
||||
// Start sending heartbeats with sync status
|
||||
StartHeartbeatTimer();
|
||||
|
||||
await TryPendingCommands();
|
||||
} catch (Exception e) {
|
||||
_circuitBreaker.RecordFailure();
|
||||
LogConnectionEvent("connect_failed", e.GetType().Name);
|
||||
_remoteEagleClientLogger.LogLine($"Got exception {e} in Connect()");
|
||||
lock (this) {
|
||||
while (pendingCommands.Any()) {
|
||||
_pendingCommands.Add(pendingCommands.Dequeue());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally { _isConnecting = false; }
|
||||
}
|
||||
|
||||
private async Task<bool> TryPendingCommands() {
|
||||
@@ -229,6 +493,10 @@ namespace eagle {
|
||||
|
||||
public void Disconnect() {
|
||||
lock (this) {
|
||||
_lastDisconnect = DateTime.UtcNow;
|
||||
LogConnectionEvent("disconnect_explicit");
|
||||
StopIdleCheckTimer();
|
||||
StopHeartbeatTimer();
|
||||
_streamingCall?.Dispose();
|
||||
_streamingCall = null;
|
||||
}
|
||||
@@ -237,11 +505,8 @@ namespace eagle {
|
||||
public void Dispose() {
|
||||
lock (this) {
|
||||
// Dispose timers first to stop any pending callbacks
|
||||
if (_timer != null) {
|
||||
_timer.Enabled = false;
|
||||
_timer.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
StopIdleCheckTimer();
|
||||
StopHeartbeatTimer();
|
||||
|
||||
if (_retryTimer != null) {
|
||||
_retryTimer.Enabled = false;
|
||||
@@ -280,11 +545,19 @@ namespace eagle {
|
||||
_lobbySubscriber = subscriber;
|
||||
}
|
||||
|
||||
public void Subscribe(IClientConnectionSubscriber subscriber) {
|
||||
/// <summary>
|
||||
/// Subscribe to a game and wait for server acknowledgment.
|
||||
/// Returns true if subscription was acknowledged, false if it failed or timed out.
|
||||
/// </summary>
|
||||
public async Task<bool> Subscribe(IClientConnectionSubscriber subscriber) {
|
||||
IClientConnectionSubscriber toStream;
|
||||
lock (this) {
|
||||
_subscribers[subscriber.GameId] = subscriber;
|
||||
StreamOneGame(subscriber);
|
||||
toStream = subscriber;
|
||||
}
|
||||
|
||||
// Stream OUTSIDE lock (can await)
|
||||
return await StreamOneGameAsync(toStream);
|
||||
}
|
||||
|
||||
public async Task PostError(EagleGameId gameId, string errorMessage, string stackTrace) {
|
||||
@@ -439,9 +712,32 @@ namespace eagle {
|
||||
});
|
||||
break;
|
||||
|
||||
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:
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.StreamingTextResponse:
|
||||
// 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) {
|
||||
@@ -472,14 +768,12 @@ namespace eagle {
|
||||
threadToken = _currentThreadToken;
|
||||
}
|
||||
|
||||
SetUpTimer();
|
||||
|
||||
var waitStartTime = DateTime.UtcNow;
|
||||
while (sc != null && !_currentThreadToken.IsCancellationRequested &&
|
||||
await sc.ResponseStream.MoveNext(_cancellationToken)) {
|
||||
var receivedTime = DateTime.UtcNow;
|
||||
_lastResponseReceived = receivedTime;
|
||||
SetUpTimer();
|
||||
_lastIdleWarningLevel = 0; // Reset idle warnings on data received
|
||||
var waitTime = (receivedTime - waitStartTime).TotalMilliseconds;
|
||||
if (waitTime > 100.0) { _timingsLogger.LogLine($"WAIT {waitTime} ms"); }
|
||||
|
||||
@@ -488,7 +782,7 @@ namespace eagle {
|
||||
|
||||
switch (current.ResponseDetailsCase) {
|
||||
case UpdateStreamResponse.ResponseDetailsOneofCase.HeartbeatResponse:
|
||||
_remoteEagleClientLogger.LogLine("Got a heartbeat response!");
|
||||
HandleHeartbeatResponse(current.HeartbeatResponse);
|
||||
break;
|
||||
|
||||
case UpdateStreamResponse.ResponseDetailsOneofCase.GameUpdate:
|
||||
@@ -553,6 +847,25 @@ namespace eagle {
|
||||
mapInfo.Map);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case UpdateStreamResponse.ResponseDetailsOneofCase.SubscriptionAck:
|
||||
var ack = current.SubscriptionAck;
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SUBSCRIBE] Received SubscriptionAck for game {ack.GameId}, " +
|
||||
$"success={ack.Success}");
|
||||
// Get TCS under lock, but call TrySetResult outside to avoid
|
||||
// potential deadlock from synchronous continuations
|
||||
TaskCompletionSource<SubscriptionAck> ackTcs = null;
|
||||
lock (_pendingSubscriptionAcks) {
|
||||
if (_pendingSubscriptionAcks.TryGetValue(
|
||||
ack.GameId,
|
||||
out ackTcs)) {
|
||||
_pendingSubscriptionAcks.Remove(ack.GameId);
|
||||
}
|
||||
}
|
||||
ackTcs?.TrySetResult(ack);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -566,114 +879,261 @@ namespace eagle {
|
||||
"How did we get here? This is not my beautiful wife!");
|
||||
} catch (RpcException e) {
|
||||
lock (this) {
|
||||
_lastDisconnect = DateTime.UtcNow;
|
||||
_lastDisconnectReason = e.StatusCode;
|
||||
|
||||
// Mark all active Shardok games for full state resync
|
||||
MarkAllShardokGamesForResync();
|
||||
CancelAllPendingSubscriptionAcks();
|
||||
|
||||
switch (e.StatusCode) {
|
||||
case StatusCode.Cancelled:
|
||||
LogConnectionEvent("disconnect", "Cancelled", StatusCode.Cancelled);
|
||||
_remoteEagleClientLogger.LogLine($"Got exception {e}");
|
||||
if (_cancellationToken.IsCancellationRequested ||
|
||||
_currentThreadToken.IsCancellationRequested) {
|
||||
return;
|
||||
}
|
||||
// Only check the main cancellation token, not the thread-specific one
|
||||
// The thread token gets cancelled during normal reconnection and
|
||||
// shouldn't prevent it
|
||||
if (_cancellationToken.IsCancellationRequested) { return; }
|
||||
|
||||
Connect();
|
||||
ScheduleReconnect("Cancelled");
|
||||
|
||||
break;
|
||||
|
||||
case StatusCode.Internal:
|
||||
LogConnectionEvent("disconnect", "Internal error", StatusCode.Internal);
|
||||
_remoteEagleClientLogger.LogLine($"Got exception {e}");
|
||||
Connect();
|
||||
ScheduleReconnect("Internal");
|
||||
break;
|
||||
|
||||
case StatusCode.DeadlineExceeded:
|
||||
LogConnectionEvent(
|
||||
"disconnect",
|
||||
"Deadline exceeded",
|
||||
StatusCode.DeadlineExceeded);
|
||||
_remoteEagleClientLogger.LogLine("Got DeadlineExceeded");
|
||||
Connect();
|
||||
ScheduleReconnect("DeadlineExceeded");
|
||||
break;
|
||||
|
||||
case StatusCode.Unavailable:
|
||||
LogConnectionEvent("disconnect", "Unavailable", StatusCode.Unavailable);
|
||||
_remoteEagleClientLogger.LogLine("Got Unavailable");
|
||||
_retryTimer?.Dispose();
|
||||
_retryTimer = new Timer();
|
||||
_retryTimer.AutoReset = false;
|
||||
_retryTimer.Interval = 5000;
|
||||
_retryTimer.Elapsed += (sender, args) => Connect();
|
||||
_retryTimer.Enabled = true;
|
||||
ScheduleReconnect("Unavailable");
|
||||
break;
|
||||
|
||||
default:
|
||||
LogConnectionEvent(
|
||||
"disconnect",
|
||||
$"Unknown: {e.StatusCode}",
|
||||
e.StatusCode);
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"Something went very wrong, caught exception {e}");
|
||||
ScheduleReconnect($"Unknown-{e.StatusCode}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (ObjectDisposedException e) {
|
||||
_lastDisconnect = DateTime.UtcNow;
|
||||
LogConnectionEvent("disconnect", "ObjectDisposed");
|
||||
_remoteEagleClientLogger.LogLine($"Got ObjectDisposedException {e}");
|
||||
Connect();
|
||||
|
||||
// Mark all active Shardok games for full state resync
|
||||
MarkAllShardokGamesForResync();
|
||||
CancelAllPendingSubscriptionAcks();
|
||||
|
||||
ScheduleReconnect("ObjectDisposed");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
private void SetUpTimer() {
|
||||
if (_timer != null) {
|
||||
_timer.Enabled = false;
|
||||
_timer.Dispose();
|
||||
_timer = null;
|
||||
/// <summary>
|
||||
/// Mark all active Shardok games for resync across all subscribers.
|
||||
/// Called when connection drops to ensure state consistency on reconnect.
|
||||
/// </summary>
|
||||
private void MarkAllShardokGamesForResync() {
|
||||
foreach (var updater in _subscribers.Values.OfType<GameModelUpdater>()) {
|
||||
updater.MarkAllShardokForResync();
|
||||
}
|
||||
|
||||
_timer = new Timer { AutoReset = false, Interval = HeartbeatTimerSeconds * 1000 };
|
||||
_timer.Elapsed += TimerFired;
|
||||
_timer.Enabled = true;
|
||||
}
|
||||
|
||||
private void SendHeartbeatRequest(
|
||||
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc) {
|
||||
var heartbeatRequest = new UpdateStreamRequest {
|
||||
HeartbeatRequest =
|
||||
new HeartbeatRequest {
|
||||
ClientTimestamp = DateTimeOffset.Now.ToUnixTimeMilliseconds()
|
||||
}
|
||||
/// <summary>
|
||||
/// Cancel all pending subscription acknowledgments.
|
||||
/// Called when connection drops to unblock any waiting subscription tasks.
|
||||
/// </summary>
|
||||
private void CancelAllPendingSubscriptionAcks() {
|
||||
lock (_pendingSubscriptionAcks) {
|
||||
foreach (var tcs in _pendingSubscriptionAcks.Values) { tcs.TrySetCanceled(); }
|
||||
|
||||
_pendingSubscriptionAcks.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void StartIdleCheckTimer() {
|
||||
StopIdleCheckTimer();
|
||||
_idleCheckTimer = new Timer {
|
||||
AutoReset = true,
|
||||
Interval = 5000 // Check every 5 seconds
|
||||
};
|
||||
sc.RequestStream.WriteAsync(heartbeatRequest);
|
||||
_idleCheckTimer.Elapsed += (sender, args) => CheckForIdleTimeout();
|
||||
_idleCheckTimer.Enabled = true;
|
||||
}
|
||||
|
||||
private void TimerFired(object sender, ElapsedEventArgs e) {
|
||||
if (sender != _timer) {
|
||||
// This timer was canceled; ignore
|
||||
return;
|
||||
private void StopIdleCheckTimer() {
|
||||
if (_idleCheckTimer != null) {
|
||||
_idleCheckTimer.Enabled = false;
|
||||
_idleCheckTimer.Dispose();
|
||||
_idleCheckTimer = null;
|
||||
}
|
||||
if (_cancellationToken.IsCancellationRequested ||
|
||||
_currentThreadToken.IsCancellationRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
private void CheckForIdleTimeout() {
|
||||
if (_cancellationToken.IsCancellationRequested) { return; }
|
||||
|
||||
var idleTime = (DateTime.UtcNow - _lastResponseReceived).TotalSeconds;
|
||||
|
||||
// Early warnings at 10s and 20s to help diagnose slow connections
|
||||
if (idleTime > 20.0 && _lastIdleWarningLevel < 2) {
|
||||
_lastIdleWarningLevel = 2;
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[IDLE_WARNING] No messages received in {idleTime:F1}s (timeout at {MaxIdleSeconds}s)");
|
||||
LogConnectionEvent("idle_warning_20s", $"idle_time={idleTime:F1}s");
|
||||
} else if (idleTime > 10.0 && _lastIdleWarningLevel < 1) {
|
||||
_lastIdleWarningLevel = 1;
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[IDLE_WARNING] No messages received in {idleTime:F1}s (timeout at {MaxIdleSeconds}s)");
|
||||
LogConnectionEvent("idle_warning_10s", $"idle_time={idleTime:F1}s");
|
||||
}
|
||||
|
||||
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc;
|
||||
lock (this) { sc = _streamingCall; }
|
||||
if (idleTime > MaxIdleSeconds) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[IDLE_TIMEOUT] No messages received in {idleTime:F1}s (max={MaxIdleSeconds}s), forcing reconnect");
|
||||
LogConnectionEvent("idle_timeout", $"idle_time={idleTime:F1}s");
|
||||
|
||||
if (sc == null) {
|
||||
// Already canceled
|
||||
return;
|
||||
}
|
||||
|
||||
_timer.Enabled = false;
|
||||
_timer.Dispose();
|
||||
_timer = null;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var lastResponseReceived = _lastResponseReceived;
|
||||
|
||||
var secondsSinceResponse = (now - lastResponseReceived).TotalSeconds;
|
||||
if (secondsSinceResponse > 2.0 * HeartbeatTimerSeconds) {
|
||||
var toCancel = _streamingCall;
|
||||
|
||||
if (toCancel != null) {
|
||||
toCancel.Dispose();
|
||||
lock (this) {
|
||||
_streamingCall = null;
|
||||
Connect();
|
||||
lock (this) { _streamingCall = null; }
|
||||
Task.Run(() => Connect());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StartHeartbeatTimer() {
|
||||
StopHeartbeatTimer();
|
||||
_heartbeatTimer =
|
||||
new Timer { AutoReset = true, Interval = HeartbeatIntervalSeconds * 1000 };
|
||||
_heartbeatTimer.Elapsed += (sender, args) => Task.Run(() => SendHeartbeat());
|
||||
_heartbeatTimer.Enabled = true;
|
||||
}
|
||||
|
||||
private void StopHeartbeatTimer() {
|
||||
if (_heartbeatTimer != null) {
|
||||
_heartbeatTimer.Enabled = false;
|
||||
_heartbeatTimer.Dispose();
|
||||
_heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendHeartbeat() {
|
||||
if (_cancellationToken.IsCancellationRequested) { return; }
|
||||
if (_currentState != ConnectionState.Connected) { return; }
|
||||
|
||||
// Build sync status for all subscribed games
|
||||
var heartbeatRequest = new HeartbeatRequest {
|
||||
ClientTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||
};
|
||||
|
||||
List<IClientConnectionSubscriber> subscribers;
|
||||
lock (this) { subscribers = _subscribers.Values.ToList(); }
|
||||
|
||||
foreach (var subscriber in subscribers) {
|
||||
var gameSyncStatus = new GameSyncStatus {
|
||||
GameId = subscriber.GameId,
|
||||
UnfilteredResultCount = subscriber.LastUnfilteredResultCount
|
||||
};
|
||||
|
||||
foreach (var shardokStatus in subscriber.ShardokViewStatuses) {
|
||||
gameSyncStatus.ShardokSyncStatuses.Add(new ShardokSyncStatus {
|
||||
ShardokGameId = shardokStatus.shardokGameId,
|
||||
FilteredResultCount = shardokStatus.filteredResultCount
|
||||
});
|
||||
}
|
||||
|
||||
heartbeatRequest.GameSyncStatuses.Add(gameSyncStatus);
|
||||
}
|
||||
|
||||
var request = new UpdateStreamRequest { HeartbeatRequest = heartbeatRequest };
|
||||
|
||||
var sent = await SendUpdateStreamRequestAsync(request);
|
||||
if (sent) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[HEARTBEAT] Sent heartbeat with {heartbeatRequest.GameSyncStatuses.Count} games");
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[HEARTBEAT] Got response, server_timestamp={response.ServerTimestamp}");
|
||||
|
||||
// Check for sync mismatches reported by server
|
||||
bool hasMismatch = false;
|
||||
foreach (var syncResult in response.GameSyncResults) {
|
||||
if (!syncResult.EagleInSync) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SYNC_MISMATCH] Game {syncResult.GameId}: Eagle out of sync, " +
|
||||
$"server has {syncResult.ServerUnfilteredResultCount} results");
|
||||
LogConnectionEvent(
|
||||
"sync_mismatch_eagle",
|
||||
$"game={syncResult.GameId}, server_count={syncResult.ServerUnfilteredResultCount}");
|
||||
hasMismatch = true;
|
||||
}
|
||||
|
||||
foreach (var shardokResult in syncResult.ShardokSyncResults) {
|
||||
if (!shardokResult.InSync) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SYNC_MISMATCH] Game {syncResult.GameId}, Shardok {shardokResult.ShardokGameId}: " +
|
||||
$"out of sync, server has {shardokResult.ServerFilteredResultCount} results");
|
||||
LogConnectionEvent(
|
||||
"sync_mismatch_shardok",
|
||||
$"game={syncResult.GameId}, shardok={shardokResult.ShardokGameId}, " +
|
||||
$"server_count={shardokResult.ServerFilteredResultCount}");
|
||||
hasMismatch = true;
|
||||
}
|
||||
}
|
||||
} else if (secondsSinceResponse > HeartbeatTimerSeconds) {
|
||||
SendHeartbeatRequest(sc);
|
||||
}
|
||||
|
||||
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
|
||||
lock (this) {
|
||||
_streamingCall?.Dispose();
|
||||
_streamingCall = null;
|
||||
_threadCancellationTokenSource?.Cancel();
|
||||
}
|
||||
MarkAllShardokGamesForResync();
|
||||
CancelAllPendingSubscriptionAcks();
|
||||
ScheduleReconnect("SyncMismatch");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,8 @@ public class EagleConnection : IDisposable {
|
||||
public readonly string playerName;
|
||||
public readonly Metadata credentials;
|
||||
public readonly string authHeader;
|
||||
private const double KeepAliveSeconds = 45.0;
|
||||
// Reduced from 45s to 15s for better NAT/firewall traversal
|
||||
private const double KeepAliveSeconds = 15.0;
|
||||
|
||||
public static CancellationToken EagleCancellationToken =>
|
||||
ConnectionKiller.EagleCancellationToken;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,21 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using UnityEngine;
|
||||
using Debug = UnityEngine.Debug;
|
||||
|
||||
public class MainQueue : MonoBehaviour {
|
||||
static MainQueue __singletonInstance;
|
||||
private readonly Queue<Action> _actionQueue = new();
|
||||
private readonly Queue<Action> _nextUpdateQueue = new();
|
||||
|
||||
// Time budget per frame to prevent blocking when resuming from background
|
||||
// 8ms leaves room for rendering within a 16ms (60fps) frame budget
|
||||
private const long MaxMillisecondsPerFrame = 8;
|
||||
|
||||
// Track queue depth for logging
|
||||
private int _lastLoggedQueueDepth = 0;
|
||||
|
||||
private MainQueue() {}
|
||||
|
||||
void Awake() {
|
||||
@@ -15,6 +24,29 @@ public class MainQueue : MonoBehaviour {
|
||||
|
||||
// Update is called once per frame
|
||||
void Update() {
|
||||
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");
|
||||
_lastLoggedQueueDepth = queueDepthBefore;
|
||||
} else if (queueDepthBefore <= 100) {
|
||||
_lastLoggedQueueDepth = 0;
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
Action possibleAction;
|
||||
do {
|
||||
possibleAction = null;
|
||||
@@ -23,7 +55,7 @@ public class MainQueue : MonoBehaviour {
|
||||
}
|
||||
|
||||
if (possibleAction != null) { possibleAction.Invoke(); }
|
||||
} while (possibleAction != null);
|
||||
} while (possibleAction != null && stopwatch.ElapsedMilliseconds < MaxMillisecondsPerFrame);
|
||||
|
||||
lock (_nextUpdateQueue) {
|
||||
foreach (Action action in _nextUpdateQueue) { Enqueue(action); }
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ public class HexMesh : MonoBehaviour {
|
||||
}
|
||||
|
||||
public void Triangulate(IEnumerable<HexCell> cells) {
|
||||
// Guard against Update() being called before SetUp() initializes hexMesh
|
||||
if (hexMesh == null) return;
|
||||
|
||||
hexMesh.Clear();
|
||||
vertices.Clear();
|
||||
triangles.Clear();
|
||||
|
||||
+108
-95
@@ -74,6 +74,8 @@ namespace Shardok {
|
||||
public ActionResultTypeManager actionResultTypeManager;
|
||||
|
||||
public Canvas eagleCanvas;
|
||||
public TMP_Text connectionStatusLabel;
|
||||
private bool _connectionStatusUIInitialized = false;
|
||||
|
||||
public ShardokGameModel Model { get; private set; }
|
||||
|
||||
@@ -353,6 +355,17 @@ namespace Shardok {
|
||||
});
|
||||
}
|
||||
|
||||
public void SetConnection(eagle.PersistentClientConnection connection) {
|
||||
if (!_connectionStatusUIInitialized && connectionStatusLabel != null &&
|
||||
connection != null) {
|
||||
var statusUI = connectionStatusLabel.GetComponent<eagle.ConnectionStatusUI>();
|
||||
if (statusUI != null) {
|
||||
statusUI.SetConnection(connection);
|
||||
_connectionStatusUIInitialized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateReserves() {
|
||||
reservesController.HeaderLabelText = "Reserves";
|
||||
reservesController.AllowSelection = false;
|
||||
@@ -372,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() {
|
||||
@@ -694,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() {
|
||||
@@ -948,7 +961,7 @@ namespace Shardok {
|
||||
commandTypeUIManager.CommandGroupForType(command.Type));
|
||||
}
|
||||
|
||||
if (allCommands.Any()) {
|
||||
if (allCommands.Any() && mapMouseCoords != null) {
|
||||
var meleeCommands = allCommands.Where(
|
||||
command => CommandTypeUIManager.MeleeAttackGroup ==
|
||||
commandTypeUIManager.CommandGroupForType(command.Type));
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using eagle;
|
||||
using eagle0;
|
||||
using UnityEngine;
|
||||
using Net.Eagle0.Shardok.Api;
|
||||
using Net.Eagle0.Shardok.Common;
|
||||
using UnityGoDiceInterface;
|
||||
@@ -81,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) {
|
||||
@@ -110,6 +111,17 @@ public class ShardokGameModel {
|
||||
|
||||
public bool InSetUp => GameStatus != null && GameStatus.State == GameStatus.Types.State.SetUp;
|
||||
|
||||
/// <summary>
|
||||
/// Mark this battle as ended (called when battle is removed from Eagle before
|
||||
/// we receive the final Shardok update, e.g., due to race conditions when
|
||||
/// Unity was backgrounded).
|
||||
/// </summary>
|
||||
public void MarkBattleEnded(string reason) {
|
||||
GameStatus =
|
||||
new GameStatus { State = GameStatus.Types.State.Victory, Description = reason };
|
||||
UpdateAction?.Invoke();
|
||||
}
|
||||
|
||||
private readonly PersistentClientConnection _persistentClientConnection;
|
||||
|
||||
private const int StartingHistoryCapacity = 100;
|
||||
@@ -173,7 +185,10 @@ public class ShardokGameModel {
|
||||
return null;
|
||||
}
|
||||
|
||||
public void HandleUpdates(
|
||||
/// <summary>
|
||||
/// Returns true if updates were handled successfully, false if a resync is needed.
|
||||
/// </summary>
|
||||
public bool HandleUpdates(
|
||||
IEnumerable<ActionResultView> newHistory,
|
||||
int expectedNewResultCount) {
|
||||
var existingHistoryCount = History.Count();
|
||||
@@ -182,12 +197,16 @@ public class ShardokGameModel {
|
||||
var diff = existingHistoryCount + newHistoryCount - expectedNewResultCount;
|
||||
History.RemoveRange(existingHistoryCount - diff, diff);
|
||||
} else if (expectedNewResultCount > existingHistoryCount + newHistoryCount) {
|
||||
throw new ArgumentException(
|
||||
$"Should have {expectedNewResultCount} results but we have {existingHistoryCount + newHistoryCount}");
|
||||
// Missing results - likely due to dropped packets on bad network.
|
||||
// Request a full resync instead of crashing.
|
||||
Debug.Log(
|
||||
$"ShardokGameModel: Missing results (expected {expectedNewResultCount}, have {existingHistoryCount + newHistoryCount}). Requesting resync.");
|
||||
return false;
|
||||
}
|
||||
foreach (ActionResultView entry in newHistory) { HandleNewHistoryEntry(entry); }
|
||||
|
||||
if (newHistoryCount > 0 && UpdateAction != null) { UpdateAction.Invoke(); }
|
||||
return true;
|
||||
}
|
||||
|
||||
public void HandleAvailableCommands(AvailableCommands newCommands) {
|
||||
@@ -198,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
+36
-9
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) {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using eagle;
|
||||
using Google.Protobuf;
|
||||
@@ -18,10 +20,33 @@ namespace common {
|
||||
public static void SetUpConnection(HttpClient httpClient) {
|
||||
headshotFetcher = new("headshots", httpClient);
|
||||
}
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
// Client that doesn't follow redirects, so we can retry each hop independently
|
||||
private readonly HttpClient _noRedirectClient;
|
||||
|
||||
// Auth header to send only to eagle0.net (not to S3 signed URLs)
|
||||
private readonly System.Net.Http.Headers.AuthenticationHeaderValue _authHeader;
|
||||
|
||||
private static readonly Uri BaseUri = new("https://eagle0.net/assets/headshots/");
|
||||
|
||||
private const int MaxRetryAttempts = 5;
|
||||
private const int MaxRedirectHops = 5;
|
||||
private const int RequestTimeoutSeconds = 10;
|
||||
private static readonly int[] RetryDelaysMs = { 500, 1000, 2000, 3000, 5000 };
|
||||
private const int PeriodicRetryIntervalMs = 30000; // 30 seconds
|
||||
|
||||
// Result of a single hop fetch attempt
|
||||
private class HopResult {
|
||||
public bool Success { get; set; }
|
||||
public Uri RedirectUri { get; set; } // Non-null if this was a redirect
|
||||
public bool GotContent { get; set; } // True if we received and saved content
|
||||
public bool ShouldRetry { get; set; } // True if this failure is retryable
|
||||
}
|
||||
|
||||
private readonly HashSet<string> _failedPaths = new();
|
||||
private readonly object _failedPathsLock = new();
|
||||
private Timer _periodicRetryTimer;
|
||||
|
||||
ResourceFetcher(string relativeLocalPath, HttpClient httpClient) {
|
||||
_localPath = Path.Combine(
|
||||
Application.persistentDataPath,
|
||||
@@ -29,7 +54,22 @@ namespace common {
|
||||
"Resources",
|
||||
relativeLocalPath);
|
||||
Directory.CreateDirectory(_localPath);
|
||||
_httpClient = httpClient;
|
||||
|
||||
// Create a client that doesn't follow redirects automatically,
|
||||
// so we can retry each hop independently
|
||||
var handler = new HttpClientHandler { AllowAutoRedirect = false };
|
||||
_noRedirectClient = new HttpClient(
|
||||
handler) { Timeout = TimeSpan.FromSeconds(RequestTimeoutSeconds) };
|
||||
// Store auth header to add per-request only for eagle0.net
|
||||
// (sending it to S3 signed URLs causes HTTP 400)
|
||||
_authHeader = httpClient.DefaultRequestHeaders.Authorization;
|
||||
|
||||
// Start periodic retry timer
|
||||
_periodicRetryTimer = new Timer(
|
||||
PeriodicRetryCallback,
|
||||
null,
|
||||
PeriodicRetryIntervalMs,
|
||||
PeriodicRetryIntervalMs);
|
||||
}
|
||||
|
||||
private readonly string _localPath;
|
||||
@@ -63,13 +103,131 @@ namespace common {
|
||||
}
|
||||
|
||||
private async Task<bool> FetchRemote(string path) {
|
||||
// Send the request to the server to fetch the headshot
|
||||
var response = await _httpClient.SendAsync(new HttpRequestMessage {
|
||||
Method = HttpMethod.Get,
|
||||
RequestUri = new Uri(BaseUri, path)
|
||||
});
|
||||
Uri currentUri = new Uri(BaseUri, path);
|
||||
|
||||
return await ReceiveHeadshot(response.Content, 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;
|
||||
}
|
||||
|
||||
// Fetch a single hop with retries. Returns content, redirect, or failure.
|
||||
private async Task<HopResult> FetchOneHopWithRetry(Uri uri, string path, int hopIndex) {
|
||||
for (int attempt = 0; attempt < MaxRetryAttempts; attempt++) {
|
||||
try {
|
||||
var request =
|
||||
new HttpRequestMessage { Method = HttpMethod.Get, RequestUri = uri };
|
||||
// Only send auth header to eagle0.net, not to S3 signed URLs
|
||||
if (_authHeader != null && uri.Host.EndsWith("eagle0.net")) {
|
||||
request.Headers.Authorization = _authHeader;
|
||||
}
|
||||
var response = await _noRedirectClient.SendAsync(request);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
// Success - got content
|
||||
if (response.IsSuccessStatusCode) {
|
||||
await ReceiveHeadshot(response.Content, path);
|
||||
return new HopResult { Success = true, GotContent = true };
|
||||
}
|
||||
|
||||
// Redirect - return the location to follow
|
||||
if (statusCode >= 300 && statusCode < 400) {
|
||||
var location = response.Headers.Location;
|
||||
if (location != null) {
|
||||
var redirectUri =
|
||||
location.IsAbsoluteUri ? location : new Uri(uri, location);
|
||||
return new HopResult { Success = true, RedirectUri = redirectUri };
|
||||
}
|
||||
Debug.Log(
|
||||
$"ResourceFetcher: hop {hopIndex} got redirect but no Location header for {path}");
|
||||
}
|
||||
|
||||
Debug.Log(
|
||||
$"ResourceFetcher: hop {hopIndex} HTTP {statusCode} for {path} (attempt {attempt + 1}/{MaxRetryAttempts})");
|
||||
|
||||
// Don't retry on 4xx client errors (except 408, 429)
|
||||
if (statusCode >= 400 && statusCode < 500 && statusCode != 408 &&
|
||||
statusCode != 429) {
|
||||
return new HopResult { Success = false, ShouldRetry = false };
|
||||
}
|
||||
|
||||
if (attempt < MaxRetryAttempts - 1) {
|
||||
await Task.Delay(RetryDelaysMs[attempt]);
|
||||
}
|
||||
} catch (HttpRequestException e) {
|
||||
Debug.Log(
|
||||
$"ResourceFetcher: hop {hopIndex} network error for {path} (attempt {attempt + 1}/{MaxRetryAttempts}): {e.Message}");
|
||||
if (attempt < MaxRetryAttempts - 1) {
|
||||
await Task.Delay(RetryDelaysMs[attempt]);
|
||||
}
|
||||
} catch (TaskCanceledException e) {
|
||||
Debug.Log(
|
||||
$"ResourceFetcher: hop {hopIndex} timeout for {path} (attempt {attempt + 1}/{MaxRetryAttempts}): {e.Message}");
|
||||
if (attempt < MaxRetryAttempts - 1) {
|
||||
await Task.Delay(RetryDelaysMs[attempt]);
|
||||
}
|
||||
} catch (WebException e) {
|
||||
Debug.Log(
|
||||
$"ResourceFetcher: hop {hopIndex} connection error for {path} (attempt {attempt + 1}/{MaxRetryAttempts}): {e.Message}");
|
||||
if (attempt < MaxRetryAttempts - 1) {
|
||||
await Task.Delay(RetryDelaysMs[attempt]);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Debug.Log(
|
||||
$"ResourceFetcher: hop {hopIndex} IO error for {path} (attempt {attempt + 1}/{MaxRetryAttempts}): {e.Message}");
|
||||
if (attempt < MaxRetryAttempts - 1) {
|
||||
await Task.Delay(RetryDelaysMs[attempt]);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Debug.LogError(
|
||||
$"ResourceFetcher: hop {hopIndex} unexpected error for {path}: {e}");
|
||||
return new HopResult { Success = false, ShouldRetry = false };
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted for this hop
|
||||
return new HopResult { Success = false, ShouldRetry = false };
|
||||
}
|
||||
|
||||
private void MarkFailed(string path) {
|
||||
lock (_failedPathsLock) { _failedPaths.Add(path); }
|
||||
}
|
||||
|
||||
private void ClearFailed(string path) {
|
||||
lock (_failedPathsLock) { _failedPaths.Remove(path); }
|
||||
}
|
||||
|
||||
private void PeriodicRetryCallback(object state) {
|
||||
List<string> pathsToRetry;
|
||||
lock (_failedPathsLock) {
|
||||
if (_failedPaths.Count == 0) return;
|
||||
pathsToRetry = _failedPaths.ToList();
|
||||
_failedPaths.Clear();
|
||||
}
|
||||
|
||||
Debug.Log($"ResourceFetcher: Retrying {pathsToRetry.Count} failed headshot(s)");
|
||||
foreach (var path in pathsToRetry) { FetchRemote(path); }
|
||||
}
|
||||
|
||||
private class ImageLoadQueueItem {
|
||||
@@ -131,15 +289,15 @@ namespace common {
|
||||
}
|
||||
|
||||
public void Prefetch(IEnumerable<string> paths) {
|
||||
var toFetch = paths.Where(path => !File.Exists(FullLocalPath(path))).ToList();
|
||||
|
||||
foreach (var path in toFetch) {
|
||||
if (string.IsNullOrEmpty(path)) {
|
||||
Debug.LogWarning("ResourceFetcher: Attempted to prefetch an empty path.");
|
||||
}
|
||||
|
||||
FetchRemote(path);
|
||||
List<string> toFetch;
|
||||
lock (_failedPathsLock) {
|
||||
toFetch = paths.Where(path => !string.IsNullOrEmpty(path) &&
|
||||
!File.Exists(FullLocalPath(path)) &&
|
||||
!_failedPaths.Contains(path))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
foreach (var path in toFetch) { FetchRemote(path); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,18 @@
|
||||
"com.unity.2d.sprite": "1.0.0",
|
||||
"com.unity.2d.tilemap": "1.0.0",
|
||||
"com.unity.ai.navigation": "2.0.9",
|
||||
"com.unity.analytics": "3.8.1",
|
||||
"com.unity.collab-proxy": "2.9.3",
|
||||
"com.unity.analytics": "3.8.2",
|
||||
"com.unity.collab-proxy": "2.10.2",
|
||||
"com.unity.ext.nunit": "2.0.5",
|
||||
"com.unity.ide.rider": "3.0.38",
|
||||
"com.unity.ide.visualstudio": "2.0.23",
|
||||
"com.unity.multiplayer.center": "1.0.0",
|
||||
"com.unity.nuget.newtonsoft-json": "3.2.1",
|
||||
"com.unity.ide.visualstudio": "2.0.25",
|
||||
"com.unity.multiplayer.center": "1.0.1",
|
||||
"com.unity.nuget.newtonsoft-json": "3.2.2",
|
||||
"com.unity.test-framework": "1.6.0",
|
||||
"com.unity.timeline": "1.8.9",
|
||||
"com.unity.ugui": "2.0.0",
|
||||
"com.unity.modules.accessibility": "1.0.0",
|
||||
"com.unity.modules.adaptiveperformance": "1.0.0",
|
||||
"com.unity.modules.ai": "1.0.0",
|
||||
"com.unity.modules.androidjni": "1.0.0",
|
||||
"com.unity.modules.animation": "1.0.0",
|
||||
@@ -41,6 +42,7 @@
|
||||
"com.unity.modules.unitywebrequestaudio": "1.0.0",
|
||||
"com.unity.modules.unitywebrequesttexture": "1.0.0",
|
||||
"com.unity.modules.unitywebrequestwww": "1.0.0",
|
||||
"com.unity.modules.vectorgraphics": "1.0.0",
|
||||
"com.unity.modules.vehicles": "1.0.0",
|
||||
"com.unity.modules.video": "1.0.0",
|
||||
"com.unity.modules.vr": "1.0.0",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"url": "https://packages.unity.com"
|
||||
},
|
||||
"com.unity.analytics": {
|
||||
"version": "3.8.1",
|
||||
"version": "3.8.2",
|
||||
"depth": 0,
|
||||
"source": "registry",
|
||||
"dependencies": {
|
||||
@@ -42,7 +42,7 @@
|
||||
"url": "https://packages.unity.com"
|
||||
},
|
||||
"com.unity.collab-proxy": {
|
||||
"version": "2.9.3",
|
||||
"version": "2.10.2",
|
||||
"depth": 0,
|
||||
"source": "registry",
|
||||
"dependencies": {},
|
||||
@@ -64,16 +64,16 @@
|
||||
"url": "https://packages.unity.com"
|
||||
},
|
||||
"com.unity.ide.visualstudio": {
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.25",
|
||||
"depth": 0,
|
||||
"source": "registry",
|
||||
"dependencies": {
|
||||
"com.unity.test-framework": "1.1.9"
|
||||
"com.unity.test-framework": "1.1.31"
|
||||
},
|
||||
"url": "https://packages.unity.com"
|
||||
},
|
||||
"com.unity.multiplayer.center": {
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"depth": 0,
|
||||
"source": "builtin",
|
||||
"dependencies": {
|
||||
@@ -81,14 +81,14 @@
|
||||
}
|
||||
},
|
||||
"com.unity.nuget.newtonsoft-json": {
|
||||
"version": "3.2.1",
|
||||
"version": "3.2.2",
|
||||
"depth": 0,
|
||||
"source": "registry",
|
||||
"dependencies": {},
|
||||
"url": "https://packages.unity.com"
|
||||
},
|
||||
"com.unity.services.analytics": {
|
||||
"version": "6.1.0",
|
||||
"version": "6.1.1",
|
||||
"depth": 1,
|
||||
"source": "registry",
|
||||
"dependencies": {
|
||||
@@ -99,7 +99,7 @@
|
||||
"url": "https://packages.unity.com"
|
||||
},
|
||||
"com.unity.services.core": {
|
||||
"version": "1.14.0",
|
||||
"version": "1.16.0",
|
||||
"depth": 2,
|
||||
"source": "registry",
|
||||
"dependencies": {
|
||||
@@ -146,6 +146,14 @@
|
||||
"source": "builtin",
|
||||
"dependencies": {}
|
||||
},
|
||||
"com.unity.modules.adaptiveperformance": {
|
||||
"version": "1.0.0",
|
||||
"depth": 0,
|
||||
"source": "builtin",
|
||||
"dependencies": {
|
||||
"com.unity.modules.subsystems": "1.0.0"
|
||||
}
|
||||
},
|
||||
"com.unity.modules.ai": {
|
||||
"version": "1.0.0",
|
||||
"depth": 0,
|
||||
@@ -353,6 +361,16 @@
|
||||
"com.unity.modules.imageconversion": "1.0.0"
|
||||
}
|
||||
},
|
||||
"com.unity.modules.vectorgraphics": {
|
||||
"version": "1.0.0",
|
||||
"depth": 0,
|
||||
"source": "builtin",
|
||||
"dependencies": {
|
||||
"com.unity.modules.uielements": "1.0.0",
|
||||
"com.unity.modules.imageconversion": "1.0.0",
|
||||
"com.unity.modules.imgui": "1.0.0"
|
||||
}
|
||||
},
|
||||
"com.unity.modules.vehicles": {
|
||||
"version": "1.0.0",
|
||||
"depth": 0,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
m_EditorVersion: 6000.2.7f2
|
||||
m_EditorVersionWithRevision: 6000.2.7f2 (2b518236b676)
|
||||
m_EditorVersion: 6000.3.0f1
|
||||
m_EditorVersionWithRevision: 6000.3.0f1 (d1870ce95baf)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
|
||||
|
||||
go_library(
|
||||
name = "admin_server_lib",
|
||||
srcs = ["admin_server.go"],
|
||||
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/admin_server",
|
||||
visibility = ["//visibility:private"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:api_go_proto", # keep
|
||||
"@org_golang_google_grpc//:grpc",
|
||||
"@org_golang_google_grpc//credentials/insecure",
|
||||
],
|
||||
)
|
||||
|
||||
go_binary(
|
||||
name = "admin_server",
|
||||
embed = [":admin_server_lib"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
@@ -0,0 +1,179 @@
|
||||
// Package main provides an HTTP admin server for Eagle game management.
|
||||
// It connects to the Eagle gRPC service and exposes REST endpoints.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
eagle "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/api/eagle"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
var (
|
||||
eagleAddr = flag.String("eagle-addr", "localhost:40032", "Eagle gRPC server address")
|
||||
httpPort = flag.Int("http-port", 8080, "HTTP server port")
|
||||
grpcClient eagle.EagleClient
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
// Connect to Eagle gRPC server
|
||||
conn, err := grpc.NewClient(*eagleAddr,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to Eagle server: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
grpcClient = eagle.NewEagleClient(conn)
|
||||
|
||||
// Set up HTTP routes
|
||||
http.HandleFunc("/games", handleGames)
|
||||
http.HandleFunc("/games/", handleGameHistory)
|
||||
http.HandleFunc("/health", handleHealth)
|
||||
|
||||
addr := fmt.Sprintf(":%d", *httpPort)
|
||||
log.Printf("Admin server starting on %s, connecting to Eagle at %s", addr, *eagleAddr)
|
||||
if err := http.ListenAndServe(addr, nil); err != nil {
|
||||
log.Fatalf("HTTP server failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// PlayerInfo represents a player in a running game
|
||||
type PlayerInfo struct {
|
||||
FactionID int32 `json:"faction_id"`
|
||||
FactionName string `json:"faction_name"`
|
||||
LeaderName string `json:"leader_name"`
|
||||
IsHuman bool `json:"is_human"`
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
}
|
||||
|
||||
// GameInfo represents a running game with hex-formatted ID
|
||||
type GameInfo struct {
|
||||
GameID string `json:"game_id"`
|
||||
CurrentRound int32 `json:"current_round"`
|
||||
ActionCount int32 `json:"action_count"`
|
||||
Players []PlayerInfo `json:"players"`
|
||||
RunStatus string `json:"run_status"`
|
||||
}
|
||||
|
||||
// GamesResponse is the response for /games endpoint
|
||||
type GamesResponse struct {
|
||||
Games []GameInfo `json:"games"`
|
||||
}
|
||||
|
||||
// formatGameID formats a game ID as unsigned hex
|
||||
func formatGameID(id int64) string {
|
||||
return fmt.Sprintf("%x", uint64(id))
|
||||
}
|
||||
|
||||
func handleGames(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := grpcClient.GetRunningGames(ctx, &eagle.GetRunningGamesRequest{})
|
||||
if err != nil {
|
||||
log.Printf("Failed to get running games: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to get running games: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to JSON response with hex game IDs
|
||||
games := make([]GameInfo, len(resp.Games))
|
||||
for i, game := range resp.Games {
|
||||
players := make([]PlayerInfo, len(game.Players))
|
||||
for j, p := range game.Players {
|
||||
players[j] = PlayerInfo{
|
||||
FactionID: p.FactionId,
|
||||
FactionName: p.FactionName,
|
||||
LeaderName: p.LeaderName,
|
||||
IsHuman: p.IsHuman,
|
||||
UserName: p.UserName,
|
||||
}
|
||||
}
|
||||
games[i] = GameInfo{
|
||||
GameID: formatGameID(game.GameId),
|
||||
CurrentRound: game.CurrentRound,
|
||||
ActionCount: game.ActionCount,
|
||||
Players: players,
|
||||
RunStatus: game.RunStatus,
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(GamesResponse{Games: games})
|
||||
}
|
||||
|
||||
func handleGameHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse game ID from URL: /games/{id}/history
|
||||
path := strings.TrimPrefix(r.URL.Path, "/games/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 || parts[1] != "history" {
|
||||
http.Error(w, "Invalid path. Use /games/{id}/history", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
gameIDUint, err := strconv.ParseUint(parts[0], 16, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid game ID (expected hex format)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
gameID := int64(gameIDUint)
|
||||
|
||||
// Parse query parameters
|
||||
startIndex := 0
|
||||
limit := 100
|
||||
if s := r.URL.Query().Get("start"); s != "" {
|
||||
if v, err := strconv.Atoi(s); err == nil {
|
||||
startIndex = v
|
||||
}
|
||||
}
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if v, err := strconv.Atoi(l); err == nil {
|
||||
limit = v
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := grpcClient.GetGameHistory(ctx, &eagle.GetGameHistoryRequest{
|
||||
GameId: gameID,
|
||||
StartIndex: int32(startIndex),
|
||||
Limit: int32(limit),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to get game history for game %d: %v", gameID, err)
|
||||
http.Error(w, fmt.Sprintf("Failed to get game history: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
@@ -212,9 +212,11 @@ proto_library(
|
||||
visibility = ["//src/main/protobuf/net/eagle0:__subpackages__"],
|
||||
)
|
||||
|
||||
# keep: gazelle wants go_grpc_v2 and shardok_internal_interface_proto, but that breaks Go compilation
|
||||
# (go_grpc_v2 only generates _grpc.pb.go, not message types; shardok_internal_interface needs separate target)
|
||||
go_proto_library(
|
||||
name = "common_go_proto",
|
||||
compilers = ["@io_bazel_rules_go//proto:go_grpc_v2"],
|
||||
compilers = ["@io_bazel_rules_go//proto:go_proto"], # keep
|
||||
importpath = "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/common",
|
||||
protos = [
|
||||
":common_unit_proto",
|
||||
@@ -222,11 +224,26 @@ go_proto_library(
|
||||
":hostility_proto",
|
||||
":player_info_proto",
|
||||
":random_units_proto",
|
||||
":shardok_internal_interface_proto",
|
||||
":victory_condition_proto",
|
||||
# keep: shardok_internal_interface_proto must NOT be here - it needs go_grpc compiler in separate target
|
||||
],
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:common_go_proto",
|
||||
# keep: other deps not needed for non-gRPC protos
|
||||
],
|
||||
)
|
||||
|
||||
# Separate target for shardok_internal_interface since it contains a gRPC service
|
||||
# Uses go_grpc (v1) which generates both messages and gRPC code
|
||||
go_proto_library(
|
||||
name = "shardok_internal_interface_go_grpc",
|
||||
compilers = ["@io_bazel_rules_go//proto:go_grpc"],
|
||||
importpath = "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/common/shardok_internal_interface",
|
||||
protos = [":shardok_internal_interface_proto"],
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
":common_go_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/api:api_go_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:common_go_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/storage:storage_go_proto",
|
||||
|
||||
@@ -21,14 +21,24 @@ option java_outer_classname = "EagleInterface";
|
||||
option objc_class_prefix = "E0G";
|
||||
|
||||
service ShardokInternalInterface {
|
||||
// Server-side streaming for game updates - replaces polling via GetGameStatus
|
||||
rpc SubscribeToGame(GameSubscriptionRequest) returns (stream GameStatusResponse) {}
|
||||
|
||||
rpc PostCommand(PostCommandRequest) returns (GameStatusResponse) {}
|
||||
rpc PostPlacementCommands(PlacementCommandsRequest) returns (GameStatusResponse) {}
|
||||
|
||||
// Deprecated: Use SubscribeToGame for streaming updates instead
|
||||
rpc GetGameStatus(GameStatusRequest) returns (GameStatusResponse) {}
|
||||
|
||||
rpc GetHexMap(HexMapRequest) returns (HexMapResponse) {}
|
||||
rpc GetHexMapNames(HexMapNamesRequest) returns (HexMapNamesResponse) {}
|
||||
}
|
||||
|
||||
message GameSubscriptionRequest {
|
||||
string game_id = 1;
|
||||
GameSetupInfo game_setup_info = 2;
|
||||
}
|
||||
|
||||
message PostCommandRequest {
|
||||
string game_id = 1;
|
||||
int32 player_id = 2;
|
||||
|
||||
@@ -212,10 +212,35 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
# Non-gRPC message protos
|
||||
go_proto_library(
|
||||
name = "api_messages_go_proto",
|
||||
compilers = ["@io_bazel_rules_go//proto:go_proto"],
|
||||
importpath = "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/api",
|
||||
protos = [
|
||||
":available_command_proto",
|
||||
":command_proto",
|
||||
":pregenerated_text_proto",
|
||||
":selected_command_proto",
|
||||
":streaming_text_response_proto",
|
||||
],
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/common:common_go_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:util_go_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:common_go_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/views:views_go_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/api:api_go_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:common_go_proto",
|
||||
],
|
||||
)
|
||||
|
||||
# Eagle gRPC service - uses go_grpc (v1) which generates both messages and gRPC code
|
||||
# (go_grpc_v2 only generates gRPC code, not the message types like UpdateStreamRequest)
|
||||
go_proto_library(
|
||||
name = "api_go_proto",
|
||||
compilers = ["@io_bazel_rules_go//proto:go_grpc_v2"],
|
||||
importpath = "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/api",
|
||||
compilers = ["@io_bazel_rules_go//proto:go_grpc"], # keep
|
||||
importpath = "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/api/eagle", # keep
|
||||
protos = [
|
||||
":available_command_proto",
|
||||
":command_proto",
|
||||
|
||||
@@ -28,6 +28,10 @@ service Eagle {
|
||||
rpc StreamUpdates(stream UpdateStreamRequest) returns (stream UpdateStreamResponse) {}
|
||||
|
||||
rpc AddSettings (AddSettingsRequest) returns (AddSettingsResponse) {}
|
||||
|
||||
// Admin endpoints
|
||||
rpc GetRunningGames(GetRunningGamesRequest) returns (GetRunningGamesResponse) {}
|
||||
rpc GetGameHistory(GetGameHistoryRequest) returns (GetGameHistoryResponse) {}
|
||||
}
|
||||
|
||||
message PostCommandRequest {
|
||||
@@ -80,6 +84,8 @@ message UpdateStreamRequest {
|
||||
message ShardokViewStatus {
|
||||
string shardok_game_id = 1;
|
||||
int32 filtered_result_count = 2;
|
||||
// Request full state resync instead of delta update (used after connection drops)
|
||||
bool request_full_resync = 3;
|
||||
}
|
||||
repeated ShardokViewStatus shardok_view_statuses = 3;
|
||||
|
||||
@@ -114,9 +120,18 @@ message UpdateStreamResponse {
|
||||
CustomBattleResponse custom_battle_response = 7;
|
||||
HexMapResponse hex_map_response = 8;
|
||||
PostCommandResponse post_command_response = 9;
|
||||
SubscriptionAck subscription_ack = 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Acknowledgment sent by server after processing a StreamGameRequest
|
||||
message SubscriptionAck {
|
||||
int64 game_id = 1;
|
||||
bool success = 2;
|
||||
string error_message = 3; // Only set if success is false
|
||||
int32 confirmed_result_count = 4; // Server confirms the starting point for updates
|
||||
}
|
||||
|
||||
message HexMapsRequest {
|
||||
message OneMapRequestInfo {
|
||||
string map_name = 1;
|
||||
@@ -156,10 +171,43 @@ message GameUpdate {
|
||||
|
||||
message HeartbeatRequest {
|
||||
int64 client_timestamp = 1;
|
||||
// Sync verification: client reports its known action counts per game
|
||||
repeated GameSyncStatus game_sync_statuses = 2;
|
||||
}
|
||||
|
||||
// Client's known sync state for a single game
|
||||
message GameSyncStatus {
|
||||
int64 game_id = 1;
|
||||
// Eagle action count (matches ActionResultResponse.unfiltered_result_count_after)
|
||||
int32 unfiltered_result_count = 2;
|
||||
// Shardok action counts per tactical battle
|
||||
repeated ShardokSyncStatus shardok_sync_statuses = 3;
|
||||
}
|
||||
|
||||
message ShardokSyncStatus {
|
||||
string shardok_game_id = 1;
|
||||
// Matches ShardokActionResultResponse.filtered_result_count_after
|
||||
int32 filtered_result_count = 2;
|
||||
}
|
||||
|
||||
message HeartbeatResponse {
|
||||
int64 server_timestamp = 1;
|
||||
// Sync verification results - only included if there are mismatches
|
||||
repeated GameSyncResult game_sync_results = 2;
|
||||
}
|
||||
|
||||
// Server's sync verification result for a single game
|
||||
message GameSyncResult {
|
||||
int64 game_id = 1;
|
||||
bool eagle_in_sync = 2;
|
||||
int32 server_unfiltered_result_count = 3; // Server's count for comparison
|
||||
repeated ShardokSyncResult shardok_sync_results = 4;
|
||||
}
|
||||
|
||||
message ShardokSyncResult {
|
||||
string shardok_game_id = 1;
|
||||
bool in_sync = 2;
|
||||
int32 server_filtered_result_count = 3; // Server's count for comparison
|
||||
}
|
||||
|
||||
message NewGameOptions {
|
||||
@@ -241,6 +289,25 @@ message ActionResultResponse {
|
||||
int32 unfiltered_result_count_after = 1;
|
||||
repeated .net.eagle0.eagle.views.ActionResultView action_result_views = 2;
|
||||
AvailableCommands available_commands = 3;
|
||||
// Server-reported game status for connection status UI
|
||||
ServerGameStatus server_game_status = 4;
|
||||
}
|
||||
|
||||
// Server-reported game status for the connection status indicator.
|
||||
// Tells the client what the server is doing/waiting for.
|
||||
message ServerGameStatus {
|
||||
enum Status {
|
||||
UNKNOWN = 0;
|
||||
YOUR_TURN = 1; // Player has commands available
|
||||
WAITING_FOR_PLAYERS = 2; // Waiting for other player(s) to act
|
||||
GENERATING_TEXT = 3; // LLM text generation in progress
|
||||
PROCESSING_ACTION = 4; // Server is processing an action
|
||||
}
|
||||
Status status = 1;
|
||||
// For WAITING_FOR_PLAYERS: which faction(s) we're waiting for
|
||||
repeated int32 waiting_for_faction_ids = 2;
|
||||
// For GENERATING_TEXT: which LLM stream is being generated
|
||||
string generating_llm_id = 3;
|
||||
}
|
||||
|
||||
message ShardokActionResultResponse {
|
||||
@@ -302,3 +369,47 @@ message AddSettingsKeyValue {
|
||||
string key = 1;
|
||||
string value = 2;
|
||||
}
|
||||
|
||||
// Admin messages
|
||||
message GetRunningGamesRequest {}
|
||||
|
||||
message GetRunningGamesResponse {
|
||||
repeated RunningGameInfo games = 1;
|
||||
}
|
||||
|
||||
message RunningGameInfo {
|
||||
int64 game_id = 1;
|
||||
int32 current_round = 2;
|
||||
int32 action_count = 3;
|
||||
repeated RunningGamePlayerInfo players = 4;
|
||||
string run_status = 5;
|
||||
}
|
||||
|
||||
message RunningGamePlayerInfo {
|
||||
int32 faction_id = 1;
|
||||
string faction_name = 2;
|
||||
string leader_name = 3;
|
||||
bool is_human = 4;
|
||||
string user_name = 5;
|
||||
}
|
||||
|
||||
message GetGameHistoryRequest {
|
||||
int64 game_id = 1;
|
||||
int32 start_index = 2; // optional: start from this action index
|
||||
int32 limit = 3; // optional: max number of actions to return (0 = all)
|
||||
}
|
||||
|
||||
message GetGameHistoryResponse {
|
||||
int64 game_id = 1;
|
||||
int32 total_action_count = 2;
|
||||
repeated GameHistoryEntry entries = 3;
|
||||
}
|
||||
|
||||
message GameHistoryEntry {
|
||||
int32 index = 1;
|
||||
string action_type = 2;
|
||||
int32 round_id = 3;
|
||||
.google.protobuf.Int32Value acting_faction_id = 4;
|
||||
.google.protobuf.Int32Value province_id = 5;
|
||||
string summary = 6; // human-readable summary of the action
|
||||
}
|
||||
|
||||
@@ -195,6 +195,28 @@ message PrisonerExecutedDetails {
|
||||
int32 executing_faction_id = 4;
|
||||
}
|
||||
|
||||
message PrisonerReleasedDetails {
|
||||
.google.protobuf.Int32Value last_faction_id = 1;
|
||||
int32 province_id = 2;
|
||||
int32 released_hero_id = 3;
|
||||
int32 releasing_faction_id = 4;
|
||||
}
|
||||
|
||||
message PrisonerExiledDetails {
|
||||
.google.protobuf.Int32Value last_faction_id = 1;
|
||||
int32 province_id = 2;
|
||||
int32 exiled_hero_id = 3;
|
||||
int32 exiling_faction_id = 4;
|
||||
}
|
||||
|
||||
message PrisonerReturnedDetails {
|
||||
.google.protobuf.Int32Value last_faction_id = 1;
|
||||
int32 province_id = 2;
|
||||
int32 returned_hero_id = 3;
|
||||
int32 returning_faction_id = 4;
|
||||
int32 to_faction_id = 5;
|
||||
}
|
||||
|
||||
message ProvinceConqueredDetails {
|
||||
int32 province_id = 1;
|
||||
int32 conquering_faction_id = 2;
|
||||
@@ -282,6 +304,9 @@ message NotificationDetails {
|
||||
OutlawSpottedDetails outlaw_spotted_details = 10;
|
||||
PrisonerExchangeDetails prisoner_exchange_details = 17;
|
||||
PrisonerExecutedDetails prisoner_executed_details = 19;
|
||||
PrisonerReleasedDetails prisoner_released_details = 36;
|
||||
PrisonerExiledDetails prisoner_exiled_details = 37;
|
||||
PrisonerReturnedDetails prisoner_returned_details = 38;
|
||||
ProvinceConqueredDetails province_conquered_details = 2;
|
||||
ProvinceExpansionDetails province_expansion_details = 1;
|
||||
ProvinceHeldDetails province_held_details = 15;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user