mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 04:25:42 +00:00
Compare commits
152
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ff9f13af3 | ||
|
|
08eee0427c | ||
|
|
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -76,6 +76,7 @@ use_repo(
|
||||
"com_github_aws_aws_sdk_go_v2_config",
|
||||
"com_github_aws_aws_sdk_go_v2_credentials",
|
||||
"com_github_aws_aws_sdk_go_v2_service_s3",
|
||||
"org_golang_google_grpc",
|
||||
"org_golang_google_protobuf",
|
||||
)
|
||||
|
||||
@@ -160,6 +161,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,
|
||||
|
||||
+1
-2
@@ -1,2 +1 @@
|
||||
|
||||
UNITY_VERSION='6000.2.7f2'
|
||||
UNITY_VERSION='6000.3.0f1'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
||||
# 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
|
||||
|
||||
The following actions still have proto usage, blocked by utility dependencies:
|
||||
|
||||
| Action | Proto Usage | Blocker |
|
||||
|--------|-------------|---------|
|
||||
| `EndBattleAftermathPhaseAction` | 1 `toProto` call | `ProvinceViewFilter` needs Scala types |
|
||||
| `NewRoundAction` | 1 `fromProto` call | `ChronicleEventGenerator` returns proto |
|
||||
| `EndHandleRiotsPhaseAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
|
||||
| `PerformVassalCommandsPhaseAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
|
||||
| `PerformVassalDefenseDecisionsAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
|
||||
| `EndVassalCommandsPhaseAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
|
||||
| `PerformReconResolutionAction` | Uses `ProvinceViewFilter` | `ProvinceViewFilter` needs Scala types |
|
||||
| `ResolveBattleAction` | Heavy proto usage | Large refactor needed |
|
||||
|
||||
### Estimated Effort (Remaining)
|
||||
|
||||
| Component | Lines | Complexity |
|
||||
|-----------|-------|------------|
|
||||
| `ProvinceViewFilter` to Scala | ~150 | Medium |
|
||||
| `CommandChoiceHelpers` to Scala | ~2000 | High |
|
||||
| `ChronicleEventGenerator` to Scala | ~400 | Medium |
|
||||
| History API updates | ~100 | Low |
|
||||
| **Total Remaining** | **~2650** | |
|
||||
|
||||
### Validation
|
||||
- [x] `ActionResultApplier` created and tested
|
||||
- [x] `RandomStateSequencer` threads Scala GameState throughout
|
||||
- [x] `RoundPhaseAdvancer` uses T-types internally
|
||||
- [ ] `ProvinceViewFilter` uses 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 (Blocking Full Deproto)
|
||||
|
||||
The `ProvinceViewFilter` utility currently works entirely with proto types, blocking full deproto of actions that generate province views:
|
||||
|
||||
| File | Issue | Needed |
|
||||
|------|-------|--------|
|
||||
| `ProvinceViewFilter.scala` | Takes proto `Province`/`GameState`, returns proto `ProvinceView` | Scala `ProvinceViewT` model |
|
||||
| `GameStateViewFilter.scala` | Uses proto types throughout | Depends on `ProvinceViewT` |
|
||||
| `GameStateViewDiffer.scala` | Works with view protos | Depends on `ProvinceViewT` |
|
||||
|
||||
**Blocked Actions**:
|
||||
- `EndBattleAftermathPhaseAction` - uses `ProvinceViewFilter` for `revelationChange`, requires lazy proto conversion
|
||||
- `PerformReconResolutionAction` - uses `ProvinceViewFilter` for reconned provinces
|
||||
- `GameStateFactionExtensions` - uses `ProvinceViewFilter` for `updatedReconnedProvinces`
|
||||
|
||||
**Solution**: Create Scala `ProvinceViewT` (and possibly `ProvinceViewC`) that mirrors the proto `ProvinceView`. Then create a protoless `ProvinceViewFilter` that operates on Scala types. The proto version can delegate to the Scala version + convert, or we maintain both during transition.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
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
+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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -229,6 +229,7 @@ void ShardokGameController::PostPlacementCommands(
|
||||
}
|
||||
|
||||
auto ShardokGameController::GetCurrentGameStateBytes() -> byte_vector {
|
||||
scoped_lock<mutex> guard(masterLock);
|
||||
return engine->GetCurrentGameStateBytes();
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ using std::weak_ptr;
|
||||
class ShardokGameController {
|
||||
private:
|
||||
// This lock should be held any time we call into engine or modify clients.
|
||||
std::mutex masterLock{};
|
||||
// Mutable so const methods can acquire it for thread-safe reads.
|
||||
mutable std::mutex masterLock{};
|
||||
std::condition_variable aiCondition{};
|
||||
bool aiThreadKeepGoing = true;
|
||||
|
||||
@@ -127,9 +128,13 @@ 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 GetGameId() const -> GameId {
|
||||
std::scoped_lock<std::mutex> guard(masterLock);
|
||||
return engine->GetGameId();
|
||||
}
|
||||
|
||||
[[nodiscard]] auto GetHexMap() const -> const HexMap * {
|
||||
std::scoped_lock<std::mutex> guard(masterLock);
|
||||
return engine->GetCurrentGameState()->hex_map();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
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);
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
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>
|
||||
/// 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 {(int)timeUntilTest}s";
|
||||
}
|
||||
}
|
||||
return "Server unavailable. Testing...";
|
||||
|
||||
default: return "Unknown state";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1c49d026769d746aeac2ceff7c1f5dc4
|
||||
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using Net.Eagle0.Eagle.Api;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Find the PersistentClientConnection - this assumes it's accessible
|
||||
// In production, this would need proper dependency injection
|
||||
// For now, the connection will be set externally or found via another method
|
||||
}
|
||||
|
||||
/// <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) {
|
||||
var nextTest = _connection.CircuitBreaker.NextTestAttempt;
|
||||
if (nextTest.HasValue) {
|
||||
var timeUntilTest = nextTest.Value - DateTime.UtcNow;
|
||||
if (timeUntilTest.TotalSeconds > 0) {
|
||||
int seconds = (int)Math.Ceiling(timeUntilTest.TotalSeconds);
|
||||
_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) {
|
||||
_textComponent.text = "<color=yellow>●</color> Testing connection...";
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal connection state display
|
||||
var state = _connection.CurrentState;
|
||||
var nextAttempt = _connection.NextReconnectAttempt;
|
||||
|
||||
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 = (int)Math.Ceiling(timeUntilRetry.TotalSeconds);
|
||||
return $"<color=orange>●</color> Retry in {secondsRemaining}s";
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 727a520b2deac4a28ae9c6a00e54c2e7
|
||||
+35
-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,12 @@ 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.
|
||||
Debug.Log($"[EagleGameController] OnApplicationPause({pause})");
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
@@ -255,7 +270,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 +315,7 @@ namespace eagle {
|
||||
movingArmiesTableController.UpdateTables();
|
||||
factionsTableController.UpdateTables();
|
||||
heroesAndBattalionsPanelController.UpdateTables();
|
||||
freeHeroesTableController.UpdateUnaffiliatedHeroSelections();
|
||||
}
|
||||
|
||||
private void UpdateButtons() {
|
||||
@@ -649,7 +671,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
|
||||
+38
-9
@@ -6,14 +6,42 @@ namespace eagle.Notifications.ARNNotifications {
|
||||
public static class ProfessionGainedDetailsNotificationGenerator {
|
||||
public static readonly ARNNotificationGenerator Generator = GenerateNotifications;
|
||||
|
||||
private static readonly Dictionary<Profession, string> ProfessionNames =
|
||||
new Dictionary<Profession, string> {
|
||||
{ Profession.Mage, "Mage" },
|
||||
{ Profession.Necromancer, "Necromancer" },
|
||||
{ Profession.Engineer, "Engineer" },
|
||||
{ Profession.Paladin, "Paladin" },
|
||||
{ Profession.Ranger, "Ranger" },
|
||||
{ Profession.Champion, "Champion" }
|
||||
};
|
||||
|
||||
private static readonly Dictionary<Profession, string> ProfessionTitles =
|
||||
new Dictionary<Profession, string> {
|
||||
{ Profession.Mage, "Arcane Awakening" },
|
||||
{ Profession.Necromancer, "Dark Pact Sealed" },
|
||||
{ Profession.Engineer, "Genius Unleashed" },
|
||||
{ Profession.Paladin, "Divine Calling" },
|
||||
{ Profession.Ranger, "One with the Wild" },
|
||||
{ Profession.Champion, "Born for Battle" }
|
||||
};
|
||||
|
||||
private static 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) {
|
||||
return ProfessionTitles.TryGetValue(profession, out var title) ? title
|
||||
: "Profession Gained";
|
||||
}
|
||||
|
||||
private static IEnumerable<Notification> GenerateNotifications(
|
||||
@@ -23,18 +51,19 @@ namespace eagle.Notifications.ARNNotifications {
|
||||
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 =
|
||||
$"{{Hero}} of {factionName} gained the {professionName} profession.\n\n";
|
||||
$"{{Hero}} of {factionName} became {article} {professionName}.\n\n";
|
||||
|
||||
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
|
||||
+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);
|
||||
|
||||
+540
-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,95 @@ 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;
|
||||
|
||||
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 +134,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 +148,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 +179,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 +482,10 @@ namespace eagle {
|
||||
|
||||
public void Disconnect() {
|
||||
lock (this) {
|
||||
_lastDisconnect = DateTime.UtcNow;
|
||||
LogConnectionEvent("disconnect_explicit");
|
||||
StopIdleCheckTimer();
|
||||
StopHeartbeatTimer();
|
||||
_streamingCall?.Dispose();
|
||||
_streamingCall = null;
|
||||
}
|
||||
@@ -237,11 +494,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 +534,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 +701,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 +757,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 +771,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 +836,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,113 +868,242 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleHeartbeatResponse(HeartbeatResponse response) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[HEARTBEAT] Got response, server_timestamp={response.ServerTimestamp}");
|
||||
|
||||
// Check for sync mismatches reported by server
|
||||
if (response.GameSyncResults.Count > 0) {
|
||||
foreach (var syncResult in response.GameSyncResults) {
|
||||
if (!syncResult.EagleInSync) {
|
||||
_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}");
|
||||
}
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (secondsSinceResponse > HeartbeatTimerSeconds) {
|
||||
SendHeartbeatRequest(sc);
|
||||
|
||||
// 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;
|
||||
@@ -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) {
|
||||
|
||||
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;
|
||||
|
||||
// Ensure Content RectTransform is tall enough to contain the text
|
||||
// This is necessary for ScrollRect to scroll properly
|
||||
if (scrollRect.content.rect.height < contentHeight) {
|
||||
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
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,7 +17,8 @@ message BeastInfo {
|
||||
string plural_name = 3;
|
||||
double likelihood = 4;
|
||||
double max_count_multiplier = 5;
|
||||
double relative_power = 6;
|
||||
double min_relative_power = 6;
|
||||
double max_relative_power = 12;
|
||||
double economy_devastation = 7;
|
||||
double agriculture_devastation = 8;
|
||||
double infrastructure_devastation = 9;
|
||||
|
||||
@@ -805,7 +805,6 @@ go_proto_library(
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/common:common_go_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:api_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",
|
||||
|
||||
@@ -23,6 +23,7 @@ message IncompleteText {
|
||||
string partial_text = 2;
|
||||
.net.eagle0.eagle.internal.GeneratedTextRequest llm_request = 3;
|
||||
int32 requested_after_history_count = 4;
|
||||
int64 requested_at_millis = 5;
|
||||
}
|
||||
|
||||
message UnrequestedText {
|
||||
|
||||
@@ -68,6 +68,10 @@ message GeneratedTextRequestDetails {
|
||||
TruceOfferMessage truce_offer_message = 2;
|
||||
TruceResolutionMessage truce_resolution_message = 8;
|
||||
ProfessionGainedMessage profession_gained_message = 30;
|
||||
PrisonerExecutedMessage prisoner_executed_message = 31;
|
||||
PrisonerReleasedMessage prisoner_released_message = 32;
|
||||
PrisonerExiledMessage prisoner_exiled_message = 33;
|
||||
PrisonerReturnedMessage prisoner_returned_message = 34;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,3 +305,32 @@ message ProfessionGainedMessage {
|
||||
int32 faction_id = 2;
|
||||
.net.eagle0.eagle.common.Profession new_profession = 3;
|
||||
}
|
||||
|
||||
message PrisonerExecutedMessage {
|
||||
int32 executed_hero_id = 1;
|
||||
int32 last_faction_id = 2;
|
||||
int32 executing_faction_id = 3;
|
||||
int32 province_id = 4;
|
||||
}
|
||||
|
||||
message PrisonerReleasedMessage {
|
||||
int32 released_hero_id = 1;
|
||||
int32 last_faction_id = 2;
|
||||
int32 releasing_faction_id = 3;
|
||||
int32 province_id = 4;
|
||||
}
|
||||
|
||||
message PrisonerExiledMessage {
|
||||
int32 exiled_hero_id = 1;
|
||||
int32 last_faction_id = 2;
|
||||
int32 exiling_faction_id = 3;
|
||||
int32 province_id = 4;
|
||||
}
|
||||
|
||||
message PrisonerReturnedMessage {
|
||||
int32 returned_hero_id = 1;
|
||||
int32 last_faction_id = 2;
|
||||
int32 returning_faction_id = 3;
|
||||
int32 to_faction_id = 4;
|
||||
int32 province_id = 5;
|
||||
}
|
||||
|
||||
@@ -1,63 +1,63 @@
|
||||
singularName pluralName likelihood maxCountMultiplier relativePower economyDevastation agricultureDevastation infrastructureDevastation averageGoldPer averageFoodPer
|
||||
agitator agitators 0.1 1 1 5 0 0 1 0
|
||||
asshole assholes 0.05 5 1 2 2 2 0.5 0
|
||||
bandit bandits 0.2 1 1 5 0 0 1 0
|
||||
bear bears 0.04 0.1 10 0 5 5 1 3
|
||||
boar boars 0.04 0.2 5 0 5 5 0 10
|
||||
brigand brigands 0.2 1 1 5 0 0 1 0
|
||||
buccaneer buccaneers 0.05 1 2 5 0 0 2 0
|
||||
cannibal cannibals 0.04 1 3 5 0 0 1 0
|
||||
chimpanzee chimpanzees 0.04 3 1 0 0 5 0 0
|
||||
clown clowns 0.04 1 5 5 0 0 1 0
|
||||
cossack cossacks 0.04 1 5 5 0 0 1 0
|
||||
crocodile crocodiles 0.04 0.04 10 0 5 0 0 1
|
||||
crook crooks 0.1 1 1 5 0 0 1 0
|
||||
cultist cultists 0.05 1 3 5 0 0 1 0
|
||||
dacoit dacoits 0.05 1 1 5 0 0 1 0
|
||||
demon demons 0.01 0.5 50 5 5 0 50 0
|
||||
desperado desperadoes 0.1 1 1 5 0 0 1 0
|
||||
dragon dragons 0.04 0.001 1000 10 10 10 5000 200
|
||||
elephant elephants 0.04 0.1 15 0 5 5 0 8
|
||||
freebooter freebooters 0.05 1 1 5 0 0 1 0
|
||||
giant giants 0.01 0.01 100 10 10 10 50 3
|
||||
heretic heretics 0.05 1 3 5 0 0 1 0
|
||||
highwayman highwaymen 0.1 1 1.5 5 0 0 1 0
|
||||
hippogryph hippogryphs 0.04 0.05 50 5 5 5 1000 100
|
||||
hippopotamus hippopotamuses 0.04 0.1 10 0 0 5 0 5
|
||||
honey badger honey badgers 0.3 3 0.3 0 5 0 0 0.5
|
||||
hooligan hooligans 0.1 1 1 5 0 0 1 0
|
||||
instigator instigators 0.1 1 1 5 0 0 1 0
|
||||
lion lions 0.04 0.2 5 0 5 0 2 1.5
|
||||
mammoth mammoths 0.1 1 1.5 5 0 0 1 0
|
||||
marauder marauders 0.1 1 1.5 5 0 0 1 0
|
||||
miscreant miscreants 0.01 1 1 5 0 0 1 0
|
||||
mobster mobsters 0.05 1 2 5 0 0 2 0
|
||||
ne'er-do-well ne'er-do-wells 0.1 1 0.8 5 0 0 1 0
|
||||
nihilist nihilists 0.1 1 1 5 0 0 1 0
|
||||
ogre ogres 0.02 0.05 20 10 5 5 20 3
|
||||
orc orcs 0.1 1 1.5 5 0 0 1 0
|
||||
particularist particularists 0.1 1 1 5 0 0 1 0
|
||||
pirate pirates 0.05 1 2 5 0 0 2 0
|
||||
proud boy proud boys 0.1 1 1 5 0 0 1 0
|
||||
rabble-rouser rabble-rousers 0.1 1 1 5 0 0 1 0
|
||||
raccoon raccoons 0.2 20 0.2 0 5 0 0 0.1
|
||||
rebel rebels 0.04 2 1 5 0 0 1 0
|
||||
robber robbers 0.1 1 1 5 0 0 1 0
|
||||
ruffian ruffians 0.1 1 1 5 0 0 1 0
|
||||
scalawag scalawags 0.1 1 0.7 5 0 0 0.5 0
|
||||
separatist separatists 0.1 1 1 5 0 0 1 0
|
||||
skeleton skeletons 0.01 1 1 5 0 0 1 0
|
||||
snake snakes 0.2 20 0.2 0 5 0 0 0.1
|
||||
spider spiders 0.1 500 0.01 0 5 0 0 0.001
|
||||
street tough street toughs 0.1 1 1 5 0 0 1 0
|
||||
terrorist terrorists 0.04 1 5 5 0 0 1 0
|
||||
thief thieves 0.1 1 1 5 0 0 1 0
|
||||
tiger tigers 0.1 0.2 3 0 5 0 1 1
|
||||
traitor traitors 0.1 1 2 5 0 0 1 0
|
||||
troll trolls 0.02 0.1 10 5 10 5 50 3
|
||||
unknown unknown 0 0 0 5 0 0 0 0
|
||||
vampire vampires 0.01 0.5 50 5 5 0 50 0
|
||||
velociraptor velociraptors 0.02 0.15 20 10 5 5 20 3
|
||||
wolf wolves 0.3 3 0.3 0 5 0 0 0.5
|
||||
wolverine wolverines 0.4 3 0.3 0 5 0 0 0.5
|
||||
zombie zombies 0.01 1 1 5 0 0 1 0
|
||||
singularName pluralName likelihood maxCountMultiplier minRelativePower maxRelativePower economyDevastation agricultureDevastation infrastructureDevastation averageGoldPer averageFoodPer
|
||||
agitator agitators 0.1 1 1 1 5 0 0 1 0
|
||||
asshole assholes 0.05 5 1 1 2 2 2 0.5 0
|
||||
bandit bandits 0.2 1 1 1 5 0 0 1 0
|
||||
bear bears 0.04 0.1 10 10 0 5 5 1 3
|
||||
boar boars 0.04 0.2 5 5 0 5 5 0 10
|
||||
brigand brigands 0.2 1 1 1 5 0 0 1 0
|
||||
buccaneer buccaneers 0.05 1 2 2 5 0 0 2 0
|
||||
cannibal cannibals 0.04 1 3 3 5 0 0 1 0
|
||||
chimpanzee chimpanzees 0.04 3 1 1 0 0 5 0 0
|
||||
clown clowns 0.04 1 5 50 5 0 0 1 0
|
||||
cossack cossacks 0.04 1 5 5 5 0 0 1 0
|
||||
crocodile crocodiles 0.04 0.04 10 10 0 5 0 0 1
|
||||
crook crooks 0.1 1 1 1 5 0 0 1 0
|
||||
cultist cultists 0.05 1 3 3 5 0 0 1 0
|
||||
dacoit dacoits 0.05 1 1 1 5 0 0 1 0
|
||||
demon demons 0.01 0.5 50 50 5 5 0 50 0
|
||||
desperado desperadoes 0.1 1 1 1 5 0 0 1 0
|
||||
dragon dragons 0.04 0.001 1000 1000 10 10 10 5000 200
|
||||
elephant elephants 0.04 0.1 15 15 0 5 5 0 8
|
||||
freebooter freebooters 0.05 1 1 1 5 0 0 1 0
|
||||
giant giants 0.01 0.01 100 100 10 10 10 50 3
|
||||
heretic heretics 0.05 1 3 3 5 0 0 1 0
|
||||
highwayman highwaymen 0.1 1 1.5 1.5 5 0 0 1 0
|
||||
hippogryph hippogryphs 0.04 0.05 50 50 5 5 5 1000 100
|
||||
hippopotamus hippopotamuses 0.04 0.1 10 10 0 0 5 0 5
|
||||
honey badger honey badgers 0.3 3 0.3 0.3 0 5 0 0 0.5
|
||||
hooligan hooligans 0.1 1 1 1 5 0 0 1 0
|
||||
instigator instigators 0.1 1 1 1 5 0 0 1 0
|
||||
lion lions 0.04 0.2 5 5 0 5 0 2 1.5
|
||||
mammoth mammoths 0.1 1 1.5 1.5 5 0 0 1 0
|
||||
marauder marauders 0.1 1 1.5 1.5 5 0 0 1 0
|
||||
miscreant miscreants 0.01 1 1 1 5 0 0 1 0
|
||||
mobster mobsters 0.05 1 2 2 5 0 0 2 0
|
||||
ne'er-do-well ne'er-do-wells 0.1 1 0.8 0.8 5 0 0 1 0
|
||||
nihilist nihilists 0.1 1 1 1 5 0 0 1 0
|
||||
ogre ogres 0.02 0.05 20 20 10 5 5 20 3
|
||||
orc orcs 0.1 1 1.5 1.5 5 0 0 1 0
|
||||
particularist particularists 0.1 1 1 1 5 0 0 1 0
|
||||
pirate pirates 0.05 1 2 2 5 0 0 2 0
|
||||
proud boy proud boys 0.1 1 1 1 5 0 0 1 0
|
||||
rabble-rouser rabble-rousers 0.1 1 1 1 5 0 0 1 0
|
||||
raccoon raccoons 0.2 20 0.2 0.2 0 5 0 0 0.1
|
||||
rebel rebels 0.04 2 1 1 5 0 0 1 0
|
||||
robber robbers 0.1 1 1 1 5 0 0 1 0
|
||||
ruffian ruffians 0.1 1 1 1 5 0 0 1 0
|
||||
scalawag scalawags 0.1 1 0.7 0.7 5 0 0 0.5 0
|
||||
separatist separatists 0.1 1 1 1 5 0 0 1 0
|
||||
skeleton skeletons 0.01 1 1 1 5 0 0 1 0
|
||||
snake snakes 0.2 20 0.2 0.2 0 5 0 0 0.1
|
||||
spider spiders 0.1 500 0.01 0.01 0 5 0 0 0.001
|
||||
street tough street toughs 0.1 1 1 1 5 0 0 1 0
|
||||
terrorist terrorists 0.04 1 5 5 5 0 0 1 0
|
||||
thief thieves 0.1 1 1 1 5 0 0 1 0
|
||||
tiger tigers 0.1 0.2 3 3 0 5 0 1 1
|
||||
traitor traitors 0.1 1 2 2 5 0 0 1 0
|
||||
troll trolls 0.02 0.1 10 10 5 10 5 50 3
|
||||
unknown unknown 0 0 0 0 5 0 0 0 0
|
||||
vampire vampires 0.01 0.5 50 50 5 5 0 50 0
|
||||
velociraptor velociraptors 0.02 0.15 20 20 10 5 5 20 3
|
||||
wolf wolves 0.3 3 0.3 0.3 0 5 0 0 0.5
|
||||
wolverine wolverines 0.4 3 0.3 0.3 0 5 0 0 0.5
|
||||
zombie zombies 0.01 1 1 1 5 0 0 1 0
|
||||
|
@@ -21,6 +21,7 @@ scala_binary(
|
||||
":external_text_generation_caller",
|
||||
":external_text_generation_service_impl",
|
||||
":open_ai_chat_completions_service_impl",
|
||||
":open_ai_responses_service_impl",
|
||||
":streaming_text_results",
|
||||
],
|
||||
)
|
||||
@@ -52,7 +53,10 @@ scala_library(
|
||||
":external_text_generation_service_impl",
|
||||
":rate_limits",
|
||||
":streaming_text_results",
|
||||
"//src/main/scala/net/eagle0/common/sse:sse_subscriber",
|
||||
"//src/main/scala/net/eagle0/common/sse:okhttp_sse_listener",
|
||||
"@maven//:com_squareup_okhttp3_okhttp",
|
||||
"@maven//:com_squareup_okhttp3_okhttp_sse",
|
||||
"@maven//:com_squareup_okio_okio_jvm",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -86,6 +90,24 @@ scala_library(
|
||||
],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "open_ai_responses_service_impl",
|
||||
srcs = ["OpenAIResponsesServiceImpl.scala"],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
deps = [
|
||||
":api_keys",
|
||||
":external_text_generation_service_impl",
|
||||
":open_ai_duration_parser",
|
||||
":rate_limits",
|
||||
":streaming_text_results",
|
||||
"@maven//:org_json4s_json4s_ast_3",
|
||||
"@maven//:org_json4s_json4s_core_3",
|
||||
"@maven//:org_json4s_json4s_native_3",
|
||||
],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "open_ai_duration_parser",
|
||||
srcs = ["OpenAiDurationParser.scala"],
|
||||
|
||||
+115
-109
@@ -1,21 +1,23 @@
|
||||
package net.eagle0.common.llm_integration
|
||||
|
||||
import java.net.http.{HttpClient, HttpRequest, HttpResponse, HttpTimeoutException}
|
||||
import java.net.http.HttpClient.{Redirect, Version}
|
||||
import java.net.http.HttpResponse.ResponseInfo
|
||||
import java.io.IOException
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketTimeoutException
|
||||
import java.time.{Duration, ZonedDateTime}
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.{Timer, TimerTask}
|
||||
import java.util.concurrent.CompletionException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.function.Consumer
|
||||
|
||||
import scala.concurrent.{ExecutionContext, Future, Promise}
|
||||
import scala.jdk.CollectionConverters.{CollectionHasAsScala, MapHasAsScala}
|
||||
import scala.jdk.CollectionConverters.{CollectionHasAsScala, IterableHasAsScala}
|
||||
import scala.jdk.FutureConverters.CompletionStageOps
|
||||
import scala.util.{Failure, Success}
|
||||
|
||||
import net.eagle0.common.sse.SseSubscriber
|
||||
import net.eagle0.common.sse.OkHttpSseListener
|
||||
import okhttp3.{MediaType, OkHttpClient, Request, RequestBody, Response}
|
||||
import okhttp3.sse.{EventSource, EventSources}
|
||||
|
||||
enum ExternalTextGenerationError extends Error:
|
||||
case RateLimit(code: Int, msg: String)
|
||||
@@ -35,10 +37,51 @@ object ExternalTextGenerationCaller {
|
||||
|
||||
private def nextBackoff(backoffSeconds: Double): Double =
|
||||
Math.min(backoffSeconds * backoffMultiplier, maxBackoffSeconds)
|
||||
|
||||
/** Convert a Java HttpRequest to an OkHttp Request */
|
||||
private def toOkHttpRequest(javaRequest: HttpRequest): Request = {
|
||||
val builder = new Request.Builder()
|
||||
.url(javaRequest.uri().toString)
|
||||
|
||||
// Copy headers
|
||||
javaRequest.headers().map().forEach { (name, values) =>
|
||||
values.forEach(value => builder.addHeader(name, value))
|
||||
}
|
||||
|
||||
// Handle request body
|
||||
javaRequest.method() match {
|
||||
case "GET" => builder.get()
|
||||
case "POST" =>
|
||||
val bodyPublisher = javaRequest.bodyPublisher().orElse(null)
|
||||
if bodyPublisher != null then {
|
||||
// Extract body content - for BodyPublishers.ofString, we can get the content
|
||||
val bodyContent = new StringBuilder()
|
||||
val subscriber = new java.util.concurrent.Flow.Subscriber[java.nio.ByteBuffer] {
|
||||
override def onSubscribe(subscription: java.util.concurrent.Flow.Subscription): Unit =
|
||||
subscription.request(Long.MaxValue)
|
||||
override def onNext(item: java.nio.ByteBuffer): Unit =
|
||||
bodyContent.append(java.nio.charset.StandardCharsets.UTF_8.decode(item).toString)
|
||||
override def onError(throwable: Throwable): Unit = ()
|
||||
override def onComplete(): Unit = ()
|
||||
}
|
||||
bodyPublisher.subscribe(subscriber)
|
||||
val contentType =
|
||||
javaRequest.headers().firstValue("Content-Type").orElse("application/json")
|
||||
val mediaType = MediaType.parse(contentType)
|
||||
builder.post(RequestBody.create(bodyContent.toString, mediaType))
|
||||
} else {
|
||||
builder.post(RequestBody.create("", null))
|
||||
}
|
||||
case other => throw new UnsupportedOperationException(s"HTTP method $other not supported")
|
||||
}
|
||||
|
||||
builder.build()
|
||||
}
|
||||
}
|
||||
|
||||
final class ExternalTextGenerationCaller(
|
||||
val timeoutSeconds: Int = 10,
|
||||
val readTimeoutSeconds: Int = 60,
|
||||
serviceImpl: ExternalTextGenerationServiceImpl
|
||||
) {
|
||||
private var inProgressCount = 0
|
||||
@@ -57,13 +100,17 @@ final class ExternalTextGenerationCaller(
|
||||
|
||||
implicit val ec: ExecutionContext = ExecutionContext.global
|
||||
|
||||
private val httpClient = HttpClient
|
||||
.newBuilder()
|
||||
.version(Version.HTTP_2)
|
||||
.followRedirects(Redirect.NORMAL)
|
||||
.connectTimeout(Duration.ofSeconds(timeoutSeconds))
|
||||
// OkHttp client with read timeout - the key benefit over Java HttpClient
|
||||
// If no data is received for readTimeoutSeconds, the connection will timeout
|
||||
private val okHttpClient = new OkHttpClient.Builder()
|
||||
.connectTimeout(timeoutSeconds.toLong, TimeUnit.SECONDS)
|
||||
.readTimeout(readTimeoutSeconds.toLong, TimeUnit.SECONDS)
|
||||
.writeTimeout(timeoutSeconds.toLong, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
|
||||
private val eventSourceFactory = EventSources.createFactory(okHttpClient)
|
||||
|
||||
private var successDurations =
|
||||
new scala.collection.mutable.ArrayBuffer[Long]()
|
||||
|
||||
@@ -72,7 +119,7 @@ final class ExternalTextGenerationCaller(
|
||||
partialCompletion: Option[String],
|
||||
streamingConsumer: Consumer[StreamingTextResults],
|
||||
backoffSeconds: Double = ExternalTextGenerationCaller.initialBackoffSeconds
|
||||
): Future[HttpResponse[Unit]] =
|
||||
): Future[Unit] =
|
||||
streamCompletion(
|
||||
inputText = inputText,
|
||||
request = serviceImpl.makeRequest(
|
||||
@@ -88,13 +135,13 @@ final class ExternalTextGenerationCaller(
|
||||
request: HttpRequest,
|
||||
backoffSeconds: Double,
|
||||
streamingConsumer: Consumer[StreamingTextResults]
|
||||
): Future[HttpResponse[Unit]] = {
|
||||
): Future[Unit] = {
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
def tryAgain: () => Future[HttpResponse[Unit]] = () => {
|
||||
def tryAgain: () => Future[Unit] = () => {
|
||||
println(s"Trying again after $backoffSeconds seconds...")
|
||||
|
||||
val promise = Promise[HttpResponse[Unit]]()
|
||||
val promise = Promise[Unit]()
|
||||
val t = new Timer()
|
||||
t.schedule(
|
||||
new TimerTask {
|
||||
@@ -113,104 +160,63 @@ final class ExternalTextGenerationCaller(
|
||||
promise.future
|
||||
}
|
||||
|
||||
val okHttpRequest = ExternalTextGenerationCaller.toOkHttpRequest(request)
|
||||
val sseListener = new OkHttpSseListener(serviceImpl.stringConsumer(streamingConsumer))
|
||||
|
||||
inProgressCount += 1
|
||||
httpClient
|
||||
.sendAsync(
|
||||
request,
|
||||
(respInfo: ResponseInfo) =>
|
||||
if respInfo.statusCode() == HttpURLConnection.HTTP_OK then
|
||||
new SseSubscriber(serviceImpl.stringConsumer(streamingConsumer))
|
||||
else
|
||||
// For error responses, read the body to get error details
|
||||
HttpResponse.BodySubscribers.mapping(
|
||||
HttpResponse.BodySubscribers.ofString(java.nio.charset.StandardCharsets.UTF_8),
|
||||
(body: String) => {
|
||||
val code = respInfo.statusCode()
|
||||
println(s"Error response ($code): $body")
|
||||
// 4xx errors (except 429) are client errors - don't retry
|
||||
if code >= 400 && code < 500 && code != 429 then
|
||||
throw ExternalTextGenerationError.Http(
|
||||
code,
|
||||
s"Client error $code: $body"
|
||||
)
|
||||
else
|
||||
throw new RuntimeException(
|
||||
s"Server error $code: $body"
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
.asScala
|
||||
.andThen {
|
||||
case x =>
|
||||
inProgressCount -= 1
|
||||
x
|
||||
}
|
||||
.transform {
|
||||
case Success(httpResponse) =>
|
||||
val rateLimits = serviceImpl.rateLimitsFrom(
|
||||
headers = httpResponse
|
||||
.headers()
|
||||
.map()
|
||||
.asScala
|
||||
.map { case (k, v) => (k, v.asScala.toVector) }
|
||||
.toMap
|
||||
|
||||
// Start the SSE connection
|
||||
val eventSource = eventSourceFactory.newEventSource(okHttpRequest, sseListener)
|
||||
|
||||
// Convert the CompletableFuture to a Scala Future
|
||||
sseListener.getFuture.asScala.andThen {
|
||||
case _ =>
|
||||
inProgressCount -= 1
|
||||
}.transform {
|
||||
case Success(_) =>
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
this.synchronized {
|
||||
successDurations.addOne(duration)
|
||||
if successDurations.size % 100 == 0 then {
|
||||
successDurations = successDurations.sorted
|
||||
val p99pos =
|
||||
successDurations.size - successDurations.size / 100
|
||||
println(s"p99 duration: ${successDurations(p99pos)}ms")
|
||||
}
|
||||
}
|
||||
Success(())
|
||||
|
||||
case Failure(e: SocketTimeoutException) =>
|
||||
println(s"Read timeout after ${readTimeoutSeconds}s: ${e.getMessage}")
|
||||
Failure(
|
||||
ExternalTextGenerationError.Timeout(
|
||||
s"Read timeout after ${readTimeoutSeconds}s: ${e.getMessage}"
|
||||
)
|
||||
currentRateLimits = rateLimits
|
||||
currentRateLimitTime = ZonedDateTime.now()
|
||||
)
|
||||
|
||||
val responseCode = httpResponse.statusCode()
|
||||
if responseCode < HttpURLConnection.HTTP_BAD_REQUEST then {
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
|
||||
this.synchronized {
|
||||
successDurations.addOne(duration)
|
||||
if successDurations.size % 100 == 0 then {
|
||||
successDurations = successDurations.sorted
|
||||
val p99pos =
|
||||
successDurations.size - successDurations.size / 100
|
||||
println(s"p99 duration: ${successDurations(p99pos)}ms")
|
||||
}
|
||||
|
||||
Success(httpResponse)
|
||||
}
|
||||
} else if responseCode == 429 then
|
||||
Failure(
|
||||
ExternalTextGenerationError.RateLimit(
|
||||
responseCode,
|
||||
httpResponse.toString
|
||||
)
|
||||
)
|
||||
else
|
||||
Failure(
|
||||
ExternalTextGenerationError.Http(
|
||||
responseCode,
|
||||
"An error occurred while generating a response.\n" + httpResponse
|
||||
)
|
||||
)
|
||||
|
||||
case Failure(timeoutException: HttpTimeoutException) =>
|
||||
Failure(
|
||||
ExternalTextGenerationError.Timeout(
|
||||
s"Timed out: ${timeoutException.getMessage}"
|
||||
)
|
||||
case Failure(e: IOException) if e.getMessage != null && e.getMessage.contains("timeout") =>
|
||||
println(s"Timeout: ${e.getMessage}")
|
||||
Failure(
|
||||
ExternalTextGenerationError.Timeout(
|
||||
s"Timeout: ${e.getMessage}"
|
||||
)
|
||||
)
|
||||
|
||||
case Failure(exception) => Failure(exception)
|
||||
}
|
||||
.recoverWith {
|
||||
// Don't retry 4xx client errors - they won't succeed
|
||||
case e: CompletionException if e.getCause.isInstanceOf[ExternalTextGenerationError.Http] =>
|
||||
Future.failed(e.getCause)
|
||||
case e: ExternalTextGenerationError.Http =>
|
||||
Future.failed(e)
|
||||
// Retry transient errors (5xx, timeouts, network issues)
|
||||
case e: CompletionException =>
|
||||
println(s"CompletionException error $e - retrying")
|
||||
tryAgain()
|
||||
case e: Throwable =>
|
||||
println(s"error $e - retrying")
|
||||
tryAgain()
|
||||
}
|
||||
case Failure(exception) => Failure(exception)
|
||||
}.recoverWith {
|
||||
// Don't retry 4xx client errors - they won't succeed
|
||||
case e: ExternalTextGenerationError.Http =>
|
||||
Future.failed(e)
|
||||
// Retry transient errors (timeouts, network issues)
|
||||
case e: ExternalTextGenerationError.Timeout =>
|
||||
println(s"Timeout error - retrying: ${e.message}")
|
||||
tryAgain()
|
||||
case e: IOException =>
|
||||
println(s"IOException - retrying: ${e.getMessage}")
|
||||
tryAgain()
|
||||
case e: Throwable =>
|
||||
println(s"error $e - retrying")
|
||||
tryAgain()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -4,6 +4,9 @@ import scala.concurrent.duration.{Duration, SECONDS}
|
||||
import scala.concurrent.Await
|
||||
//import scala.util.Random
|
||||
|
||||
enum LlmProvider:
|
||||
case Claude, OpenAIChatCompletions, OpenAIResponses
|
||||
|
||||
object ExternalTextGenerationCallerApp {
|
||||
def main(args: Array[String]): Unit = {
|
||||
// val personalityWords = Vector(
|
||||
@@ -30,8 +33,16 @@ object ExternalTextGenerationCallerApp {
|
||||
val chatGptImpl: ExternalTextGenerationServiceImpl =
|
||||
new OpenAIChatCompletionsServiceImpl()
|
||||
|
||||
val caller = new ExternalTextGenerationCaller(
|
||||
serviceImpl = if true then claudeImpl else chatGptImpl
|
||||
val openAiResponsesImpl: ExternalTextGenerationServiceImpl =
|
||||
new OpenAIResponsesServiceImpl()
|
||||
|
||||
val selectedProvider = LlmProvider.OpenAIResponses
|
||||
val caller = new ExternalTextGenerationCaller(
|
||||
serviceImpl = selectedProvider match {
|
||||
case LlmProvider.Claude => claudeImpl
|
||||
case LlmProvider.OpenAIChatCompletions => chatGptImpl
|
||||
case LlmProvider.OpenAIResponses => openAiResponsesImpl
|
||||
}
|
||||
)
|
||||
|
||||
(0 to 0).foreach { _ =>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package net.eagle0.common.llm_integration
|
||||
|
||||
import java.net.{URI, URL}
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpRequest.BodyPublishers
|
||||
import java.time.{Duration, ZonedDateTime}
|
||||
import java.util.function.Consumer
|
||||
|
||||
import org.json4s.{DefaultFormats, JString}
|
||||
import org.json4s.jvalue2extractable
|
||||
import org.json4s.jvalue2monadic
|
||||
import org.json4s.native.{Json, Serialization}
|
||||
|
||||
/**
|
||||
* OpenAI Responses API implementation.
|
||||
*
|
||||
* The Responses API is OpenAI's newer API primitive that offers: - Better performance with reasoning models (3%
|
||||
* improvement in SWE-bench) - Lower costs through improved cache utilization (40-80% improvement) - Semantic streaming
|
||||
* events with clear lifecycle (response.created, response.output_text.delta, response.completed) - Built-in tools (web
|
||||
* search, file search, computer use, code interpreter)
|
||||
*
|
||||
* @see
|
||||
* https://platform.openai.com/docs/api-reference/responses
|
||||
*/
|
||||
object OpenAIResponsesServiceImpl {
|
||||
val gpt5: String = "gpt-5.1"
|
||||
|
||||
private val apiKey = ApiKeys.openAI
|
||||
private val baseURL = new URL("https://api.openai.com/v1/responses")
|
||||
private val temperature: Double = 1.0
|
||||
|
||||
private def baseRequest(timeoutSeconds: Int): HttpRequest.Builder =
|
||||
HttpRequest
|
||||
.newBuilder()
|
||||
.uri(URI.create(baseURL.toString))
|
||||
.timeout(Duration.ofSeconds(timeoutSeconds))
|
||||
.header(
|
||||
"Authorization",
|
||||
s"Bearer ${OpenAIResponsesServiceImpl.apiKey}"
|
||||
)
|
||||
}
|
||||
|
||||
class OpenAIResponsesServiceImpl(
|
||||
timeoutSeconds: Int = 10,
|
||||
val defaultModelName: String = "gpt-5.1"
|
||||
) extends ExternalTextGenerationServiceImpl {
|
||||
implicit val jsonFormats: DefaultFormats.type = DefaultFormats
|
||||
|
||||
private def requestDictionary(
|
||||
inputText: String,
|
||||
partialCompletion: Option[String]
|
||||
): Map[String, Any] = {
|
||||
// For partial completions, we need to format as an array of input items
|
||||
// The Responses API uses "input" instead of "messages"
|
||||
val input = partialCompletion match {
|
||||
case Some(partial) =>
|
||||
// When we have a partial completion, send as array of items
|
||||
Vector(
|
||||
Map("type" -> "message", "role" -> "user", "content" -> inputText),
|
||||
Map("type" -> "message", "role" -> "assistant", "content" -> partial)
|
||||
)
|
||||
case None =>
|
||||
// Simple text input when no partial completion
|
||||
inputText
|
||||
}
|
||||
|
||||
Map(
|
||||
"model" -> defaultModelName,
|
||||
"temperature" -> OpenAIResponsesServiceImpl.temperature,
|
||||
"stream" -> true,
|
||||
"input" -> input
|
||||
)
|
||||
}
|
||||
|
||||
override def makeRequest(
|
||||
inputText: String,
|
||||
partialCompletion: Option[String]
|
||||
): HttpRequest =
|
||||
OpenAIResponsesServiceImpl
|
||||
.baseRequest(timeoutSeconds = timeoutSeconds)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(
|
||||
BodyPublishers.ofString(
|
||||
Serialization.write(
|
||||
requestDictionary(
|
||||
inputText = inputText,
|
||||
partialCompletion = partialCompletion
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.build()
|
||||
|
||||
override def stringConsumer(
|
||||
streamingConsumer: Consumer[StreamingTextResults]
|
||||
): Consumer[String] = (t: String) => {
|
||||
val json = new Json(DefaultFormats)
|
||||
val parsedJson =
|
||||
try
|
||||
json.parse(t)
|
||||
catch {
|
||||
case pe: org.json4s.ParserUtil.ParseException =>
|
||||
println(s"Failed to parse JSON: $t")
|
||||
throw pe
|
||||
}
|
||||
|
||||
val eventType = (parsedJson \ "type").extractOpt[String]
|
||||
|
||||
eventType match {
|
||||
case Some("response.output_text.delta") =>
|
||||
// Text delta event - extract the delta and stream ID
|
||||
val delta = (parsedJson \ "delta").extract[String]
|
||||
val itemId = (parsedJson \ "item_id").extractOpt[String].getOrElse("unknown")
|
||||
val streamId = itemId
|
||||
|
||||
streamingConsumer.accept(
|
||||
StreamingTextResults(
|
||||
streamId = streamId,
|
||||
value = delta,
|
||||
completed = false
|
||||
)
|
||||
)
|
||||
|
||||
case Some("response.output_text.done") =>
|
||||
// Text is complete for this output item
|
||||
val itemId = (parsedJson \ "item_id").extractOpt[String].getOrElse("unknown")
|
||||
val streamId = itemId
|
||||
|
||||
streamingConsumer.accept(
|
||||
StreamingTextResults(
|
||||
streamId = streamId,
|
||||
value = "",
|
||||
completed = true
|
||||
)
|
||||
)
|
||||
|
||||
case Some("response.completed") =>
|
||||
// Response is fully complete - extract the response ID as streamId
|
||||
val responseId = (parsedJson \ "response" \ "id").extractOpt[String].getOrElse("unknown")
|
||||
streamingConsumer.accept(
|
||||
StreamingTextResults(
|
||||
streamId = responseId,
|
||||
value = "",
|
||||
completed = true
|
||||
)
|
||||
)
|
||||
|
||||
case Some("response.created") | Some("response.in_progress") | Some("response.output_item.added") | Some(
|
||||
"response.content_part.added"
|
||||
) =>
|
||||
// Lifecycle events - ignore, no content to stream
|
||||
()
|
||||
|
||||
case Some("error") =>
|
||||
// Error event
|
||||
val errorMsg = (parsedJson \ "error" \ "message").extractOpt[String].getOrElse("Unknown error")
|
||||
throw new RuntimeException(s"OpenAI Responses API error: $errorMsg")
|
||||
|
||||
case Some(other) =>
|
||||
// Unknown event type - log but continue
|
||||
println(s"OpenAI Responses API: ignoring unknown event type: $other")
|
||||
|
||||
case None =>
|
||||
// No event type - might be malformed, log it
|
||||
println(s"OpenAI Responses API: received message without event type: $t")
|
||||
}
|
||||
}
|
||||
|
||||
override def rateLimitsFrom(
|
||||
headers: Map[String, Vector[String]]
|
||||
): Option[RateLimits] =
|
||||
// The Responses API uses the same rate limit headers as Chat Completions
|
||||
for {
|
||||
requestLimit <- headers.get("x-ratelimit-limit-requests")
|
||||
tokenLimit <- headers.get("x-ratelimit-limit-tokens")
|
||||
requestsRemaining <- headers.get("x-ratelimit-remaining-requests")
|
||||
tokensRemaining <- headers.get("x-ratelimit-remaining-tokens")
|
||||
requestResetTime <- headers.get("x-ratelimit-reset-requests")
|
||||
tokenResetTime <- headers.get("x-ratelimit-reset-tokens")
|
||||
} yield RateLimits(
|
||||
requestLimit = requestLimit.head.toInt,
|
||||
tokenLimit = tokenLimit.head.toInt,
|
||||
requestsRemaining = requestsRemaining.head.toInt,
|
||||
tokensRemaining = tokensRemaining.head.toInt,
|
||||
requestResetTime = ZonedDateTime
|
||||
.now()
|
||||
.plus(
|
||||
OpenAiDurationParser
|
||||
.parseDuration(requestResetTime.head)
|
||||
.getOrElse(Duration.ZERO)
|
||||
),
|
||||
tokenResetTime = ZonedDateTime
|
||||
.now()
|
||||
.plus(
|
||||
OpenAiDurationParser
|
||||
.parseDuration(tokenResetTime.head)
|
||||
.getOrElse(Duration.ZERO)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -18,3 +18,13 @@ scala_library(
|
||||
"@maven//:org_json4s_json4s_core_3",
|
||||
],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "okhttp_sse_listener",
|
||||
srcs = ["OkHttpSseListener.scala"],
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"@maven//:com_squareup_okhttp3_okhttp",
|
||||
"@maven//:com_squareup_okhttp3_okhttp_sse",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package net.eagle0.common.sse
|
||||
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.function.Consumer
|
||||
|
||||
import okhttp3.sse.{EventSource, EventSourceListener}
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* OkHttp-based SSE listener that wraps our existing SseEventReader logic.
|
||||
*
|
||||
* Key benefit over Java HttpClient: OkHttp supports read timeouts, so if the server stops sending data without properly
|
||||
* closing the connection, we'll get a timeout error instead of waiting forever.
|
||||
*/
|
||||
class OkHttpSseListener(messageDataConsumer: Consumer[String]) extends EventSourceListener {
|
||||
|
||||
private val future: CompletableFuture[Unit] = new CompletableFuture[Unit]
|
||||
|
||||
private val DoneToken = "[DONE]"
|
||||
|
||||
def getFuture: CompletableFuture[Unit] = future
|
||||
|
||||
override def onOpen(eventSource: EventSource, response: Response): Unit = {
|
||||
// Connection established, nothing to do
|
||||
}
|
||||
|
||||
override def onEvent(
|
||||
eventSource: EventSource,
|
||||
id: String,
|
||||
`type`: String,
|
||||
data: String
|
||||
): Unit =
|
||||
try
|
||||
if data != DoneToken then {
|
||||
messageDataConsumer.accept(data)
|
||||
}
|
||||
// If it's [DONE], just ignore - onClosed will be called
|
||||
catch {
|
||||
case e: Exception =>
|
||||
val _ = future.completeExceptionally(e)
|
||||
eventSource.cancel()
|
||||
}
|
||||
|
||||
override def onClosed(eventSource: EventSource): Unit =
|
||||
if !future.isDone then {
|
||||
val _ = future.complete(())
|
||||
}
|
||||
|
||||
override def onFailure(
|
||||
eventSource: EventSource,
|
||||
t: Throwable,
|
||||
response: Response
|
||||
): Unit =
|
||||
if !future.isDone then {
|
||||
if response != null then {
|
||||
println(s"SSE failure with response code ${response.code()}: ${t.getMessage}")
|
||||
} else {
|
||||
println(s"SSE failure: ${t.getMessage}")
|
||||
}
|
||||
val _ = future.completeExceptionally(t)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
|
||||
import net.eagle0.eagle.api.available_command.AvailableCommand
|
||||
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
|
||||
import net.eagle0.eagle.api.selected_command.SelectedCommand
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
|
||||
import net.eagle0.eagle.library.util.*
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.{
|
||||
AlmsCommandSelector,
|
||||
@@ -16,6 +16,7 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
|
||||
}
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
|
||||
import net.eagle0.eagle.library.Engine
|
||||
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
|
||||
|
||||
case class AIClientWithSelectedCommand(
|
||||
client: AIClient,
|
||||
@@ -54,13 +55,13 @@ case class AIClient(
|
||||
else
|
||||
chooseFrom(
|
||||
maybeCommandsMap.get.commandsByProvince,
|
||||
engine.currentState,
|
||||
GameStateConverter.toProto(engine.currentState),
|
||||
functionalRandom
|
||||
)
|
||||
}
|
||||
|
||||
private def chooseMidGameCommandFrom(
|
||||
gameState: GameState,
|
||||
gameState: GameStateProto,
|
||||
oneProvinceAvailableCommands: OneProvinceAvailableCommands,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[AIClientWithSelectedCommand] =
|
||||
@@ -115,7 +116,7 @@ case class AIClient(
|
||||
|
||||
private def chooseFrom(
|
||||
acs: Map[ProvinceId, OneProvinceAvailableCommands],
|
||||
gs: GameState,
|
||||
gs: GameStateProto,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[AIClientWithSelectedCommand] = {
|
||||
val opac = acs.head._2
|
||||
@@ -186,7 +187,7 @@ case class AIClient(
|
||||
),
|
||||
(
|
||||
fid: FactionId,
|
||||
gs: GameState,
|
||||
gs: GameStateProto,
|
||||
acs: Vector[AvailableCommand],
|
||||
functionalRandom
|
||||
) =>
|
||||
|
||||
@@ -25,6 +25,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:alms_command_selector",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:attack_decision_command_chooser",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -21,7 +21,8 @@ case class IncompleteClientText(
|
||||
id: ClientTextId,
|
||||
partialText: String,
|
||||
requestedAfterHistoryCount: Int,
|
||||
llmRequest: GeneratedTextRequest
|
||||
llmRequest: GeneratedTextRequest,
|
||||
requestedAtMillis: Long
|
||||
) extends ClientText {
|
||||
def append(newText: String): IncompleteClientText =
|
||||
copy(partialText = partialText + newText)
|
||||
|
||||
@@ -17,6 +17,15 @@ trait ClientTextStore {
|
||||
def unrequestedTexts: Map[ClientTextId, UnrequestedClientText]
|
||||
def accessibleTo: Map[ClientTextId, Vector[FactionId]]
|
||||
|
||||
/** Returns incomplete texts that have been waiting longer than the threshold */
|
||||
def stalledIncompleteTexts(
|
||||
thresholdMillis: Long,
|
||||
currentTimeMillis: Long = System.currentTimeMillis()
|
||||
): Vector[IncompleteClientText] =
|
||||
incompleteTexts.values
|
||||
.filter(ict => currentTimeMillis - ict.requestedAtMillis > thresholdMillis)
|
||||
.toVector
|
||||
|
||||
def saved: ClientTextStore
|
||||
|
||||
def withAddedTextRequest(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user