Compare commits

..
Author SHA1 Message Date
admin 478ad98081 Simplify MCTS scoring to always use root player's perspective
Replaced complex playerId-to-role mapping logic with simpler approach that
always uses the root player's perspective (isDefender_).

The previous implementation tried to determine the requested player's role
from the game state and score from their perspective. However, MCTS scoring
should always be from the root player's perspective - the adversarial logic
(negating scores for opponent moves) happens in the MCTS selection algorithm,
not in the state scoring function.

This simplification:
- Makes the scoring behavior more predictable
- Aligns with MCTS best practices (fixed evaluation perspective)
- Removes unnecessary game state lookups
- Clarifies the separation of concerns between scoring and selection
2025-11-09 07:14:18 -08:00
366 changed files with 8950 additions and 26475 deletions
-3
View File
@@ -29,9 +29,6 @@ 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
+1 -45
View File
@@ -34,54 +34,10 @@ jobs:
with:
lfs: false
- name: Run tests
id: test
continue-on-error: true
run: bazel test --build_event_json_file=test.json //src/test/... //src/main/go/...
- name: Collect failed test logs
if: always()
run: |
# Remove any existing failed_test_logs directory and create fresh
rm -rf failed_test_logs
mkdir -p failed_test_logs
# Extract failed test targets from test.json and copy their logs
# The test.json is in JSONL format - one JSON object per line
# We look for lines with testResult that have a status other than PASSED
if [ -f test.json ]; then
grep '"testResult"' test.json | \
grep '"status"' | \
grep -v '"status":"PASSED"' | \
grep -o '"label":"[^"]*"' | \
cut -d'"' -f4 | \
sort -u | \
while read target; do
# Convert target like //src/test/cpp/...:test_name to path
log_path=$(echo "$target" | sed 's|^//||' | sed 's|:|/|')
if [ -f "bazel-testlogs/$log_path/test.log" ]; then
log_name=$(echo "$log_path" | tr '/' '_')
if cp "bazel-testlogs/$log_path/test.log" "failed_test_logs/${log_name}.log"; then
echo "Collected log for failed test: $target"
else
echo "Error: Failed to copy log for $target"
fi
fi
done
fi
# List what we collected
echo "Collected logs:"
ls -lh failed_test_logs/ 2>/dev/null || echo "No logs collected"
- name: Archive test results
if: always()
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: test.json
path: test.json
- name: Archive failed test logs
if: always()
uses: actions/upload-artifact@v4
with:
name: failed-test-logs
path: failed_test_logs/
if-no-files-found: ignore
- name: Fail if tests failed
if: steps.test.outcome == 'failure'
run: exit 1
-1
View File
@@ -37,4 +37,3 @@ 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/
+1 -2
View File
@@ -32,9 +32,8 @@ repos:
- id: gazelle
name: gazelle
language: system
entry: ./scripts/pre-commit-gazelle.sh
entry: bazel run //:gazelle
files: '(\.go|\.proto|BUILD\.bazel|BUILD|WORKSPACE|WORKSPACE\.bazel|\.bzl)$'
pass_filenames: false
- repo: local
hooks:
- id: update-action-result-types
-61
View File
@@ -85,16 +85,6 @@ 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
@@ -216,31 +206,6 @@ 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:
@@ -279,32 +244,6 @@ 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/`
-1
View File
@@ -76,7 +76,6 @@ 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",
)
+2 -1
View File
@@ -1 +1,2 @@
UNITY_VERSION='6000.3.0f1'
UNITY_VERSION='6000.2.7f2'
File diff suppressed because it is too large Load Diff
-297
View File
@@ -1,297 +0,0 @@
# 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
→ ActionResultTApplier.applyActionResults()
→ GameStateC
(Proto conversion only at boundaries)
```
### Key Files to Convert
**Tier 1 - Core Applier:**
```
src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultProtoApplierImpl.scala
```
Create `ActionResultApplier` that applies `ActionResultT` directly to Scala `GameState`.
**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 | Target |
|------|-------|--------|
| `ActionResultTApplierImpl.scala` | Converts T→Proto, delegates to proto applier | Replace with `ActionResultApplier` |
| `RoundPhaseAdvancer.scala` | ~~20 converter calls~~ | ✅ **Complete** - uses Scala GameState |
| `RandomStateTSequencer.scala` | Converts T→Proto internally | Thread Scala GameState throughout |
| `RandomStateProtoSequencer.scala` | Returns `Vector[ActionResultProto]` | Evaluate if still needed |
| `VigorXPApplier.scala` | Wraps proto results | Convert to work with T |
| `ResolveBattleAction.scala` | 2 converter calls | Convert after dependencies |
| `PerformForcedTurnBackAction.scala` | 1 converter call | Convert after dependencies |
| `EndFreeForAllDecisionPhaseAction.scala` | ~~fromProtoState~~ | **Complete** - now takes Scala GameState |
| `EndBattleRequestPhaseAction.scala` | ~~fromProtoState~~ | **Complete** - now takes Scala GameState |
| `EndDefenseDecisionPhaseAction.scala` | ~~fromProtoState~~ | **Complete** - now takes Scala GameState |
| `EndPleaseRecruitMePhaseAction.scala` | ~~fromProtoState~~ | **Complete** - now takes Scala GameState |
| `InMemoryHistory.scala` | Stores proto results | Vend Scala types, remove proto entirely |
| `PersistedHistory.scala` | Stores proto results | Vend Scala types, convert internally for disk |
| `GameController.scala` | Uses proto for client communication | Keep proto (gRPC boundary) |
### Estimated Effort
| Component | Lines | Complexity |
|-----------|-------|------------|
| `ActionResultApplier` | ~900 | High (port of proto applier) |
| `RandomStateTSequencer` refactor | ~150 | Medium |
| `RoundPhaseAdvancer` updates | ~100 | Medium |
| History API updates | ~100 | Low |
| Action/utility updates | ~200 | Low |
| **Total** | **~1450** | |
### Validation
- [ ] `ActionResultApplier` created and tested
- [ ] `RandomStateTSequencer` threads Scala GameState throughout
- [ ] `RoundPhaseAdvancer` uses T-types internally
- [ ] 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.
-1
View File
@@ -9,7 +9,6 @@ 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
)
-2
View File
@@ -40,8 +40,6 @@ 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=
+2 -3
View File
@@ -3,8 +3,7 @@
set -euxo pipefail
/bin/echo "building darwin bundle"
bazel build --config=mactools @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
ZIP_LOCATION=$(bazel cquery --config=mactools --output=files @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle 2>/dev/null)
/usr/bin/unzip -o $ZIP_LOCATION -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
bazel build --noincompatible_enable_cc_toolchain_resolution @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
/usr/bin/unzip -o bazel-bin/external/net_eagle0_unity_godice/darwin/framework/DarwinGodiceBundle.zip -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
/usr/bin/plutil -convert xml1 src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/DarwinGodiceBundle.bundle/Contents/Info.plist
+2 -3
View File
@@ -5,9 +5,8 @@ set -euxo pipefail
/bin/echo "build plugins"
/bin/echo "building darwin bundle"
bazel build --config=mactools @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
ZIP_LOCATION=$(bazel cquery --config=mactools --output=files @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle 2>/dev/null)
/usr/bin/unzip -o $ZIP_LOCATION -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
bazel build --noincompatible_enable_cc_toolchain_resolution @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
/usr/bin/unzip -o bazel-bin/external/net_eagle0_unity_godice/darwin/framework/DarwinGodiceBundle.zip -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
/usr/bin/plutil -convert xml1 src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/DarwinGodiceBundle.bundle/Contents/Info.plist
+2 -4
View File
@@ -1,10 +1,8 @@
#!/usr/bin/env bash
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
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
bazel run //src/main/go/net/eagle0/build/settings_generator:settings_generator -- \
${PWD}/src/main/resources/net/eagle0/eagle/settings.tsv \
${PWD}/src/main/scala/net/eagle0/eagle/library/settings/
bazel run gazelle
+4 -4
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env bash
curl -L "https://docs.google.com/spreadsheets/d/1DHEsiv4cY4gE6AX3sVH82K__mpBD1aznIYCQwQxA_F0/export?gid=0&format=tsv" | tr -d '\r' > /tmp/names.tsv
curl -L "https://docs.google.com/spreadsheets/d/1DHEsiv4cY4gE6AX3sVH82K__mpBD1aznIYCQwQxA_F0/export?gid=0&format=tsv" > /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" | 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/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/1Z-60cJ_N1IasvqpVb5awKEkIYznEeR2IZSdli47oW88/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/province_map.tsv
${PWD}/scripts/dlSettings.sh
-19
View File
@@ -1,19 +0,0 @@
#!/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
@@ -14,14 +14,6 @@
#include "src/main/cpp/net/eagle0/common/RandomGenerator.hpp"
// A deterministic random generator that returns values from a fixed sequence.
// Used for testing and MCTS simulation where we want specific, predictable outcomes.
//
// Values in the sequence are treated as [0, 1] probabilities that are returned
// by DoubleZeroToOne(). The normal percentile methods (including open-ended
// variants) work as usual, so callers must provide appropriate sequences.
// For example, to get an open-ended low result of -50, provide [0.02, 0.52]
// which produces: initial=2 (triggers open-ended), accumulated=52, final=2-52=-50
class SequenceRandomGenerator : public ::RandomGenerator {
private:
const std::vector<double> sequence;
@@ -5,7 +5,6 @@
#include "AbstractMCTSAI.hpp"
#include <algorithm>
#include <chrono>
#include <fstream>
#include <future>
#include <iomanip>
@@ -15,8 +14,6 @@
#include <stdexcept>
#include <thread>
#include "src/main/cpp/net/eagle0/common/mcts/util/TreeIndentUtil.hpp"
namespace shardok::mcts {
AbstractMCTSAI::AbstractMCTSAI(MCTSPlayerId playerId, MCTSConfig config)
@@ -85,7 +82,7 @@ auto AbstractMCTSAI::Search(
LogSearchResults(rootNode.get(), bestChild, result);
}
// Dump tree if explicitly requested via config
// Dump tree if requested
if (!config_.debugDumpPath.empty()) { DumpTreeToFile(rootNode.get(), config_.debugDumpPath); }
return result;
@@ -130,37 +127,6 @@ 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) {
@@ -207,7 +173,7 @@ auto AbstractMCTSAI::BuildMCTSTree(
while (std::chrono::steady_clock::now() < deadline) {
// Selection
auto* selected = MCTSSelection(root.get());
if (!selected) { break; }
if (!selected) break;
// Expansion
auto* expanded = MCTSExpansion(selected, engine);
@@ -237,27 +203,11 @@ auto AbstractMCTSAI::BuildMCTSTree(
auto AbstractMCTSAI::MCTSSelection(MCTSNode* root) const -> MCTSNode* {
MCTSNode* current = root;
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)
while (!current->isTerminal && current->depth < config_.maxTreeDepth) {
if (current->CanExpand()) {
return current; // Node has untried actions/outcomes
}
// 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
current = current->GetBestChanceChild();
} else {
// Decision nodes: select using UCB1
current = current->GetBestChild(config_.explorationConstant);
}
return current; // Node has untried actions
} else if (!current->children.empty()) {
current = current->GetBestChild(config_.explorationConstant);
if (!current) break;
} else {
break; // Leaf node
@@ -269,109 +219,10 @@ auto AbstractMCTSAI::MCTSSelection(MCTSNode* root) const -> MCTSNode* {
auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine) const
-> MCTSNode* {
// 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()) {
if (!node->CanExpand() || node->isTerminal) {
return node; // Nothing to expand
}
// Handle chance node expansion (expanding outcomes)
if (node->IsChanceNode()) {
// Chance nodes expand their outcome children
// This should have been set up when the chance node was created
if (node->outcomeProbabilities.empty()) {
throw MCTSInternalError(
"Chance node has no outcome probabilities - this indicates a bug");
}
const size_t outcomeIndex = node->nextUntriedActionIndex++;
if (outcomeIndex >= node->outcomeProbabilities.size()) {
throw MCTSInternalError(
"Chance node outcomeIndex >= outcomeProbabilities.size() - bug in expansion");
}
// The chance node's action should be the binary action
if (!node->action) {
throw MCTSInternalError("Chance node has no action - this indicates a bug");
}
// Apply the action with the representative roll for this outcome
// Outcome 0 = success, Outcome 1 = failure
// Use the representative roll for this specific outcome
const double representativeRoll = node->outcomeRolls[outcomeIndex];
auto newState = engine.applyAction(*node->gameState, *node->action, representativeRoll);
if (!newState) {
throw MCTSInternalError(
"MCTS expansion: engine.applyAction() returned nullptr for chance node "
"outcome - this indicates a game engine error");
}
// Determine if player changed
const MCTSPlayerId newPlayerId = newState->currentPlayerId();
const bool playerChanged = (newPlayerId != node->playerId);
// Calculate player flips and maximizing status
const int newPlayerFlips = node->playerFlips + (playerChanged ? 1 : 0);
const bool newIsMaximizing = (newPlayerId == playerId_);
// Create outcome child (decision node)
auto outcomeChild = std::make_unique<MCTSNode>(
node->action->clone(),
std::move(newState),
newPlayerId,
node->depth + 1,
outcomeIndex,
newPlayerFlips,
newIsMaximizing,
node->actionWeight); // Inherit action weight from chance node
// Set up outcome child's actions if not terminal
const bool shouldExpand =
!outcomeChild->isTerminal && node->playerFlips <= config_.maxPlayerFlips;
if (shouldExpand) {
const auto childActions = engine.getLegalActions(
*outcomeChild->gameState,
playerId_,
newPlayerFlips,
config_.maxPlayerFlips);
outcomeChild->totalActions = childActions.size();
}
// Calculate scores
outcomeChild->immediateScore = engine.evaluateState(*outcomeChild->gameState, playerId_);
outcomeChild->lookaheadScore = outcomeChild->immediateScore;
// Set parent and add to children
outcomeChild->parent = node;
node->children.push_back(std::move(outcomeChild));
// Update chance node's immediate score to expected value of expanded outcomes
// This corrects the initial value (which incorrectly used parent state) and ensures
// fair UCB comparison with non-chance actions like END_TURN
{
double expectedImmediate = 0.0;
double totalProbability = 0.0;
for (size_t i = 0; i < node->children.size(); i++) {
const double prob = node->outcomeProbabilities[i];
const double childImmediate = node->children[i]->immediateScore;
expectedImmediate += prob * childImmediate;
totalProbability += prob;
}
// Normalize by total probability of expanded outcomes
if (totalProbability > 0.0) {
node->immediateScore = expectedImmediate / totalProbability;
// 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;
}
}
return node->children.back().get();
}
// Handle decision node expansion (expanding actions)
// Get next action to expand (sequential order)
const size_t actionIndex = node->nextUntriedActionIndex++;
@@ -395,53 +246,9 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
const auto actionWeights = engine.getActionWeights(nodeActions, *node->gameState);
const auto& action = nodeActions[actionIndex];
const double actionWeight =
actionIndex < actionWeights.size() ? actionWeights[actionIndex] : 1.0;
// Check if this action requires a chance node
if (action->requiresChanceNode()) {
// Create intermediate chance node
auto chanceNode = std::make_unique<MCTSNode>(
action->clone(),
node->gameState->clone(), // Chance node has same state as parent
node->playerId,
node->depth + 1,
actionIndex,
node->playerFlips,
node->isMaximizingPlayer,
actionWeight);
chanceNode->nodeType = NodeType::CHANCE;
// Get outcome information from engine
const auto outcomeInfo = engine.getBinaryOutcomeInfo(*node->gameState, *action);
// Set up outcome metadata (2 outcomes for binary actions)
chanceNode->outcomeProbabilities = outcomeInfo.getProbabilities();
chanceNode->outcomeRolls = outcomeInfo.getRepresentativeRolls();
chanceNode->totalActions = 2; // Binary: success and failure
// Chance node immediate score will be computed as expected value during backpropagation
// For now, initialize to parent's score as a reasonable default
chanceNode->immediateScore = engine.evaluateState(*node->gameState, playerId_);
chanceNode->lookaheadScore = chanceNode->immediateScore;
// Set parent and add to children
chanceNode->parent = node;
node->children.push_back(std::move(chanceNode));
// 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
auto newState = engine.applyAction(*node->gameState, *action);
if (!newState) {
throw MCTSInternalError(
@@ -597,79 +404,28 @@ auto AbstractMCTSAI::MCTSBackpropagation(
node->totalReward += reward;
node->averageReward = node->totalReward / node->visitCount;
// Update lookahead score based on node type and strategy
if (node->IsChanceNode() && !node->children.empty()) {
// Chance nodes: compute expected value (weighted average of outcomes)
// lookaheadScore = sum(probability[i] * childValue[i])
double expectedValue = 0.0;
double totalProbability = 0.0;
int visitedChildCount = 0;
for (size_t i = 0; i < node->children.size(); i++) {
const auto& child = node->children[i];
if (child->visitCount == 0) continue; // Unvisited outcomes don't contribute
const double probability = node->outcomeProbabilities[i];
const double childValue = child->lookaheadScore;
expectedValue += probability * childValue;
totalProbability += probability;
visitedChildCount++;
}
// Use expected value if we have visited outcomes, else use average
if (visitedChildCount > 0) {
// CRITICAL: Normalize by total probability to get correct expected value
// when not all outcomes have been visited yet
if (totalProbability > 0.0 && totalProbability < 1.0) {
// Normalize to account for unvisited outcomes
// This gives the correct expected value among visited outcomes
expectedValue /= totalProbability;
}
node->lookaheadScore = expectedValue;
} else {
// No outcomes visited yet, fall back to average
if (node->visitCount == 1) {
node->lookaheadScore = reward;
} else {
const double alpha = 1.0 / node->visitCount;
node->lookaheadScore = (1.0 - alpha) * node->lookaheadScore + alpha * reward;
}
}
} else if (useMinimaxBackup && !node->children.empty()) {
// Update lookahead score based on strategy
if (useMinimaxBackup && !node->children.empty()) {
// Minimax backup: use best/worst child value for adversarial games
// The operation (MAX or MIN) depends on whose turn it is at THIS node
// - If this node is root player's turn: root chooses MAX (best for root)
// - If this node is opponent's turn: opponent chooses MIN (best for opponent = worst
// for root)
//
// Note: In setup phase, children can have different isMaximizingPlayer values:
// - PLACE_UNIT keeps same player's turn
// - END_PLAYER_SETUP flips to opponent's turn
// So we must use the PARENT node's isMaximizingPlayer, not the child's.
// This is correct when exploring opponent responses
double minmaxValue = node->isMaximizingPlayer ? -std::numeric_limits<double>::max()
: std::numeric_limits<double>::max();
const bool thisNodeIsRootPlayer = node->isMaximizingPlayer;
double minmaxValue = thisNodeIsRootPlayer ? -std::numeric_limits<double>::max()
: std::numeric_limits<double>::max();
int visitedChildCount = 0;
for (const auto& child : node->children) {
if (child->visitCount == 0) continue; // Unvisited children don't contribute
const double childValue = child->lookaheadScore;
visitedChildCount++;
if (thisNodeIsRootPlayer) {
// Root player chooses: take MAX (best for root)
if (node->isMaximizingPlayer) {
minmaxValue = std::max(minmaxValue, childValue);
} else {
// Opponent chooses: take MIN (best for opponent = worst for root)
minmaxValue = std::min(minmaxValue, childValue);
}
}
// Use minimax value if we found any visited children, else use average
if (visitedChildCount > 0) {
if (minmaxValue != (node->isMaximizingPlayer ? -std::numeric_limits<double>::max()
: std::numeric_limits<double>::max())) {
node->lookaheadScore = minmaxValue;
} else {
// No children visited yet, fall back to average
@@ -877,17 +633,12 @@ auto AbstractMCTSAI::LogSearchResults(
return a->visitCount > b->visitCount;
});
// Show all actions if there are <= 10, otherwise top 5
const size_t numToShow = sortedChildren.size() <= 10 ? sortedChildren.size() : 5;
printf("MCTS: Top %zu actions by visits (out of %zu total):\n",
numToShow,
sortedChildren.size());
for (size_t i = 0; i < numToShow; ++i) {
printf("MCTS: Top actions by visits:\n");
for (size_t i = 0; i < std::min(static_cast<size_t>(3), sortedChildren.size()); ++i) {
const auto* child = sortedChildren[i];
printf(" [%zu] visits:%d avgReward:%.2f immediate:%.2f lookahead:%.2f",
printf(" [%zu] visits:%d immediate:%.2f lookahead:%.2f",
i,
child->visitCount,
child->averageReward,
child->immediateScore,
child->lookaheadScore);
@@ -941,43 +692,18 @@ auto AbstractMCTSAI::LogSearchResults(
if (!bestSequence.empty()) {
printf("MCTS: Best sequence from chosen action (final: %.2f):\n", sequenceScore);
int displayedStep = 0;
for (size_t i = 0; i < bestSequence.size(); ++i) {
const auto* node = bestSequence[i];
// Skip outcome nodes (children of chance nodes) - they're displayed with their parent
if (i > 0 && node->parent && node->parent->IsChanceNode()) { continue; }
displayedStep++;
printf(" %d.", displayedStep);
printf(" %zu.", i + 1);
if (node->action) { printf(" %s", node->action->getDescription().c_str()); }
// If this is a chance node, display outcome probabilities and scores
if (node->IsChanceNode() && !node->outcomeProbabilities.empty()) {
printf(" [");
for (size_t j = 0; j < node->outcomeProbabilities.size(); ++j) {
if (j > 0) printf(", ");
const double prob = node->outcomeProbabilities[j] * 100;
// Show lookahead score for each outcome if child exists
if (j < node->children.size() && node->children[j]->visitCount > 0) {
printf("%.0f%%->%.1f", prob, node->children[j]->lookaheadScore);
} else {
printf("%.0f%%->?", prob);
}
}
printf("]");
}
printf(" (visits:%d, immediate:%.2f, lookahead:%.2f)\n",
node->visitCount,
node->immediateScore,
node->lookaheadScore);
// For non-root nodes in the sequence, show what the top alternatives were
// Skip showing alternatives for chance nodes (they have outcome children, not action
// alternatives)
if (i > 0 && node->parent && !node->parent->children.empty() &&
!node->parent->IsChanceNode()) {
// This helps diagnose if opponent moves are being properly explored
if (i > 0 && node->parent && !node->parent->children.empty()) {
// Collect all siblings (including this node) and sort by visit count
std::vector<const MCTSNode*> siblings;
siblings.reserve(node->parent->children.size());
@@ -1053,14 +779,17 @@ auto AbstractMCTSAI::DumpNodeRecursive(
if (!node) return;
// Create indent string
const std::string indent = ::mcts::util::BuildTreeIndent(indentLevel, isLastChild);
std::string indent;
for (int i = 0; i < indentLevel; ++i) {
if (i == indentLevel - 1) {
indent += isLastChild ? "└─ " : "├─ ";
} else {
indent += " ";
}
}
// Write node information
out << indent;
// Show node type for chance nodes
if (node->IsChanceNode()) { out << "[CHANCE] "; }
if (node->action) {
out << node->action->getDescription();
} else {
@@ -1079,21 +808,6 @@ auto AbstractMCTSAI::DumpNodeRecursive(
if (node->isTerminal) { out << ", TERMINAL"; }
out << ")\n";
// Show outcome probabilities and rolls for chance nodes
if (node->IsChanceNode() && !node->outcomeProbabilities.empty()) {
const std::string outcomeIndent = ::mcts::util::ConvertBranchToContinuation(indent);
out << outcomeIndent << " Outcomes: ";
for (size_t i = 0; i < node->outcomeProbabilities.size(); ++i) {
if (i > 0) out << ", ";
out << "[" << i << "] p=" << std::fixed << std::setprecision(3)
<< node->outcomeProbabilities[i];
if (i < node->outcomeRolls.size()) {
out << " roll=" << std::fixed << std::setprecision(1) << node->outcomeRolls[i];
}
}
out << "\n";
}
// Recursively dump children
if (!node->children.empty()) {
for (size_t i = 0; i < node->children.size(); ++i) {
@@ -86,7 +86,6 @@ cc_library(
":mcts_game_state",
":mcts_node",
":mcts_types",
"//src/main/cpp/net/eagle0/common/mcts/util:tree_indent_util",
],
)
@@ -27,11 +27,6 @@ public:
// Check if two actions are equivalent
[[nodiscard]] virtual bool equals(const MCTSAction& other) const = 0;
// Check if this action requires a chance node (binary success/failure outcome)
// Examples: START_FIRE, RAISE_DEAD, EXTINGUISH_FIRE
// If true, the game engine should provide outcome probabilities
[[nodiscard]] virtual bool requiresChanceNode() const = 0;
};
} // namespace mcts
@@ -16,50 +16,15 @@
namespace shardok {
namespace mcts {
// 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
// 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 {{successProbability, 1.0 - successProbability}, {-100.0, 150.0}};
}
// 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:
virtual ~MCTSGameEngine() = default;
// Apply an action to a state and return the resulting state
// If deterministicRoll is provided (0.0-100.0), use that for any random outcomes
[[nodiscard]] virtual std::unique_ptr<MCTSGameState> applyAction(
const MCTSGameState& state,
const MCTSAction& action,
double deterministicRoll = -1.0) const = 0;
const MCTSAction& action) const = 0;
// Apply an action to a mutable state in-place (for efficient simulation)
// Default: clone, apply, and move the result back
@@ -146,13 +111,6 @@ public:
(void)state; // Suppress unused parameter warning
return filteredIndex;
}
// Get binary outcome information for an action that requires a chance node
// Only called for actions where action.requiresChanceNode() returns true
// Returns success probability for binary success/failure actions
[[nodiscard]] virtual BinaryOutcomeInfo getBinaryOutcomeInfo(
const MCTSGameState& state,
const MCTSAction& action) const = 0;
};
} // namespace mcts
@@ -17,16 +17,8 @@
namespace shardok {
namespace mcts {
// Node type for MCTS tree
enum class NodeType {
DECISION, // Player chooses an action (standard MCTS node)
CHANCE // Nature determines outcome (for probabilistic actions)
};
// Abstract MCTS Node structure
struct MCTSNode {
// Node type
NodeType nodeType = NodeType::DECISION;
// Action information
std::unique_ptr<MCTSAction> action; // The action that led to this node (null for root)
size_t actionIndex = SIZE_MAX; // Index in the original actions array (SIZE_MAX for root)
@@ -51,10 +43,6 @@ struct MCTSNode {
size_t totalActions = 0; // Total number of available actions
MCTSNode* parent = nullptr;
// Chance node specific fields (only used when nodeType == CHANCE)
std::vector<double> outcomeProbabilities; // Probability of each outcome
std::vector<double> outcomeRolls; // Representative roll for each outcome
// Game context
MCTSPlayerId playerId;
int depth = 0;
@@ -148,40 +136,6 @@ struct MCTSNode {
// Check if this node can be expanded
[[nodiscard]] bool CanExpand() const { return nextUntriedActionIndex < totalActions; }
// Check if this is a chance node
[[nodiscard]] bool IsChanceNode() const { return nodeType == NodeType::CHANCE; }
// Check if this is a decision node
[[nodiscard]] bool IsDecisionNode() const { return nodeType == NodeType::DECISION; }
// Get best child from chance node (probability-weighted selection)
// For chance nodes, we want to explore outcomes proportionally to their probability
[[nodiscard]] MCTSNode* GetBestChanceChild() const {
if (children.empty() || !IsChanceNode()) return nullptr;
// Find the outcome that is most under-explored relative to its probability
// Expected visits for outcome i: total_visits * probability[i]
// Actual visits: child[i]->visitCount
// Deficit: expected - actual
size_t bestIndex = 0;
double bestDeficit = -std::numeric_limits<double>::max();
for (size_t i = 0; i < children.size(); i++) {
if (!children[i] || children[i]->isRedundant) continue;
const double expectedVisits = visitCount * outcomeProbabilities[i];
const double actualVisits = static_cast<double>(children[i]->visitCount);
const double deficit = expectedVisits - actualVisits;
if (deficit > bestDeficit) {
bestDeficit = deficit;
bestIndex = i;
}
}
return children[bestIndex].get();
}
// Get best child based on UCB1
[[nodiscard]] MCTSNode* GetBestChild(const double explorationConstant) const {
if (children.empty()) return nullptr;
@@ -1,8 +0,0 @@
load("@rules_cc//cc:defs.bzl", "cc_library")
cc_library(
name = "tree_indent_util",
srcs = ["TreeIndentUtil.cpp"],
hdrs = ["TreeIndentUtil.hpp"],
visibility = ["//visibility:public"],
)
@@ -1,53 +0,0 @@
//
// Utility functions for processing tree indentation with UTF-8 box drawing characters
//
#include "TreeIndentUtil.hpp"
namespace mcts::util {
namespace {
// Box drawing characters for tree visualization
constexpr const char* kBranch = "\xE2\x94\x9C"; // ├
constexpr const char* kCorner = "\xE2\x94\x94"; // └
constexpr const char* kVertical = "\xE2\x94\x82"; // │
constexpr const char* kHorizontal = "\xE2\x94\x80"; // ─
} // namespace
std::string BuildTreeIndent(int indentLevel, bool isLastChild) {
std::string indent;
for (int i = 0; i < indentLevel; ++i) {
if (i == indentLevel - 1) {
indent += isLastChild ? kCorner : kBranch;
indent += kHorizontal;
indent += " ";
} else {
indent += " ";
}
}
return indent;
}
std::string ConvertBranchToContinuation(const std::string& indent) {
std::string result = indent;
const std::string replacement = std::string(kVertical) + " ";
// Replace ├ and └ with │
size_t pos = 0;
while ((pos = result.find(kBranch, pos)) != std::string::npos) {
result.replace(pos, 3, replacement); // UTF-8 chars are 3 bytes
pos += replacement.size();
}
pos = 0;
while ((pos = result.find(kCorner, pos)) != std::string::npos) {
result.replace(pos, 3, replacement);
pos += replacement.size();
}
return result;
}
} // namespace mcts::util
@@ -1,22 +0,0 @@
//
// Utility functions for processing tree indentation with UTF-8 box drawing characters
//
#ifndef EAGLE0_TREE_INDENT_UTIL_HPP
#define EAGLE0_TREE_INDENT_UTIL_HPP
#include <string>
namespace mcts::util {
// Builds tree indentation string for a node at a given depth
// Returns string like " ├─ " or " └─ " with proper spacing
std::string BuildTreeIndent(int indentLevel, bool isLastChild);
// Converts tree branch characters (├ and └) to continuation lines (│) for sub-content
// This preserves the tree structure when displaying additional info below a node
std::string ConvertBranchToContinuation(const std::string& indent);
} // namespace mcts::util
#endif // EAGLE0_TREE_INDENT_UTIL_HPP
@@ -4,7 +4,6 @@
#include "AIHeuristicWeighting.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokException.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistances.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
@@ -41,27 +40,21 @@ double AIHeuristicWeighting::GetCommandWeight(
// Area/tactical spells - high impact
case CommandType::METEOR_START_COMMAND: {
// METEOR_START doesn't have a target - it's based on actor location
if (hasTarget) {
throw ShardokInternalErrorException(
"METEOR_START_COMMAND should not have target coordinates");
}
// High weight per enemy unit at or adjacent to target
// FIXME: MeteorStart doesn't have a target yet; this should be based on the actor
// location
if (!hasTarget) return 0.0; // Default if no target info
// Get actor's location
const auto* actorUnit = units->Get(actorUnitId);
if (!actorUnit) {
throw ShardokInternalErrorException(
"METEOR_START_COMMAND actor unit not found in game state");
}
const Coords& actorLocation = actorUnit->location();
int enemyCount = 0;
// Count enemies within meteor range (3 hexes) of actor location
constexpr int METEOR_RANGE = 3;
const auto tilesInRange = TilesWithinDistance(hexMap, actorLocation, METEOR_RANGE);
for (const auto& tileCoords : tilesInRange) {
if (const auto* unit = Occupant(units, tileCoords)) {
// Count enemies at target
if (const auto* targetUnit = Occupant(units, targetCoords)) {
if (targetUnit->player_id() != actorPlayerId) { enemyCount++; }
}
// Count enemies adjacent to target
for (const auto& neighbor : HexMapUtils::GetAdjacentTiles(hexMap, targetCoords)) {
if (const auto* unit = Occupant(units, neighbor.coords)) {
if (unit->player_id() != actorPlayerId) { enemyCount++; }
}
}
@@ -71,11 +64,8 @@ double AIHeuristicWeighting::GetCommandWeight(
case CommandType::METEOR_TARGET_COMMAND: {
// High weight per enemy unit at or adjacent to target
if (!hasTarget) {
throw ShardokInternalErrorException(
"METEOR_TARGET_COMMAND requires target coordinates for heuristic "
"weighting");
}
// FIXME: this should throw if !hasTarget
if (!hasTarget) return 6.0; // Default if no target info
int enemyCount = 0;
@@ -100,17 +90,15 @@ double AIHeuristicWeighting::GetCommandWeight(
// Fire on enemy (context-dependent)
case CommandType::START_FIRE_COMMAND: {
// High if enemy at target, low otherwise
if (!hasTarget) {
throw ShardokInternalErrorException(
"START_FIRE_COMMAND requires target coordinates for heuristic weighting");
}
// FIXME: throw if no target
if (!hasTarget) return 3.0; // Default
if (const auto* targetUnit = Occupant(units, targetCoords)) {
if (targetUnit->player_id() != actorPlayerId) {
return 10.0; // Enemy at target - high value
}
}
return 1.0; // No enemy - low value but still valid
return 1.0; // No enemy - low value
}
// === MEDIUM-HIGH OFFENSIVE (5.0-7.0) ===
@@ -142,10 +130,7 @@ double AIHeuristicWeighting::GetCommandWeight(
}
// Attackers: weight based on distance improvement towards castle
if (!hasTarget) {
throw ShardokInternalErrorException(
"MOVE_COMMAND requires target coordinates for heuristic weighting");
}
if (!hasTarget) return 4.0; // Default if no target
// Get actor unit to determine battalion type and start position
const auto* actorUnit = units->Get(actorUnitId);
@@ -199,18 +184,14 @@ double AIHeuristicWeighting::GetCommandWeight(
// === LOW VALUE DEFENSIVE/UTILITY (1.0-2.0) ===
case CommandType::EXTINGUISH_FIRE_COMMAND: {
// High if friendly at target, low otherwise
if (!hasTarget) {
throw ShardokInternalErrorException(
"EXTINGUISH_FIRE_COMMAND requires target coordinates for heuristic "
"weighting");
}
if (!hasTarget) return 2.0; // Default
if (const auto* targetUnit = Occupant(units, targetCoords)) {
if (targetUnit->player_id() == actorPlayerId) {
return 8.0; // Friendly at target - high value
}
}
return 1.0; // No friendly - low value but still valid
return 1.0; // No friendly - low value
}
case CommandType::UNIT_REST_COMMAND: return 1.5;
@@ -113,15 +113,6 @@ auto CalculateTimeBudget(
const auto clampedBudgetMs = std::clamp(budgetMs, 200.0, maxBudgetMs);
const auto remainingBudget = std::chrono::milliseconds(static_cast<int64_t>(clampedBudgetMs));
// TEMPORARY DEBUG OUTPUT
printf("[DEBUG CalculateTimeBudget] numCommands=%zu, msPerCommand=%.2f, budgetMs=%.2f, "
"clampedBudgetMs=%.2f, isClose=%d\n",
numCommands,
msPerCommand,
budgetMs,
clampedBudgetMs,
isClose);
// Get minimum depth requirement
const size_t minDepth = settingsGetter.Backing().min_lookahead_turns();
@@ -17,10 +17,9 @@ using std::end;
using std::shared_ptr;
constexpr double kProfessionValue = 200;
constexpr double kVigorScoreMultiplier = 5.0;
constexpr double kCastleMultiplierBonus = 1.0;
constexpr double kOnFireMultiplier = 0.25;
constexpr double kAdjacentFireMultiplier = 0.80;
constexpr double kAdjacentFireMultiplier = 0.99;
constexpr double kOnIceMultiplier = 0.25;
constexpr double kMeteorStartInRangeValue = 50;
constexpr double kMeteorDirectTargetingEnemy = 2;
@@ -64,8 +63,7 @@ auto ContextFreeUnitValue(const Unit *unit) -> ScoreValue {
4.0;
}
const double vigorValue =
unit->has_attached_hero() ? unit->attached_hero().vigor() * kVigorScoreMultiplier : 0.0;
const double vigorValue = unit->has_attached_hero() ? unit->attached_hero().vigor() : 0.0;
double battalionTypeMultiplier = 1.0;
switch (unit->battalion().type()) {
@@ -357,7 +355,9 @@ auto UnitValue(
kCastleMultiplierBonus * (terrain->modifier().castle().integrity() + 25) / 100.0;
}
double onFireMultiplier = 1.0;
if (terrain->modifier().fire().present()) { onFireMultiplier *= kOnFireMultiplier; }
if (terrain->modifier().fire().present() && (isAttacker || attackerWantsCastles)) {
onFireMultiplier *= kOnFireMultiplier;
}
{
for (const auto adjacentCoords = HexMapUtils::GetAdjacentCoords(map, location);
const auto &c : adjacentCoords) {
@@ -10,16 +10,6 @@
#define DEBUG_FLEE_DECISIONS
// Enable to dump game state and debug tree to /tmp for debugging
// #define ENABLE_MCTS_DEBUG_DUMP
#ifdef ENABLE_MCTS_DEBUG_DUMP
#include <chrono>
#include <fstream>
#include <iomanip>
#include <sstream>
#endif
#include "AIAttackerStrategySelector.hpp"
#include "AIConfig.hpp"
#include "AIDefenderStrategySelector.hpp"
@@ -151,7 +141,7 @@ auto ShardokAIClient::StandardChooseCommandIndex(
// - Leaves at playerFlips=0 (still my turn): simulate through END_TURN to playerFlips=1
// - Leaves at playerFlips=1 (opponent's turn): evaluate immediately
// Result: consistent comparison of "what happens after I end my turn"
// adjustedMCTSConfig.maxSimulatfixionFlips = 1;
adjustedMCTSConfig.maxSimulationFlips = 1;
// adjustedMCTSConfig.maxPlayerFlips = 0;
// if (timeBudget.isCloseToEnemy) {
@@ -218,37 +208,6 @@ auto ShardokAIClient::StandardChooseCommandIndex(
IterativeDeepeningAI::SearchResult search_result;
if (aiAlgorithmType == AIAlgorithmType::MCTS) {
#ifdef ENABLE_MCTS_DEBUG_DUMP
// Set unique debug dump path for each action using timestamp
const auto now = std::chrono::system_clock::now();
const auto nowTime = std::chrono::system_clock::to_time_t(now);
const auto nowMs =
std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()) %
1000;
std::ostringstream pathStream;
pathStream << "/tmp/shardok_debug_"
<< std::put_time(std::localtime(&nowTime), "%Y%m%d_%H%M%S") << "_"
<< std::setfill('0') << std::setw(3) << nowMs.count() << "_p"
<< static_cast<int>(playerId) << ".txt";
adjustedMCTSConfig.debugDumpPath = pathStream.str();
// Also dump the game state to a file for reproduction
std::ostringstream statePathStream;
statePathStream << "/tmp/shardok_state_"
<< std::put_time(std::localtime(&nowTime), "%Y%m%d_%H%M%S") << "_"
<< std::setfill('0') << std::setw(3) << nowMs.count() << "_p"
<< static_cast<int>(playerId) << ".bin";
const std::string statePath = statePathStream.str();
// Write the flatbuffer game state to file using SaveTo method
if (guessedState.SaveTo(statePath)) {
printf("Game state dumped to: %s\n", statePath.c_str());
} else {
printf("Failed to dump game state to: %s\n", statePath.c_str());
}
#endif // ENABLE_MCTS_DEBUG_DUMP
// Using Monte Carlo Tree Search AI (with abstraction layer)
ShardokMCTSAI ai(
playerId,
@@ -1,813 +0,0 @@
# Chance Nodes in MCTS for Shardok
## Problem Statement
### Current Behavior
The current MCTS implementation uses a fixed roll (50th percentile) for all probabilistic outcomes during simulation. This creates several issues:
1. **Binary success actions overvalued**: A START_FIRE command with 51% success is treated as always succeeding, making it appear better than it actually is.
2. **Discontinuity at 50%**: Actions with 49% vs 51% success have dramatically different evaluations, when they should be similar.
3. **Variable-outcome actions simplified**: Melee/archery attacks with damage ranges are evaluated at a single point rather than their full distribution.
### Example Issue
```
START_FIRE with 51% success:
- Current MCTS: Assumes always succeeds (roll = 50)
- Reality: Succeeds 51% of time, fails 49% of time
- Result: AI overvalues this action
```
### How Iterative Deepening Solves This
The iterative deepening AI (see `AICommandEvaluator.cpp:352-393`) handles randomness correctly:
```cpp
// For actions with odds (binary success/fail):
// 1. Evaluate success outcome with representative roll
auto [successScore, successLookahead] = EvaluateWithRandomness(
...,
std::make_shared<SequenceRandomGenerator>(std::vector{1.0 - successChance / 2.0})
);
// 2. Evaluate failure outcome with representative roll
auto [failureScore, failureLookahead] = EvaluateWithRandomness(
...,
std::make_shared<SequenceRandomGenerator>(std::vector{(1.0 - successChance) / 2.0})
);
// 3. Compute weighted average (expected value)
immediateScore = std::lerp(failureScore, successScore, successChance);
lookaheadScore = std::lerp(failureLookahead.get(), successLookahead.get(), successChance);
```
This is essentially an implicit form of chance nodes - evaluating both outcomes and weighting by probability.
## Chance Nodes Concept
### Classic MCTS with Chance Nodes
In games with randomness (e.g., backgammon), MCTS uses two types of nodes:
1. **Decision Nodes**: Player chooses an action
- Selection uses UCB formula (exploration/exploitation tradeoff)
- One child per legal action
2. **Chance Nodes**: Nature determines outcome
- Selection uses expectation (weighted by probability)
- One child per possible outcome
```
Decision Node (Player to move)
├─ Action A
│ └─ Chance Node
│ ├─ Outcome 1 (prob 0.3) → Game State
│ ├─ Outcome 2 (prob 0.5) → Game State
│ └─ Outcome 3 (prob 0.2) → Game State
└─ Action B
└─ Deterministic → Game State
```
### Example: START_FIRE in Shardok
**Current approach:**
```
State S
└─ START_FIRE (roll=50)
└─ State S' (fire always starts)
```
**With chance nodes:**
```
State S
└─ START_FIRE action
└─ Chance Node
├─ Success (51%) → State S_success (fire started)
└─ Failure (49%) → State S_failure (no fire, vigor spent)
```
### Value Propagation
**Decision nodes:** Maximize/minimize over children (depending on player)
**Chance nodes:** Expected value over children (weighted by probability)
```cpp
// Decision node value (max for current player)
value = max(child.value for child in children)
// Chance node value (expectation)
value = sum(prob[i] * child[i].value for i in outcomes)
```
## Implementation Approaches
### Option 1: Explicit Chance Nodes (Full Implementation)
Modify the MCTS tree structure to explicitly represent chance nodes.
**Pros:**
- Theoretically sound
- Handles arbitrary outcome distributions
- Clear separation of decision vs chance
**Cons:**
- Significant code changes
- Larger tree (more memory)
- More complex tree traversal
**Tree Structure:**
```cpp
enum class NodeType { DECISION, CHANCE };
struct MCTSNode {
NodeType type;
// For decision nodes
MCTSPlayerId player;
std::vector<std::unique_ptr<MCTSAction>> actions;
std::vector<std::unique_ptr<MCTSNode>> children; // One per action
// For chance nodes
std::vector<double> probabilities; // One per outcome
std::vector<std::unique_ptr<MCTSNode>> outcomes; // One per outcome
double visits;
double totalReward;
};
```
**Selection Phase:**
```cpp
MCTSNode* select(MCTSNode* node) {
while (!node->isLeaf()) {
if (node->type == DECISION) {
// Use UCB to select action
node = selectChildUCB(node);
} else { // CHANCE node
// Use probability-weighted selection
node = selectOutcomeByProbability(node);
}
}
return node;
}
```
**Backpropagation:**
```cpp
void backpropagate(MCTSNode* node, double reward) {
while (node != nullptr) {
node->visits++;
if (node->type == DECISION) {
node->totalReward += reward; // Sum for averaging
} else { // CHANCE node
node->totalReward += reward; // Still sum, but averaged differently
}
node = node->parent;
}
}
```
### Option 2: Implicit Chance Nodes (Hybrid Approach)
Keep the current tree structure but sample outcomes during expansion/simulation.
**Pros:**
- Smaller code changes
- More memory efficient
- Easier to implement incrementally
**Cons:**
- Less theoretically pure
- May need more visits to converge
- Sampling introduces variance
**Approach:**
```cpp
// During expansion
std::unique_ptr<MCTSGameState> expand(
const MCTSGameState& state,
const MCTSAction& action
) {
if (action.isDeterministic()) {
return applyActionDeterministic(state, action);
} else {
// Sample an outcome based on probabilities
auto outcome = sampleOutcome(action);
return applyActionWithOutcome(state, action, outcome);
}
}
```
**For binary actions (e.g., START_FIRE):**
```cpp
// Expand creates one of two children based on sampling
if (random() < successProbability) {
return applySuccess(state, action);
} else {
return applyFailure(state, action);
}
// Over many visits, visit ratio will approach probability ratio
// E.g., 51% success action will have ~51% success children, 49% failure children
```
### Option 3: Determinized Sampling (Simplest)
Pre-sample all random outcomes at the start of each simulation rollout.
**Pros:**
- Minimal code changes
- Easy to understand
- Works with existing tree structure
**Cons:**
- May converge slowly
- Doesn't explicitly represent probability
- Can waste simulations on unlikely outcomes
**Approach:**
```cpp
// At start of each simulation
std::vector<double> rollSequence = generateRollSequence(maxDepth);
// Use sequence during simulation
auto state = rootState;
for (int depth = 0; depth < maxDepth; depth++) {
auto action = selectAction(state);
state = applyAction(state, action, rollSequence[depth]);
}
```
## Recommended Approach: Progressive Enhancement
Implement in phases to manage complexity:
### Phase 1: Binary Chance Nodes (Explicit)
Start with actions that have clear success/failure outcomes (e.g., START_FIRE, EXTINGUISH_FIRE, RAISE_DEAD):
1. Identify binary actions (commands with `HasOdds()`)
2. Add chance node support for these actions only
3. Modify tree expansion to create chance nodes
4. Update selection/backpropagation for chance nodes
**Implementation:**
```cpp
// In ShardokGameEngine::getLegalActions()
// Mark which actions require chance nodes
struct ActionMetadata {
std::unique_ptr<MCTSAction> action;
bool requiresChanceNode;
double successProbability; // If requiresChanceNode = true
};
```
```cpp
// In tree expansion
if (action.requiresChanceNode) {
// Create chance node with two children
auto chanceNode = std::make_unique<MCTSNode>(CHANCE);
chanceNode->probabilities = {successProb, 1.0 - successProb};
// Expand both outcomes
chanceNode->outcomes.push_back(applySuccess(state, action));
chanceNode->outcomes.push_back(applyFailure(state, action));
return chanceNode;
} else {
// Normal deterministic expansion
return applyAction(state, action);
}
```
### Phase 2: Multi-Outcome Actions
Extend to actions with multiple outcomes (e.g., melee damage ranges):
1. Discretize continuous distributions into buckets
2. For melee/archery, use 3-5 representative damage values (min, low, avg, high, max)
3. Compute probabilities for each bucket
4. Create chance nodes with multiple children
**Example: Melee Attack**
```cpp
// Instead of sampling full damage distribution,
// use representative values
struct DamageBucket {
int damageValue; // Representative damage
double probability; // Probability of this range
};
// For a melee attack that can deal 10-20 damage
std::vector<DamageBucket> buckets = {
{10, 0.1}, // Min damage (unlucky)
{13, 0.2}, // Low damage
{15, 0.4}, // Average damage
{17, 0.2}, // High damage
{20, 0.1} // Max damage (lucky)
};
```
### Phase 3: Optimization
Once chance nodes work correctly:
1. Add transposition table support for chance nodes
2. Optimize memory layout
3. Consider progressive widening (start with 2 outcomes, expand to more if visited often)
4. Profile and tune
## Design Decisions
### How to Represent Outcomes?
**Option A: Explicit state copies**
```cpp
struct ChanceNode {
std::vector<std::unique_ptr<MCTSGameState>> outcomeStates;
std::vector<double> probabilities;
};
```
**Option B: Lazy evaluation**
```cpp
struct ChanceNode {
MCTSGameState baseState;
MCTSAction action;
std::vector<int> outcomeRolls; // Roll values for each outcome
std::vector<double> probabilities;
// Compute state on-demand
MCTSGameState getOutcome(size_t index) {
return applyActionWithRoll(baseState, action, outcomeRolls[index]);
}
};
```
**Recommendation:** Option B - lazy evaluation. Only materialize states when visited.
### How Many Outcomes per Action?
**Binary actions (START_FIRE, etc.):**
- Exactly 2 outcomes (success/fail)
- Use exact probabilities from `GetOddsPercentile()`
**Damage actions (MELEE, ARCHERY):**
- Start with 3 outcomes (low/med/high)
- Can expand to 5 if needed for accuracy
- Use representative rolls: 10th, 50th, 90th percentile
**Complex actions (METEOR):**
- Consider 2-3 outcomes initially
- Can model as "hits N enemies" for N in {0, 1, 2, 3+}
### How to Handle Transposition Table?
**Challenge:** Same state can be reached via different chance outcomes
**Solution:**
- Hash based on game state only (not the path taken)
- When looking up, return cached evaluation if state matches
- This is already how transposition tables work!
```cpp
// Current approach works fine:
auto hash = computeHash(gameState); // Doesn't include how we got here
if (auto cached = transpositionTable.lookup(hash)) {
return cached->value;
}
```
### Selection at Chance Nodes
**During tree traversal:**
```cpp
size_t selectOutcome(const ChanceNode& node) {
// Option 1: Sample by probability (introduces variance)
double r = random();
double cumulative = 0.0;
for (size_t i = 0; i < node.probabilities.size(); i++) {
cumulative += node.probabilities[i];
if (r < cumulative) return i;
}
// Option 2: Round-robin weighted by visit count vs probability
// (Explore under-visited outcomes more)
size_t leastVisited = findMostUnderExploredOutcome(node);
return leastVisited;
}
```
**Recommendation:** Use Option 2 to ensure all outcomes get explored proportionally.
## Integration Points
### Modified Functions
1. **`ShardokGameEngine::getLegalActions()`**
- Add metadata about which actions need chance nodes
- Return action + probability information
2. **`ShardokGameEngine::applyAction()`**
- For binary actions, return both possible outcomes
- Or: take an explicit outcome index parameter
3. **`AbstractMCTSAI::selection()`**
- Handle chance nodes differently from decision nodes
- Use probability-weighted selection instead of UCB
4. **`AbstractMCTSAI::expand()`**
- Create chance node children for probabilistic actions
- May create multiple child nodes per action
5. **`AbstractMCTSAI::backpropagate()`**
- Update all nodes in path (both decision and chance)
- Value calculation already handles this correctly (just averages)
### New Functions Needed
```cpp
// In ShardokGameEngine
struct ChanceOutcome {
int roll; // The dice roll that produces this outcome
double probability; // Probability of this outcome
};
std::vector<ChanceOutcome> getChanceOutcomes(const MCTSAction& action) const;
```
```cpp
// In MCTSNode
bool isChanceNode() const;
const std::vector<double>& getOutcomeProbabilities() const;
```
## Testing Strategy
### Unit Tests
1. **Binary action correctness**
```cpp
TEST(ChanceNodes, BinaryActionExpectedValue) {
// START_FIRE with 60% success
// Run MCTS with chance nodes
// Verify: visits to success ~= 60%, visits to failure ~= 40%
// Verify: expected value matches manual calculation
}
```
2. **Comparison with iterative deepening**
```cpp
TEST(ChanceNodes, MatchesIterativeDeepening) {
// Same position, both AIs
// Should choose same action
// Scores should be similar (within variance)
}
```
3. **Transposition table with chance**
```cpp
TEST(ChanceNodes, TranspositionConsistency) {
// Two paths to same state via different chance outcomes
// Should reuse cached evaluation
}
```
### Integration Tests
1. Compare MCTS with/without chance nodes on test positions
2. Verify that chance nodes reduce overvaluation of marginal actions
3. Performance test: measure slowdown (expect 1.5-2x for binary actions)
### Real-World Validation
Run the problematic START_FIRE scenario:
- With current MCTS: Should overvalue START_FIRE
- With chance nodes: Should correctly weight success/failure
- Expected: END_TURN should get significantly more visits
## Performance Considerations
### Memory Overhead
**Per chance node:**
- Probability vector: `N * sizeof(double)` (N = number of outcomes)
- Outcome children: `N * sizeof(unique_ptr)`
- For binary: ~32 bytes per chance node
**Estimate:**
- Current tree: ~100K nodes per search
- With chance nodes: ~150K nodes (50% actions are probabilistic)
- Extra memory: ~50K * 32 bytes = ~1.6 MB
- **Acceptable overhead**
### Computational Overhead
**Per simulation:**
- Current: 1 path through tree
- With chance nodes: Still 1 path, but more nodes
- Overhead: ~20-30% (more node visits)
**Mitigation:**
- Transposition table helps (same states via different paths)
- Progressive widening (start with 2 outcomes, expand if visited often)
- Lazy state evaluation (don't materialize until needed)
### Convergence Speed
Chance nodes may require more visits to converge because:
- More children per action (branching factor increases)
- Outcomes need proportional exploration
**Mitigation:**
- Use visit count thresholds before expanding chance nodes
- Consider progressive widening (UCT-ProgressiveWidening)
## Migration Path
### Step 1: Infrastructure (1-2 days)
- Add `NodeType` enum and metadata to MCTSNode
- Implement chance node creation (without using them yet)
- Add unit tests for chance node structure
### Step 2: Binary Actions (2-3 days)
- Identify all binary success/fail actions
- Modify expansion to create chance nodes for these
- Update selection/backpropagation
- Test on START_FIRE scenario
### Step 3: Integration Testing (1 day)
- Run full MCTS tests with chance nodes enabled
- Compare with iterative deepening on test positions
- Validate that it fixes the START_FIRE overvaluation
### Step 4: Multi-Outcome Actions (2-3 days)
- Implement damage bucketing for MELEE/ARCHERY
- Create chance nodes with 3-5 outcomes
- Test on combat scenarios
### Step 5: Optimization (1-2 days)
- Profile performance
- Add progressive widening if needed
- Tune outcome granularity
### Step 6: Documentation & Cleanup (1 day)
- Document the new approach
- Clean up code
- Add comprehensive tests
## Alternative: Simpler Hybrid Approach
If full chance nodes are too complex, consider a hybrid:
1. **Keep current tree structure** (no explicit chance nodes)
2. **During expansion:** Sample outcome and create one child
3. **Over many simulations:** Statistics converge to correct probabilities
4. **Add outcome tracking:** Store "which outcome" in edge/node metadata
**Example:**
```cpp
// Expansion samples an outcome
auto expand(state, action) {
if (action.hasBinaryOutcome()) {
// Sample once
bool success = (random() < successProb);
// Store which outcome this edge represents
edge.metadata.outcome = success ? OUTCOME_SUCCESS : OUTCOME_FAILURE;
return applyWithOutcome(state, action, success);
}
}
// Selection prioritizes under-explored outcomes
auto selectChild(node) {
// Find action where outcome distribution is unbalanced
// E.g., 60% success action should have ~60% success children
// If we have 80% success children, prefer exploring failure
}
```
This is simpler but less theoretically sound. It's a reasonable starting point if full chance nodes prove too complex.
## Comparison: Chance Nodes vs Open-Loop MCTS
### What is Open-Loop MCTS?
**Open-loop MCTS** (also called "determinization MCTS" or "information set MCTS") is an alternative approach to handling randomness:
1. At the **start of each simulation**, sample all random outcomes needed for that simulation
2. Play out the entire simulation using those fixed random values
3. Different simulations use different random seeds
4. The tree structure doesn't explicitly model randomness - it's all in the rollouts
**Example implementation:**
```cpp
// At start of simulation
std::vector<double> rollSequence = sampleRolls(maxDepth); // Pre-sample all rolls
// During simulation
MCTSNode* node = root;
for (int depth = 0; depth < maxDepth; depth++) {
Action action = selectAction(node);
node = applyAction(node, action, rollSequence[depth]); // Use pre-sampled roll
}
```
### Open-Loop MCTS for Shardok
**How it would work:**
```cpp
// Each simulation samples a "possible world"
void simulate(MCTSNode* root) {
// Sample random rolls for this simulation
auto rolls = generateRollSequence(); // e.g., {0.45, 0.78, 0.23, ...}
// Play out simulation using these fixed rolls
auto state = root->state;
for (int depth = 0; depth < maxDepth; depth++) {
auto action = selectAction(state);
state = applyAction(state, action, rolls[depth]);
}
double reward = evaluate(state);
backpropagate(root, reward);
}
```
**Would this fix the START_FIRE issue?**
**Yes** - partially. Different simulations would see different outcomes:
- Some simulations: START_FIRE succeeds (roll < 0.51)
- Some simulations: START_FIRE fails (roll >= 0.51)
- Over many simulations, the action's value would approach the expected value
**However**, it's less efficient than chance nodes because:
- Needs MORE simulations to converge
- Wastes effort exploring unlikely scenarios equally with likely ones
- Doesn't explicitly guide exploration based on probability
### Detailed Comparison
| Aspect | Chance Nodes (Closed-Loop) | Open-Loop MCTS | Current (Fixed Roll) |
|--------|---------------------------|----------------|----------------------|
| **Randomness Handling** | Explicit in tree structure | Implicit in simulation sampling | Fixed roll=50 |
| **Convergence Speed** | Fast - probabilities guide search | Slower - needs more samples | N/A (wrong answer) |
| **Memory Usage** | Higher (more nodes) | Lower (no extra nodes) | Lowest |
| **Implementation Complexity** | High (tree structure changes) | Medium (sampling layer) | Low (current) |
| **Theoretical Soundness** | Highest (models true game tree) | Medium (approximation via sampling) | Low (assumes fixed outcome) |
| **START_FIRE Fix** | ✅ Yes, accurately | ✅ Yes, eventually | ❌ No |
| **Efficiency** | Most efficient per simulation | Less efficient (wasted samples) | Efficient but wrong |
| **Handles Hidden Information** | Poor | Excellent | N/A |
### When to Prefer Each Approach
**Prefer Chance Nodes when:**
- Randomness outcomes are discrete and enumerable (e.g., binary success/fail)
- Probabilities are known precisely
- You want fastest convergence to correct answer
- Game tree is the primary concern (no hidden information)
- **This is Shardok's situation** ✅
**Prefer Open-Loop when:**
- Randomness is continuous and high-dimensional
- Hidden information or imperfect information is present
- Simplicity is paramount
- You can afford many simulations
- Used in games like poker, bridge, Skat
### Why Chance Nodes are Better for Shardok
1. **Discrete outcomes**: Most Shardok randomness is binary (success/fail) or small discrete sets (damage ranges)
- START_FIRE: 2 outcomes (success/fail)
- MELEE: Can bucket into 3-5 damage ranges
- Not continuous - perfect fit for chance nodes
2. **Known probabilities**: We have exact probabilities from `GetOddsPercentile()`
- Chance nodes can use exact probabilities
- Open-loop just samples blindly
3. **No hidden information**: Shardok is perfect information (all units visible to AI)
- Chance nodes' main weakness doesn't apply
- Open-loop's main strength doesn't help
4. **Convergence matters**: Limited simulation budget
- Need to converge quickly
- Chance nodes achieve this better
5. **Existing infrastructure**: We already have deterministic state transitions
- Adding chance nodes builds on what we have
- Open-loop would need different rollout structure
### Performance Analysis
**Chance Nodes:**
```
Time per simulation: 1.3x current
Simulations needed: 10,000 to converge
Total time: 13,000x units
Memory: 1.5x current (extra chance nodes)
```
**Open-Loop:**
```
Time per simulation: 1.0x current (same as now)
Simulations needed: 30,000 to converge (more variance)
Total time: 30,000x units
Memory: 1.0x current (no extra nodes)
```
**Result:** Chance nodes are **2.3x faster overall** despite being slower per simulation, because they converge with fewer simulations.
### Hybrid Approach: Best of Both Worlds?
Could we combine them?
**Idea:** Use chance nodes for high-probability branches, open-loop for rare events
```cpp
if (probability > 0.1 && outcomeCount <= 5) {
// Use explicit chance node
createChanceNode(outcomes, probabilities);
} else {
// Use open-loop sampling
sampleOutcome();
}
```
**Verdict:** Probably not worth the complexity. Shardok's randomness is simple enough that chance nodes handle everything well.
### Recommendation for Shardok
**Use Chance Nodes**, specifically:
1. **Phase 1:** Binary actions (START_FIRE, RAISE_DEAD, etc.)
- 2 outcomes, exact probabilities
- Biggest bang for buck
2. **Phase 2:** Damage ranges (MELEE, ARCHERY)
- 3-5 buckets
- Still manageable
3. **If needed:** Could fall back to open-loop for complex actions
- E.g., METEOR with many possible outcomes
- But likely unnecessary
### Why Not Open-Loop?
While open-loop would eventually fix the START_FIRE issue, it has significant downsides for Shardok:
1. **Slower convergence**: Needs 2-3x more simulations
2. **Doesn't leverage known probabilities**: We have exact odds, why ignore them?
3. **Less interpretable**: Harder to debug why AI chose an action
4. **Doesn't align with iterative deepening**: We want MCTS to match the proven algorithm
The only advantage of open-loop (simplicity) is outweighed by chance nodes' efficiency and correctness.
### Could We Use Current Approach + Better Sampling?
**Idea:** Keep fixed rolls but use different rolls per simulation?
```cpp
// Instead of always roll=50
double roll = random(); // Different each simulation
```
**Problem:** This is essentially open-loop without the tree!
- Even slower to converge
- Tree doesn't learn the outcome probabilities
- Worst of both worlds
**Verdict:** No, this doesn't help. If we're going to sample, do it properly (open-loop). Otherwise, use chance nodes.
### Final Verdict
**For Shardok, chance nodes are clearly superior:**
- ✅ Faster convergence (2-3x vs open-loop)
- ✅ Leverages exact probabilities
- ✅ Perfect fit for discrete outcomes
- ✅ Aligns with iterative deepening approach
- ✅ Better debuggability and interpretability
- ❌ More complex implementation (but manageable)
Open-loop would be a fallback if chance nodes prove too difficult, but given the benefits and the bounded complexity (only binary and small discrete outcomes), chance nodes are the right choice.
## Conclusion
Implementing chance nodes will fix the overvaluation of marginal probabilistic actions like START_FIRE with 51% success. The recommended approach is:
1. Start with **explicit chance nodes for binary actions**
2. Use **lazy state evaluation** to minimize memory
3. **Progressive enhancement** - binary first, then multi-outcome
4. Compare with iterative deepening to validate correctness
Expected benefits:
- More accurate action evaluation
- Better handling of probabilistic outcomes
- Closer alignment with theoretical MCTS
- Fixes the START_FIRE issue without tuning heuristics
Expected costs:
- ~20-30% slower per simulation (more nodes)
- ~1-2MB extra memory
- ~1-2 weeks development time
The benefits significantly outweigh the costs for a more theoretically sound and accurate AI.
@@ -32,7 +32,6 @@ 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",
],
)
@@ -20,15 +20,13 @@ ShardokAction::ShardokAction(
PlayerId player,
int actorId,
int targetRow,
int targetCol,
bool hasOdds)
int targetCol)
: commandIndex_(index),
type_(type),
player_(player),
actorId_(actorId),
targetRow_(targetRow),
targetCol_(targetCol),
hasOdds_(hasOdds) {}
targetCol_(targetCol) {}
std::string ShardokAction::getDescription() const {
std::stringstream ss;
@@ -54,8 +52,7 @@ std::unique_ptr<MCTSAction> ShardokAction::clone() const {
player_,
actorId_,
targetRow_,
targetCol_,
hasOdds_);
targetCol_);
}
bool ShardokAction::equals(const MCTSAction& other) const {
@@ -66,23 +63,4 @@ bool ShardokAction::equals(const MCTSAction& other) const {
return commandIndex_ == shardokOther->commandIndex_;
}
bool ShardokAction::requiresChanceNode() const {
// 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
@@ -29,15 +29,13 @@ public:
PlayerId player,
int actorId,
int targetRow,
int targetCol,
bool hasOdds);
int targetCol);
// MCTSAction interface implementation
[[nodiscard]] size_t getIndex() const override { return commandIndex_; }
[[nodiscard]] std::string getDescription() const override;
[[nodiscard]] std::unique_ptr<MCTSAction> clone() const override;
[[nodiscard]] bool equals(const MCTSAction& other) const override;
[[nodiscard]] bool requiresChanceNode() const override;
// Shardok-specific accessors (O(1), no allocations)
[[nodiscard]] int getType() const { return static_cast<int>(type_); }
@@ -46,14 +44,13 @@ public:
[[nodiscard]] std::pair<int, int> getTarget() const { return {targetRow_, targetCol_}; }
private:
// Store only essential fields (~25 bytes, all POD, cache-friendly)
// Store only essential fields (~24 bytes, all POD, cache-friendly)
size_t commandIndex_;
CommandType type_;
PlayerId player_;
int actorId_; // -1 if no actor
int targetRow_; // -1 if no target
int targetCol_; // -1 if no target
bool hasOdds_; // true if command has probabilistic outcome
};
} // namespace shardok::mcts
@@ -4,19 +4,15 @@
#include "ShardokGameEngine.hpp"
#include <algorithm>
#include <chrono>
#include <numeric>
#include "ShardokAction.hpp"
#include "ShardokGameState.hpp"
#include "src/main/cpp/net/eagle0/common/SequenceRandomGenerator.hpp"
#include "src/main/cpp/net/eagle0/common/mcts/abstract/MCTSTypes.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AICommandFilter.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIHeuristicWeighting.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokException.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
namespace shardok::mcts {
@@ -62,8 +58,7 @@ ShardokGameEngine::ShardokGameEngine(
std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
const MCTSGameState& state,
const MCTSAction& action,
double deterministicRoll) const {
const MCTSAction& action) const {
const auto* shardokState = dynamic_cast<const ShardokGameState*>(&state);
const auto* shardokAction = dynamic_cast<const ShardokAction*>(&action);
@@ -93,54 +88,9 @@ std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
engine = std::make_shared<ShardokEngine>(*engine);
}
// Create deterministic random generator if a specific roll is requested
// deterministicRoll of -1.0 (default) means use random generator
// Any other value (including negative) creates a deterministic generator
// For open-ended percentile commands, we compute a sequence of values that will
// produce the desired final result through the normal open-ended mechanics
std::shared_ptr<::RandomGenerator> randomGen = nullptr;
constexpr double kNoRollSentinel = -1.0;
if (deterministicRoll != kNoRollSentinel) {
std::vector<double> sequence;
engine->PostCommand(currentPlayer, shardokAction->getIndex(), nullptr);
if (deterministicRoll >= 5.0 && deterministicRoll <= 95.0) {
// Normal range: single value works directly
sequence = {deterministicRoll / 100.0};
} else if (deterministicRoll < 5.0) {
// Need open-ended LOW result (e.g., -100 for guaranteed success)
// OpenEndedPercentile: if initial < 5, returns initial - OpenEndedHighImpl(0, 4)
// We want: initial - accumulated = deterministicRoll
// Use initial = 2 (clearly < 5), so accumulated = 2 - deterministicRoll
constexpr double kInitialLow = 2.0;
sequence = {kInitialLow / 100.0};
// OpenEndedHighImpl accumulates rolls until one < 95
// Split accumulated into rolls: 96 (continues) + remaining (stops)
double remaining = kInitialLow - deterministicRoll;
while (remaining > 95.0) {
sequence.push_back(0.96); // 96 > 95, continues accumulation
remaining -= 96.0;
}
sequence.push_back(remaining / 100.0); // Final roll < 95, stops
} else {
// Need open-ended HIGH result (e.g., 150 for guaranteed failure)
// OpenEndedPercentile: if initial > 95, returns OpenEndedHighImpl(initial, 4)
// OpenEndedHighImpl accumulates rolls until one < 95
constexpr double kInitialHigh = 96.0;
sequence = {kInitialHigh / 100.0};
double remaining = deterministicRoll - kInitialHigh;
while (remaining > 95.0) {
sequence.push_back(0.96);
remaining -= 96.0;
}
sequence.push_back(remaining / 100.0);
}
randomGen = std::make_shared<::SequenceRandomGenerator>(sequence);
}
engine->PostCommand(currentPlayer, shardokAction->getIndex(), randomGen);
// Create and return the new state
// Create and return the new state (don't cache the mutated engine)
auto newState = std::make_unique<ShardokGameState>(
engine->GetCurrentGameState(),
scoreCalculator_,
@@ -152,11 +102,6 @@ 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;
@@ -256,26 +201,11 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
cmd->GetPlayerId(),
cmd->GetActorUnitId(),
cmd->GetTargetRow(),
cmd->GetTargetColumn(),
cmd->HasOdds()));
cmd->GetTargetColumn()));
}
}
// Sort actions by weight (descending) to ensure MCTS explores high-value actions first
const std::vector<double> weights = getActionWeights(actions, state);
std::vector<size_t> sortedIndices(actions.size());
std::iota(sortedIndices.begin(), sortedIndices.end(), 0);
std::sort(sortedIndices.begin(), sortedIndices.end(), [&weights](size_t a, size_t b) {
return weights[a] > weights[b];
});
std::vector<std::unique_ptr<MCTSAction>> sortedActions;
sortedActions.reserve(actions.size());
for (size_t idx : sortedIndices) { sortedActions.push_back(std::move(actions[idx])); }
return sortedActions;
return actions;
}
cacheMisses_.fetch_add(1, std::memory_order_relaxed);
@@ -326,31 +256,10 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
cmd->GetPlayerId(),
cmd->GetActorUnitId(),
cmd->GetTargetRow(),
cmd->GetTargetColumn(),
cmd->HasOdds()));
cmd->GetTargetColumn()));
}
}
// Sort actions by weight (descending) to ensure MCTS explores high-value actions first
// This is critical when maxPlayerFlips is low (e.g., 1), as only the first few actions
// get explored deeply. Original indices are preserved in ShardokAction::getIndex()
const std::vector<double> weights = getActionWeights(actions, state);
// Create index vector for sorting
std::vector<size_t> sortedIndices(actions.size());
std::iota(sortedIndices.begin(), sortedIndices.end(), 0);
// Sort indices by weight (descending)
std::sort(sortedIndices.begin(), sortedIndices.end(), [&weights](size_t a, size_t b) {
return weights[a] > weights[b];
});
// Reorder actions according to sorted indices
std::vector<std::unique_ptr<MCTSAction>> sortedActions;
sortedActions.reserve(actions.size());
for (size_t idx : sortedIndices) { sortedActions.push_back(std::move(actions[idx])); }
actions = std::move(sortedActions);
const auto actionsEnd = std::chrono::high_resolution_clock::now();
timeInLegalActionsComputation_.fetch_add(
std::chrono::duration_cast<std::chrono::microseconds>(actionsEnd - actionsStart)
@@ -360,18 +269,10 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
// Store in transposition table for future lookups
// Note: We only store filtered indices and the engine (which caches commands internally)
// This avoids duplicating heavy protocol buffer objects
// Use lazy_emplace_l to ensure thread-safe insertion (locks the bucket during construction)
legalActionsCache_.lazy_emplace_l(
stateHash,
[&](typename decltype(legalActionsCache_)::value_type& v) {
// Update existing entry
v.second.filteredIndices = filteredIndices;
v.second.engine = engine;
},
[&](const typename decltype(legalActionsCache_)::constructor& ctor) {
// Create new entry
ctor(stateHash, LegalActionsCache{filteredIndices, engine});
});
LegalActionsCache entry;
entry.filteredIndices = filteredIndices;
entry.engine = engine;
legalActionsCache_[stateHash] = std::move(entry);
return actions;
}
@@ -414,17 +315,6 @@ std::vector<double> ShardokGameEngine::getActionWeights(
const auto currentPlayer = static_cast<PlayerId>(state.currentPlayerId());
const CommandListSPtr commands = cachedEngine->GetAvailableCommandsForAIPlayer(currentPlayer);
// Determine if current player is defender (not root player!)
// During simulation we need to use the correct perspective for action weighting
bool currentPlayerIsDefender = false;
const auto& gameState = shardokState->getShardokState();
for (const auto* pi : *gameState->player_infos()) {
if (pi->player_id() == currentPlayer) {
currentPlayerIsDefender = pi->is_defender();
break;
}
}
// Use AIHeuristicWeighting for fast O(1) context-aware command weighting
std::vector<double> weights;
weights.reserve(actions.size());
@@ -451,10 +341,10 @@ std::vector<double> ShardokGameEngine::getActionWeights(
cmd->GetActorUnitId(),
cmd->GetPlayerId(),
Coords{cmd->GetTargetRow(), cmd->GetTargetColumn()},
gameState,
shardokState->getShardokState(),
castleCoords_,
apdCache_,
currentPlayerIsDefender, // Use current player's role, not root player's!
isDefender_,
[this](BattalionTypeId typeId) {
return gameSettings_->GetGetter().GetBattalionType(typeId);
}));
@@ -469,7 +359,6 @@ double ShardokGameEngine::getActionScore(
MCTSPlayerId playerId) const {
auto newState = applyAction(state, action);
if (!newState) { return 0.0; }
return newState->score(playerId);
}
@@ -543,86 +432,4 @@ void ShardokGameEngine::resetCacheStatistics() {
timeInLegalActionsComputation_.store(0, std::memory_order_relaxed);
}
ChanceOutcomeInfo ShardokGameEngine::getBinaryOutcomeInfo(
const MCTSGameState& state,
const MCTSAction& action) const {
const auto* shardokState = dynamic_cast<const ShardokGameState*>(&state);
const auto* shardokAction = dynamic_cast<const ShardokAction*>(&action);
if (!shardokState || !shardokAction) {
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
std::shared_ptr<ShardokEngine> engine;
if (auto cachedEngine = shardokState->getCachedEngine()) {
engine = cachedEngine;
} else {
engine = std::make_shared<ShardokEngine>(
gameSettings_,
shardokState->getShardokState(),
criticalTileCoords_,
0,
false);
// Populate command cache
[[maybe_unused]] const auto commands =
engine->GetAvailableCommandsForAIPlayer(currentPlayer);
shardokState->setCachedEngine(engine);
}
// Get command descriptors
const auto descriptors = engine->GetAvailableCommandsForAIPlayer(currentPlayer);
const size_t actionIndex = shardokAction->getIndex();
if (actionIndex >= descriptors->size()) {
throw ShardokInternalErrorException("Action index out of range in getBinaryOutcomeInfo");
}
const auto& descriptor = descriptors->at(actionIndex);
// Get success probability for binary outcome actions
if (!descriptor->HasOdds()) {
throw ShardokInternalErrorException("Action does not have odds in getBinaryOutcomeInfo");
}
const auto successChancePercentile = descriptor->GetOddsPercentile();
const double successProbability = static_cast<double>(successChancePercentile) / 100.0;
return ChanceOutcomeInfo::binary(successProbability);
}
void ShardokGameEngine::clearLegalActionsCache() { legalActionsCache_.clear(); }
// Extern-linkage function for testing
void clearLegalActionsCache_ForTesting() { ShardokGameEngine::clearLegalActionsCache(); }
} // namespace shardok::mcts
@@ -14,6 +14,7 @@
#include "src/main/cpp/net/eagle0/common/mcts/abstract/MCTSGameEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
@@ -47,8 +48,7 @@ public:
// MCTSGameEngine interface implementation
[[nodiscard]] std::unique_ptr<MCTSGameState> applyAction(
const MCTSGameState& state,
const MCTSAction& action,
double deterministicRoll = -1.0) const override;
const MCTSAction& action) const override;
void applyActionMutable(std::unique_ptr<MCTSGameState>& state, const MCTSAction& action)
const override;
@@ -86,10 +86,6 @@ public:
size_t filteredIndex,
const MCTSGameState& state) const override;
[[nodiscard]] BinaryOutcomeInfo getBinaryOutcomeInfo(
const MCTSGameState& state,
const MCTSAction& action) const override;
// Report transposition table statistics
void reportCacheStatistics() const;
@@ -110,9 +106,9 @@ private:
const ALCache* alCache_;
bool isDefender_;
AIStrategy strategy_;
const CoordsSet castleCoords_; // Own the data to avoid dangling references
const CoordsSet& castleCoords_;
// Computed once to avoid 8.5% overhead per engine construction
const CoordsSet criticalTileCoords_; // Own the data to avoid dangling references
const CoordsSet& criticalTileCoords_;
// Transposition table for legal actions (shared across threads with lock-free hash map)
// parallel_flat_hash_map provides thread-safe concurrent access without explicit locking
@@ -132,10 +128,6 @@ private:
// Performance timing (in microseconds)
static std::atomic<uint64_t> timeInHashComputation_;
static std::atomic<uint64_t> timeInLegalActionsComputation_;
public:
// Clear the static legal actions cache (useful for tests)
static void clearLegalActionsCache();
};
} // namespace mcts
@@ -42,31 +42,12 @@ uint64_t ShardokGameState::hash() const {
}
double ShardokGameState::score(MCTSPlayerId playerId) const {
// Honor the interface contract: score() should return evaluation from playerId's perspective.
// Map the requested playerId to defender/attacker role to determine scoring perspective.
// Look up which player ID is the defender from game state
bool foundDefender = false;
bool requestedPlayerIsDefender = false;
if (state_->player_infos()) {
for (const auto* pi : *state_->player_infos()) {
if (pi && pi->is_defender()) {
foundDefender = true;
requestedPlayerIsDefender = (static_cast<PlayerId>(playerId) == pi->player_id());
break;
}
}
}
// Fallback: if we can't determine from game state, use isDefender_ which represents
// the root player's role (and playerId is always the root player in practice)
const bool scoreFromDefenderPerspective =
foundDefender ? requestedPlayerIsDefender : isDefender_;
// Score the current state directly
return scoreCalculator_
->GuessedStateScore(scoreFromDefenderPerspective, state_, strategy_, castleCoords_);
(void)playerId; // Intentionally unused - we use isDefender_ (fixed at root)
// MCTS scoring: Always use root player's perspective (isDefender_) for evaluation.
// The playerId parameter represents the root player making decisions in the MCTS tree.
// All immediate scores and leaf evaluations must be from this fixed perspective.
// Adversarial logic (negation for opponent) happens in MCTS selection, not here.
return scoreCalculator_->GuessedStateScore(isDefender_, state_, strategy_, castleCoords_);
}
MCTSPlayerId ShardokGameState::currentPlayerId() const { return state_->current_player(); }
@@ -68,12 +68,12 @@ private:
const GameSettings* settings_;
bool isDefender_;
AIStrategy strategy_;
const CoordsSet castleCoords_; // Own the data to avoid dangling references
const CoordsSet& castleCoords_;
const APDCache& apdCache_;
const ALCache& alCache_;
mutable uint64_t cachedHash_ = 0;
mutable bool hashCached_ = false;
const CoordsSet criticalTileCoords_; // Own the data to avoid dangling references
const CoordsSet& criticalTileCoords_;
mutable std::shared_ptr<ShardokEngine> cachedEngine_; // Engine with cached available commands
};
@@ -72,8 +72,7 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokMCTSFactory::createActionsFromCo
cmd->GetPlayerId(),
cmd->GetActorUnitId(),
cmd->GetTargetRow(),
cmd->GetTargetColumn(),
cmd->HasOdds()));
cmd->GetTargetColumn()));
}
return actions;
@@ -206,19 +206,11 @@ auto StandardAIScoreCalculator::CombineDefenderHoldCastlesScores(
const UnitsScoreComponents &components,
const double victoryConditionScore,
const int roundsRemaining) const -> ScoreValue {
(void)roundsRemaining; // Intentionally unused for now
// From defender's perspective: negate the attacker-defender difference
const double unitsDifference = components.defenderUnitsValue - components.attackerUnitsValue;
// TODO: The time-decay multiplier (roundsRemaining/maxRounds) was causing END_TURN
// to score better than tactical actions because it reduced the penalty for having
// fewer units. Setting to constant 1.0 for now to fix tactical decision-making.
const double unitsMultiplier = 1.0;
// const double unitsMultiplier =
// static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
const double finalScore =
UNITS_BASE_MULTIPLIER * unitsMultiplier * unitsDifference + victoryConditionScore;
return finalScore;
const double unitsMultiplier =
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
return UNITS_BASE_MULTIPLIER * unitsMultiplier * unitsDifference + victoryConditionScore;
}
auto StandardAIScoreCalculator::DefenderFleeStrategyScoreForState(const GameStateW &gameState) const
@@ -7,13 +7,12 @@
#include <iostream>
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
#include "src/main/cpp/net/eagle0/common/TsvParser.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/AIClientFactory.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GamePhaseRunner.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GameSettingsFactory.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/GameStateHelpers.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/cpp/net/eagle0/shardok/util/BattalionTypeRegistrar.hpp"
#include "src/main/cpp/net/eagle0/shardok/util/MapLoader.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit.hpp"
@@ -68,7 +67,20 @@ AiBattleSimulator::AiBattleSimulator(const BattleConfigProto& config, GameSettin
gameSettings_(std::move(gameSettings)) {
if (!gameSettings_) {
// Initialize default game settings
gameSettings_ = ai_testing_common::GameSettingsFactory::CreateDefault();
gameSettings_ = std::make_shared<GameSettings>();
auto setter = gameSettings_->GetSetter();
// Load battalion types
BattalionTypeRegistrar::RegisterBattalionTypes(setter);
// Load settings from file
const std::string settingsPath =
FilesystemUtils::StaticShardokFilesDirectory() + "settings.tsv";
const std::string settingsTsv = std::string(byte_vector::FromPath(settingsPath));
TsvParser parser;
const auto valuesAndTypes = parser.ParseColumnEntryTsv(settingsTsv);
setter.SetFromTypesAndValues(valuesAndTypes[1], valuesAndTypes[0]);
}
}
@@ -349,7 +361,7 @@ std::unique_ptr<ShardokAIClient> AiBattleSimulator::CreateAIClient(
AIAlgorithmType algorithmType = ConvertAIAlgorithmType(playerConfig.ai_algorithm());
return ai_testing_common::AIClientFactory::Create(
return std::make_unique<ShardokAIClient>(
playerId,
isDefender,
hexMap,
@@ -361,28 +373,44 @@ BattleResult AiBattleSimulator::RunSetupPhase(
ShardokEngine& engine,
ShardokAIClient& attackerAI,
ShardokAIClient& defenderAI) {
auto getAI = [&](PlayerId playerId) -> ShardokAIClient& {
return (playerId == ATTACKER_ID) ? attackerAI : defenderAI;
};
int commandsExecuted = 0;
auto phaseResult = ai_testing_common::GamePhaseRunner::RunSetupPhase(engine, getAI);
while (engine.GetCurrentGameState()->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP) {
auto currentState = engine.GetCurrentGameState();
PlayerId currentPlayer = currentState->current_player();
std::cout << "Setup phase complete. Commands executed: " << phaseResult.commandsExecuted
<< "\n";
auto availableCommands = engine.GetAvailableCommandProtos(currentPlayer, false);
// Check if game ended unexpectedly
if (phaseResult.gameEnded) {
return CreateResultFromGameState(
engine.GetCurrentGameState(),
0,
phaseResult.commandsExecuted);
if (availableCommands.empty()) {
std::cout << "No commands available during setup for player " << (int)currentPlayer
<< "\n";
break;
}
// Choose which AI to use
ShardokAIClient& activeAI = (currentPlayer == ATTACKER_ID) ? attackerAI : defenderAI;
// Get AI decision
auto choiceResults = activeAI.ChooseCommandIndex(engine);
// Apply command
engine.PostCommand(currentPlayer, choiceResults.chosenIndex);
commandsExecuted++;
// Check if game ended unexpectedly
if (engine.GameIsOver()) {
return CreateResultFromGameState(engine.GetCurrentGameState(), 0, commandsExecuted);
}
}
std::cout << "Setup phase complete. Commands executed: " << commandsExecuted << "\n";
// Return a "not finished" result
BattleResult result;
result.winner = -1;
result.totalRounds = 0;
result.totalCommands = phaseResult.commandsExecuted;
result.totalCommands = commandsExecuted;
result.endReason = BattleResult::EndReason::DRAW; // Temporary placeholder
result.description = "Setup phase completed";
return result;
@@ -23,9 +23,6 @@ cc_library(
"//src/main/cpp/net/eagle0/common:filesystem_utils",
"//src/main/cpp/net/eagle0/common:tsv_parser",
"//src/main/cpp/net/eagle0/shardok/ai:shardok_ai_client",
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:ai_client_factory",
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:game_phase_runner",
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:game_settings_factory",
"//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/fb_helpers:game_state_helpers",
@@ -13,8 +13,6 @@
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIConfig.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/AIClientFactory.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GamePhaseRunner.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
@@ -122,7 +120,7 @@ int main(int argc, char* argv[]) {
const auto* hexMap = currentState->hex_map();
const auto settingsGetter = settings->GetGetter();
auto aiClient = ai_testing_common::AIClientFactory::Create(
ShardokAIClient aiClient(
aiPlayerId,
isDefender,
hexMap,
@@ -132,7 +130,7 @@ int main(int argc, char* argv[]) {
// Create a second AI client for the human player during setup
// This ensures consistent state handling during setup phase
const PlayerId humanPlayerId = 1;
auto humanSetupAI = ai_testing_common::AIClientFactory::Create(
ShardokAIClient humanSetupAI(
humanPlayerId,
!isDefender,
hexMap,
@@ -142,18 +140,29 @@ int main(int argc, char* argv[]) {
// Complete setup phase - AI makes intelligent placement decisions
if (currentState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP) {
auto getAI = [&](PlayerId playerId) -> ShardokAIClient& {
return (playerId == aiPlayerId) ? *aiClient : *humanSetupAI;
};
while (currentState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP) {
PlayerId currentPlayer = currentState->current_player();
auto availableCommands = engine.GetAvailableCommandProtos(currentPlayer, false);
auto setupResult = ai_testing_common::GamePhaseRunner::RunSetupPhase(engine, getAI);
if (availableCommands.empty()) {
std::cout << "No commands available for player "
<< static_cast<int>(currentPlayer) << "\n";
break;
}
if (setupResult.gameEnded) {
std::cout << "Game ended unexpectedly during setup phase.\n";
return 0;
if (currentPlayer == aiPlayerId) {
// Let AI make intelligent placement decisions
auto choiceResults = aiClient.ChooseCommandIndex(engine);
engine.PostCommand(currentPlayer, choiceResults.chosenIndex);
} else {
// Human player: use AI for setup to ensure consistent state handling
auto choiceResults = humanSetupAI.ChooseCommandIndex(engine);
engine.PostCommand(currentPlayer, choiceResults.chosenIndex);
}
currentState = engine.GetCurrentGameState();
}
currentState = engine.GetCurrentGameState();
}
// Test AI performance for configured number of turns
@@ -170,7 +179,7 @@ int main(int argc, char* argv[]) {
}
// Get AI decision with performance metrics
auto choiceResults = aiClient->ChooseCommandIndex(engine);
auto choiceResults = aiClient.ChooseCommandIndex(engine);
std::cout << " AI chose command index: " << choiceResults.chosenIndex << "\n";
std::cout << " Depth achieved: " << choiceResults.depthAchieved << "\n";
@@ -23,9 +23,6 @@ cc_binary(
"//src/main/cpp/net/eagle0/shardok/ai:ai_water_crossing_command_chooser",
"//src/main/cpp/net/eagle0/shardok/ai:shardok_ai_client",
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:ai_client_factory",
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:game_phase_runner",
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:game_settings_factory",
"//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",
@@ -42,7 +39,6 @@ cc_library(
],
copts = COPTS,
deps = [
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:game_settings_factory",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
"//src/main/cpp/net/eagle0/shardok/library/fb_helpers:game_state_helpers",
@@ -7,9 +7,11 @@
#include <filesystem>
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GameSettingsFactory.hpp"
#include "src/main/cpp/net/eagle0/common/TsvParser.hpp"
#include "src/main/cpp/net/eagle0/common/byte_vector.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/GameStateHelpers.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/cpp/net/eagle0/shardok/util/BattalionTypeRegistrar.hpp"
#include "src/main/cpp/net/eagle0/shardok/util/MapLoader.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit.hpp"
#include "src/main/protobuf/net/eagle0/shardok/common/player_info.pb.h"
@@ -28,7 +30,20 @@ constexpr PlayerId HUMAN_PLAYER_ID = 1;
} // namespace
auto PerformanceTestGameStateBuilder::InitializeGameSettings() -> GameSettingsSPtr {
return ai_testing_common::GameSettingsFactory::CreateDefault();
auto settings = std::make_shared<GameSettings>();
auto setter = settings->GetSetter();
// Load battalion types
BattalionTypeRegistrar::RegisterBattalionTypes(setter);
// Load complete settings from settings.tsv file
TsvParser parser;
const string settingsPath = FilesystemUtils::StaticShardokFilesDirectory() + "settings.tsv";
const string settingsTsv = string(byte_vector::FromPath(settingsPath));
const auto valuesAndTypes = parser.ParseColumnEntryTsv(settingsTsv);
setter.SetFromTypesAndValues(valuesAndTypes[1], valuesAndTypes[0]);
return settings;
}
auto PerformanceTestGameStateBuilder::CreatePerfTestGameState(
@@ -1,469 +0,0 @@
# AI Testing Guide
This guide explains how to use the two AI testing tools in the Eagle0 codebase for evaluating and improving AI performance.
## Overview
The codebase provides two complementary tools for AI testing:
1. **AI Performance Runner** - Benchmarks AI decision-making quality over specific turns
2. **AI Battle Simulator** - Tests AI effectiveness by running complete battles
Both tools support the Iterative Deepening and MCTS AI algorithms.
---
## AI Performance Runner
**Location**: `src/main/cpp/net/eagle0/shardok/ai_performance_runner/`
### Purpose
Measures AI decision-making performance to:
- Identify performance regressions when making AI changes
- Understand search depth achieved within time budgets
- Analyze command evaluation patterns at each depth
- Profile AI bottlenecks
### Building
```bash
bazel build //src/main/cpp/net/eagle0/shardok/ai_performance_runner:ai_performance_runner
```
### Usage
```bash
bazel run //src/main/cpp/net/eagle0/shardok/ai_performance_runner:ai_performance_runner -- \
--map=MAP_NAME \
--turns=N \
[--defender=true|false] \
[--verbose]
```
### Command-Line Arguments
| Argument | Required | Default | Description |
|----------|----------|---------|-------------|
| `--map` | Yes | - | Map name (e.g., "FourWay", "Bridge") |
| `--turns` | Yes | - | Number of turns to run AI for |
| `--defender` | No | false | If true, test defender AI; if false, test attacker AI |
| `--verbose` | No | false | Enable verbose logging of AI decisions |
### Output Format
For each turn, outputs:
```
Turn 1:
Depth achieved: 3
Commands evaluated:
Depth 1: 45/45 commands (100%)
Depth 2: 127/234 commands (54%)
Depth 3: 23/891 commands (3%)
Completion reason: TIME_LIMIT
Time elapsed: 5.2s
```
### Performance Metrics Explained
- **Depth achieved**: Maximum lookahead depth the AI reached
- **Commands evaluated**: At each depth, shows commands fully evaluated vs total available
- Higher depth evaluations are more valuable (depth 3 > depth 2 > depth 1)
- Completion rates show how thoroughly the AI searched each depth
- **Completion reason**: Why the search stopped
- `TIME_LIMIT`: Ran out of time budget (normal)
- `COMPLETE`: Fully evaluated all possibilities (rare, usually only in simple endgames)
- `DEPTH_LIMIT`: Hit maximum configured depth
### Example Workflow: Testing Performance Improvements
```bash
# 1. Commit your baseline changes
git checkout -b my-performance-improvement
git add . && git commit -m "Baseline before optimization"
# 2. Run performance tests multiple times (reduce noise)
for i in 1 2 3; do
echo "=== Baseline Run $i ==="
bazel run //src/main/cpp/net/eagle0/shardok/ai_performance_runner:ai_performance_runner -- \
--map=FourWay --turns=5 --defender=true | grep -A 10 "Turn"
done
# Save the results
# 3. Make your optimization changes
# ... edit code ...
# 4. Run tests again and compare
for i in 1 2 3; do
echo "=== Optimized Run $i ==="
bazel run //src/main/cpp/net/eagle0/shardok/ai_performance_runner:ai_performance_runner -- \
--map=FourWay --turns=5 --defender=true | grep -A 10 "Turn"
done
# 5. Compare the results
# Look for: increased depth, higher completion rates, more commands evaluated at deeper levels
```
### When to Use Performance Runner
- ✅ Making changes to AI search algorithms
- ✅ Optimizing performance-critical code paths
- ✅ Identifying regressions in decision quality
- ✅ Understanding where the AI spends its time
- ❌ Testing which AI strategy wins more battles (use Battle Simulator instead)
---
## AI Battle Simulator
**Location**: `src/main/cpp/net/eagle0/shardok/ai_battle_simulator/`
### Purpose
Tests AI effectiveness by:
- Running complete AI vs AI battles from start to finish
- Measuring win rates across different AI configurations
- Testing AI behavior with various unit compositions
- Comparing different AI algorithms (MCTS vs Iterative Deepening)
### Building
```bash
bazel build //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:ai_battle_simulator
```
### Usage
The battle simulator works with JSON configuration files.
#### Step 1: Generate a Sample Configuration
```bash
bazel run //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:ai_battle_simulator -- \
--generate-config=my_battle_config.json
```
This creates a sample configuration file you can customize.
#### Step 2: Customize the Configuration
Edit the generated JSON file:
```json
{
"map_name": "FourWay",
"max_rounds": 100,
"attacker": {
"ai_algorithm": "ITERATIVE_DEEPENING",
"units": [
{
"battalion_type": "HEAVY_INFANTRY",
"row": 2,
"column": 3,
"strength": 1000,
"has_hero": true,
"hero_profession": "WARRIOR",
"hero_level": 5
},
{
"battalion_type": "ARCHERS",
"row": 2,
"column": 4,
"strength": 800,
"has_hero": false
}
]
},
"defender": {
"ai_algorithm": "MCTS",
"units": [
{
"battalion_type": "HEAVY_INFANTRY",
"row": 8,
"column": 3,
"strength": 1000,
"has_hero": true,
"hero_profession": "WARRIOR",
"hero_level": 5
}
]
}
}
```
#### Step 3: Run the Battle
```bash
bazel run //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:ai_battle_simulator -- \
--config=my_battle_config.json
```
### Configuration Format
#### Top-Level Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `map_name` | string | Yes | Name of the map to use (e.g., "FourWay", "Bridge") |
| `max_rounds` | integer | Yes | Maximum rounds before declaring a draw |
| `attacker` | object | Yes | Attacker configuration (see below) |
| `defender` | object | Yes | Defender configuration (see below) |
#### Player Configuration (attacker/defender)
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `ai_algorithm` | string | Yes | "ITERATIVE_DEEPENING" or "MCTS" |
| `units` | array | Yes | List of unit configurations (see below) |
#### Unit Configuration
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `battalion_type` | string | Yes | - | "HEAVY_INFANTRY", "LIGHT_INFANTRY", "ARCHERS", "CAVALRY", etc. |
| `row` | integer | Yes | - | Starting row position (0-indexed) |
| `column` | integer | Yes | - | Starting column position (0-indexed) |
| `strength` | integer | No | 1000 | Unit strength (max 1000) |
| `has_hero` | boolean | No | false | Whether unit has an attached hero |
| `hero_profession` | string | No* | - | "WARRIOR", "RANGER", "MAGE" (*required if has_hero=true) |
| `hero_level` | integer | No | 1 | Hero level (1-10) |
| `hero_vigor` | integer | No | 100 | Hero vigor (0-100) |
### Output Format
After running a battle, outputs:
```
Battle Result:
Winner: ATTACKER
Total Rounds: 47
Total Commands: 2,341
Attacker Survivors: 3 units
- Heavy Infantry at (4,5): 723 strength
- Archers at (3,6): 412 strength
- Cavalry at (5,4): 891 strength
Defender Survivors: 0 units
Battle Duration: 14.2 seconds
```
### Example Workflow: Testing AI Effectiveness
```bash
# 1. Create a baseline configuration
bazel run //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:ai_battle_simulator -- \
--generate-config=baseline.json
# 2. Edit baseline.json to set up your test scenario
# Set both players to ITERATIVE_DEEPENING
# 3. Run multiple battles to get win rate
for i in {1..20}; do
echo "Battle $i:"
bazel run //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:ai_battle_simulator -- \
--config=baseline.json | grep "Winner"
done
# 4. Create a comparison configuration
cp baseline.json mcts_comparison.json
# Edit mcts_comparison.json: change attacker to MCTS
# 5. Run battles with MCTS attacker
for i in {1..20}; do
echo "Battle $i:"
bazel run //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:ai_battle_simulator -- \
--config=mcts_comparison.json | grep "Winner"
done
# 6. Compare win rates
# Count ATTACKER wins in each set to see if MCTS performs better/worse
```
### Advanced: Testing Specific Scenarios
The battle simulator is ideal for testing:
**Scenario 1: Hero Ability Usage**
```json
{
"attacker": {
"units": [
{
"battalion_type": "LIGHT_INFANTRY",
"has_hero": true,
"hero_profession": "RANGER",
"hero_level": 8,
"row": 2, "column": 3
}
]
}
}
```
Tests whether AI properly uses Ranger stealth abilities.
**Scenario 2: Terrain Advantage**
```json
{
"map_name": "BridgeChoke",
"defender": {
"units": [
{"battalion_type": "HEAVY_INFANTRY", "row": 5, "column": 4}
]
}
}
```
Tests whether AI exploits defensive terrain.
**Scenario 3: Numerical Superiority**
```json
{
"attacker": {
"units": [
{"battalion_type": "ARCHERS", "row": 2, "column": 2},
{"battalion_type": "ARCHERS", "row": 2, "column": 3},
{"battalion_type": "ARCHERS", "row": 2, "column": 4}
]
},
"defender": {
"units": [
{"battalion_type": "CAVALRY", "row": 8, "column": 3}
]
}
}
```
Tests whether AI properly coordinates multiple units.
### When to Use Battle Simulator
- ✅ Testing which AI strategy wins more battles
- ✅ Measuring AI effectiveness with different unit types
- ✅ Comparing MCTS vs Iterative Deepening algorithms
- ✅ Validating AI behavior in specific tactical scenarios
- ✅ Regression testing: ensuring AI changes don't reduce win rates
- ❌ Profiling performance bottlenecks (use Performance Runner instead)
---
## Combining Both Tools
For comprehensive AI evaluation:
1. **Make AI changes** to improve decision-making
2. **Run Performance Runner** to verify the AI searches deeper or evaluates more commands
3. **Run Battle Simulator** to verify the changes actually improve win rates
4. **Iterate** if performance improves but win rate doesn't (or vice versa)
### Example: Evaluating a New Heuristic
```bash
# 1. Baseline performance
bazel run //src/main/cpp/net/eagle0/shardok/ai_performance_runner:ai_performance_runner -- \
--map=FourWay --turns=3 > baseline_perf.txt
# 2. Baseline effectiveness
for i in {1..10}; do
bazel run //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:ai_battle_simulator -- \
--config=test_scenario.json | grep "Winner"
done > baseline_wins.txt
# 3. Make changes to heuristic
# ... edit code ...
# 4. New performance
bazel run //src/main/cpp/net/eagle0/shardok/ai_performance_runner:ai_performance_runner -- \
--map=FourWay --turns=3 > new_perf.txt
# 5. New effectiveness
for i in {1..10}; do
bazel run //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:ai_battle_simulator -- \
--config=test_scenario.json | grep "Winner"
done > new_wins.txt
# 6. Compare results
diff baseline_perf.txt new_perf.txt
wc -l < baseline_wins.txt | grep ATTACKER # Count baseline wins
wc -l < new_wins.txt | grep ATTACKER # Count new wins
```
---
## Available Maps
Common maps for testing:
- **FourWay**: Open terrain with multiple approach paths
- **Bridge**: Chokepoint scenario testing tactical positioning
- **Forest**: Tests unit behavior in hiding terrain
- **Mountain**: Tests pathfinding around impassable terrain
Find all available maps in: `src/main/resources/net/eagle0/shardok/maps/`
---
## Troubleshooting
### Performance Runner Issues
**Issue**: "Map not found"
```bash
# Solution: Use exact map name without .e0mj extension
# ✅ Correct:
--map=FourWay
# ❌ Incorrect:
--map=FourWay.e0mj
```
**Issue**: AI completes instantly
```bash
# Likely cause: Too few turns or already-won scenario
# Solution: Increase --turns or use more complex map
--turns=10 # Instead of --turns=1
```
### Battle Simulator Issues
**Issue**: "Invalid unit position"
```bash
# Solution: Ensure positions are within map bounds and not overlapping
# Check map size first, then place units accordingly
```
**Issue**: "Battle ends immediately"
```bash
# Cause: Units placed too close or in invalid starting positions
# Solution:
# - Attacker units should start on attacker side (low row numbers)
# - Defender units should start on defender side (high row numbers)
# - Leave space between forces for tactical maneuvering
```
**Issue**: Battle runs extremely slowly
```bash
# Cause: Too many units or max_rounds too high
# Solution: Start with 2-3 units per side, max_rounds=50
# Scale up once basic scenario works
```
---
## Best Practices
### For Performance Testing
1. **Run multiple iterations** (3-5) to reduce variance
2. **Test on consistent maps** to enable before/after comparison
3. **Focus on depth and completion rates** rather than raw time
4. **Test both attacker and defender** AIs (they use different strategies)
### For Effectiveness Testing
1. **Run 10-20 battles** minimum for statistical significance
2. **Use symmetric scenarios** (equal forces) to isolate AI quality
3. **Test multiple maps** to avoid overfitting to one scenario
4. **Document unit compositions** so tests are reproducible
5. **Version control configs** alongside code changes
### For Both
1. **Commit before testing** so you can easily revert
2. **Document unexpected results** (AI might be correct, your intuition wrong)
3. **Test edge cases** (one unit, heroes only, terrain heavy)
4. **Compare algorithms** (MCTS vs Iterative Deepening) regularly
@@ -1,647 +0,0 @@
# Proposal: Server-Based AI Effectiveness Testing
## Executive Summary
Replace proxy-based performance metrics (search depth, nodes visited) with **actual effectiveness testing** (win rates, battle outcomes) by running AI battles through the production Shardok server. This measures what matters: whether AI improvements make the AI smarter, not just faster.
## Problem Statement
### Current State: Measuring the Wrong Things
The existing `ai_performance_runner` measures proxy metrics:
- Search depth achieved
- Number of commands evaluated
- Time spent searching
**Problem**: These metrics don't tell us if the AI is making good decisions.
**Example failure mode**:
- AI searches to depth 4 (looks impressive!)
- But uses terrible heuristics (all decisions are bad)
- Result: Loses every battle despite "good" metrics
### What We Actually Care About
- **Does the AI win?** (win rate)
- **By what margin?** (survivors, rounds taken)
- **Is it tactically sound?** (decision quality in specific scenarios)
- **Does it handle edge cases?** (terrain, heroes, special abilities)
## Proposed Solution
Build a **server-based AI effectiveness testing framework** that:
1. Runs battles through the production Shardok server (real code paths)
2. Measures actual effectiveness (win rates, outcomes)
3. Supports repeatable test scenarios
4. Enables comparison between AI algorithms (MCTS vs Iterative Deepening)
5. Eventually allows Unity clients to watch battles
## Architecture
### Component Overview
```
┌─────────────────────┐
│ Test Scenarios │
│ (JSON configs) │
└──────────┬──────────┘
┌─────────────────────┐
│ Go Test Client │
│ - Loads scenarios │
│ - Sends gRPC reqs │
│ - Collects results │
└──────────┬──────────┘
│ gRPC
┌─────────────────────┐
│ Shardok Server │
│ - Creates games │
│ - Runs AI vs AI │
│ - Returns outcomes │
└─────────────────────┘
```
### Why Go for the Client?
- Native gRPC support with `protoc-gen-go-grpc`
- Easy JSON config parsing
- Good for CLI tools
- Fast compile times for iteration
- Excellent concurrency for running parallel test scenarios
## Implementation Plan
### Phase 1: Core Framework (1 week)
**Goal**: Basic server-based effectiveness testing
#### 1.1 Protocol Extensions
Add to `src/main/protobuf/net/eagle0/common/shardok_internal_interface.proto`:
```protobuf
message TestBattleRequest {
string game_id = 1;
string map_name = 2;
message PlayerSetup {
bool is_defender = 1;
AIAlgorithmType ai_algorithm = 2; // MCTS or ITERATIVE_DEEPENING
ScoringCalculatorType scoring_type = 3; // STANDARD, NORMALIZED, MCTS_OPTIMIZED
repeated UnitPlacement units = 4;
}
repeated PlayerSetup players = 3;
int32 max_rounds = 4;
}
message UnitPlacement {
string battalion_type = 1; // "HEAVY_INFANTRY", "ARCHERS", etc.
int32 row = 2;
int32 column = 3;
int32 strength = 4;
bool has_hero = 5;
string hero_profession = 6; // "WARRIOR", "RANGER", "MAGE"
int32 hero_level = 7;
}
message TestBattleResponse {
string game_id = 1;
int32 winner_player_id = 2; // -1 for draw
int32 rounds_taken = 3;
string end_reason = 4; // "VICTORY", "DRAW", "MAX_ROUNDS"
repeated FinalUnitStatus final_units = 5;
}
message FinalUnitStatus {
int32 player_id = 1;
string battalion_type = 2;
int32 strength_remaining = 3;
int32 row = 4;
int32 column = 5;
}
// Add to ShardokInternalInterface service:
rpc StartTestBattle(TestBattleRequest) returns (TestBattleResponse) {}
```
**Estimated effort**: 1 day
#### 1.2 Server Implementation
Add handler to `src/main/cpp/net/eagle0/shardok/server/EagleInterfaceGrpcServer.cpp`:
```cpp
Status EagleInterfaceImpl::StartTestBattle(
ServerContext* context,
const TestBattleRequest* request,
TestBattleResponse* response) {
// 1. Create game with specified setup
// 2. Mark all players as is_ai=true
// 3. Wait for game to complete (AI thread handles it)
// 4. Collect final state and return results
}
```
**Key insight**: The infrastructure already exists! `ShardokGameController` already:
- Creates AI clients automatically for `is_ai=true` players
- Runs AI decisions in background thread
- Tracks game state and completion
**Estimated effort**: 2 days
#### 1.3 Go Test Client
Create `src/main/go/net/eagle0/shardok/ai_effectiveness_runner/`:
```go
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
pb "eagle0/protobuf/net/eagle0/common"
"google.golang.org/grpc"
)
type ScenarioConfig struct {
Name string `json:"name"`
MapName string `json:"map_name"`
MaxRounds int32 `json:"max_rounds"`
Attacker PlayerConfig `json:"attacker"`
Defender PlayerConfig `json:"defender"`
}
type PlayerConfig struct {
AIAlgorithm string `json:"ai_algorithm"`
ScoringType string `json:"scoring_type"`
Units []UnitPlacement `json:"units"`
}
func runTestBattle(client pb.ShardokInternalInterfaceClient, scenario ScenarioConfig) (*pb.TestBattleResponse, error) {
ctx := context.Background()
req := &pb.TestBattleRequest{
GameId: fmt.Sprintf("test_%s_%d", scenario.Name, time.Now().Unix()),
MapName: scenario.MapName,
MaxRounds: scenario.MaxRounds,
Players: []*pb.TestBattleRequest_PlayerSetup{
{
IsDefender: false,
AiAlgorithm: parseAIAlgorithm(scenario.Attacker.AIAlgorithm),
ScoringType: parseScoringType(scenario.Attacker.ScoringType),
Units: convertUnits(scenario.Attacker.Units),
},
{
IsDefender: true,
AiAlgorithm: parseAIAlgorithm(scenario.Defender.AIAlgorithm),
ScoringType: parseScoringType(scenario.Defender.ScoringType),
Units: convertUnits(scenario.Defender.Units),
},
},
}
return client.StartTestBattle(ctx, req)
}
func main() {
serverAddr := flag.String("server", "localhost:50051", "Shardok server address")
scenariosFile := flag.String("scenarios", "test_scenarios.json", "Test scenarios JSON file")
iterations := flag.Int("iterations", 20, "Number of iterations per scenario")
flag.Parse()
// Load scenarios
scenarios := loadScenarios(*scenariosFile)
// Connect to server
conn, err := grpc.Dial(*serverAddr, grpc.WithInsecure())
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
client := pb.NewShardokInternalInterfaceClient(conn)
// Run effectiveness tests
results := make(map[string]*ScenarioResults)
for _, scenario := range scenarios {
fmt.Printf("\n=== Testing Scenario: %s ===\n", scenario.Name)
scenarioResults := &ScenarioResults{
ScenarioName: scenario.Name,
}
for i := 0; i < *iterations; i++ {
resp, err := runTestBattle(client, scenario)
if err != nil {
log.Printf("Battle %d failed: %v", i+1, err)
continue
}
scenarioResults.TotalBattles++
if resp.WinnerPlayerId == 0 { // Attacker wins
scenarioResults.AttackerWins++
} else if resp.WinnerPlayerId == 1 { // Defender wins
scenarioResults.DefenderWins++
} else {
scenarioResults.Draws++
}
scenarioResults.TotalRounds += int(resp.RoundsTaken)
scenarioResults.TotalSurvivors += len(resp.FinalUnits)
fmt.Printf(" Battle %d/%d: Winner=%d, Rounds=%d, Survivors=%d\n",
i+1, *iterations, resp.WinnerPlayerId, resp.RoundsTaken, len(resp.FinalUnits))
}
results[scenario.Name] = scenarioResults
}
// Print summary
printSummary(results)
}
```
**Estimated effort**: 2 days
#### 1.4 Test Scenario Definitions
Create `test_scenarios.json`:
```json
{
"scenarios": [
{
"name": "mcts_vs_id_balanced",
"map_name": "FourWay",
"max_rounds": 100,
"attacker": {
"ai_algorithm": "MCTS",
"scoring_type": "MCTS_OPTIMIZED",
"units": [
{
"battalion_type": "HEAVY_INFANTRY",
"row": 2,
"column": 3,
"strength": 1000,
"has_hero": true,
"hero_profession": "WARRIOR",
"hero_level": 5
},
{
"battalion_type": "ARCHERS",
"row": 2,
"column": 4,
"strength": 800,
"has_hero": false
}
]
},
"defender": {
"ai_algorithm": "ITERATIVE_DEEPENING",
"scoring_type": "STANDARD",
"units": [
{
"battalion_type": "HEAVY_INFANTRY",
"row": 8,
"column": 3,
"strength": 1000,
"has_hero": true,
"hero_profession": "WARRIOR",
"hero_level": 5
},
{
"battalion_type": "ARCHERS",
"row": 8,
"column": 4,
"strength": 800,
"has_hero": false
}
]
}
},
{
"name": "terrain_advantage_test",
"map_name": "Bridge",
"max_rounds": 100,
"attacker": {
"ai_algorithm": "MCTS",
"scoring_type": "MCTS_OPTIMIZED",
"units": [
{
"battalion_type": "LIGHT_INFANTRY",
"row": 2,
"column": 5,
"strength": 1000,
"has_hero": false
}
]
},
"defender": {
"ai_algorithm": "MCTS",
"scoring_type": "MCTS_OPTIMIZED",
"units": [
{
"battalion_type": "HEAVY_INFANTRY",
"row": 10,
"column": 5,
"strength": 800,
"has_hero": false
}
]
}
}
]
}
```
**Estimated effort**: 1 day (create comprehensive test suite)
### Phase 2: Enhanced Observability (1 week)
**Goal**: Stream battle updates for real-time monitoring
#### 2.1 Streaming Protocol
Extend protocol to support streaming updates:
```protobuf
message TestBattleUpdate {
enum UpdateType {
SETUP_COMPLETE = 0;
ROUND_START = 1;
AI_DECISION = 2;
ROUND_END = 3;
BATTLE_END = 4;
}
UpdateType type = 1;
int32 current_round = 2;
// For AI_DECISION updates
int32 player_id = 3;
string command_type = 4; // "MOVE", "MELEE", "ARCHERY", etc.
// Optional: Include AI metrics as secondary data
AIDecisionMetrics ai_metrics = 5;
// For BATTLE_END updates
TestBattleResponse final_result = 6;
}
message AIDecisionMetrics {
int32 depth_achieved = 1;
int32 commands_evaluated = 2;
int64 time_spent_ms = 3;
}
// Add streaming RPC:
rpc StartTestBattleStreaming(TestBattleRequest) returns (stream TestBattleUpdate) {}
```
**Benefits**:
- Real-time battle monitoring
- Can log/replay interesting decisions
- Still captures proxy metrics as secondary data (if desired)
- Enables debugging of specific scenarios
**Estimated effort**: 3 days
#### 2.2 Go Client Updates
Add streaming support to Go client:
```go
func runTestBattleStreaming(client pb.ShardokInternalInterfaceClient, scenario ScenarioConfig) (*pb.TestBattleResponse, error) {
ctx := context.Background()
stream, err := client.StartTestBattleStreaming(ctx, req)
if err != nil {
return nil, err
}
for {
update, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
switch update.Type {
case pb.TestBattleUpdate_AI_DECISION:
fmt.Printf(" Round %d: Player %d chose %s\n",
update.CurrentRound, update.PlayerId, update.CommandType)
case pb.TestBattleUpdate_BATTLE_END:
return update.FinalResult, nil
}
}
}
```
**Estimated effort**: 2 days
### Phase 3: Unity Client Integration (2 weeks)
**Goal**: Enable Unity clients to watch AI battles in real-time
#### 3.1 Route Through Eagle
Extend Eagle server to accept test battle requests and create Shardok games:
```scala
// In Eagle server
def startTestBattle(request: TestBattleRequest): Unit = {
// 1. Create game state in Eagle
// 2. Send to Shardok via existing protocol
// 3. Mark both players as AI
// 4. Allow Unity clients to connect and watch
}
```
**Benefits**:
- Tests complete production stack (Eagle + Shardok)
- Unity clients can connect as spectators
- Most realistic integration test possible
**Estimated effort**: 1 week
#### 3.2 Unity Spectator Mode
Add spectator mode to Unity client:
```csharp
// In Unity client
public class AIBattleSpectator : MonoBehaviour {
public void ConnectToBattle(string gameId) {
// Connect to Eagle as spectator
// Receive battle updates
// Render on screen
}
}
```
**Estimated effort**: 1 week
## Usage Examples
### Basic Effectiveness Testing
```bash
# Start Shardok server
bazel run //src/main/cpp/net/eagle0/shardok:shardok-server
# Run effectiveness tests
bazel run //src/main/go/net/eagle0/shardok/ai_effectiveness_runner:ai_effectiveness_runner -- \
--server=localhost:50051 \
--scenarios=test_scenarios.json \
--iterations=20
# Output:
# === Testing Scenario: mcts_vs_id_balanced ===
# Battle 1/20: Winner=0, Rounds=47, Survivors=3
# Battle 2/20: Winner=1, Rounds=52, Survivors=2
# ...
#
# === Summary ===
# Scenario: mcts_vs_id_balanced
# MCTS (attacker) wins: 15/20 (75%)
# Iterative Deepening (defender) wins: 5/20 (25%)
# Average rounds: 48.3
# Average survivors: 2.8
```
### Comparing AI Improvements
```bash
# Before optimization
git checkout main
bazel run //src/main/go/net/eagle0/shardok/ai_effectiveness_runner:ai_effectiveness_runner -- \
--scenarios=regression_suite.json --iterations=50 > baseline_results.txt
# After optimization
git checkout my-ai-improvement
bazel run //src/main/go/net/eagle0/shardok/ai_effectiveness_runner:ai_effectiveness_runner -- \
--scenarios=regression_suite.json --iterations=50 > improved_results.txt
# Compare
diff baseline_results.txt improved_results.txt
# Shows: MCTS win rate improved from 60% to 75%! ✅
```
### Watching Battles in Unity
```bash
# Terminal 1: Eagle server
bazel run //src/main/scala/net/eagle0/eagle:eagle_server
# Terminal 2: Shardok server
bazel run //src/main/cpp/net/eagle0/shardok:shardok-server
# Terminal 3: Start test battle
bazel run //src/main/go/net/eagle0/shardok/ai_effectiveness_runner:ai_effectiveness_runner -- \
--server=localhost:40032 \
--scenarios=interesting_scenario.json \
--stream
# Terminal 4: Unity client
# Open Unity, connect as spectator to watch battle unfold
```
## Success Metrics
### Immediate (Phase 1)
- ✅ Can run 20+ battle scenarios against server
- ✅ Measures win rates, rounds, survivors
- ✅ Tests real production Shardok server code paths
- ✅ Reproducible results
### Medium-term (Phase 2)
- ✅ Streaming battle updates work
- ✅ Can capture and replay interesting battles
- ✅ Logs include both effectiveness metrics and proxy metrics
### Long-term (Phase 3)
- ✅ Unity clients can watch AI battles
- ✅ Tests complete Eagle + Shardok stack
- ✅ Community can watch AI improvements
## Migration Strategy
### Keep Existing Tools
**AI Battle Simulator** (in-process, keep for development):
- Fast iteration during development
- Easy debugging (direct access to internals)
- Use case: "Does this change work at all?"
**AI Effectiveness Runner** (server-based, new primary tool):
- Tests production code paths
- Measures real effectiveness
- Use case: "Is this change actually better?"
### Deprecate Performance Runner
The current `ai_performance_runner` measures proxy metrics. Recommend:
1. Keep it temporarily for comparison
2. After Phase 2 (streaming + AI metrics), deprecate it
3. Streaming effectiveness runner includes proxy metrics as secondary data
## Open Questions
1. **Server Resource Management**: Should we limit concurrent test battles on the server?
- Proposal: Add `--max-concurrent-battles` flag to Go client
2. **Scenario Versioning**: How do we ensure scenarios remain valid as game evolves?
- Proposal: Version scenarios in git, validate against server on load
3. **Metrics Storage**: Should we store historical effectiveness metrics?
- Proposal: Phase 4 (future) - add database for tracking AI effectiveness over time
4. **Randomness Control**: How do we handle dice roll randomness in battles?
- Current: Protocol supports `roll` override, but battles have many rolls
- Proposal: Add `random_seed` to TestBattleRequest for reproducibility
## Timeline
- **Phase 1**: 1 week (core framework)
- **Phase 2**: 1 week (streaming + observability)
- **Phase 3**: 2 weeks (Unity integration)
**Total**: 4 weeks for complete vision
**Minimal viable**: Phase 1 only (1 week) provides immediate value
## Next Steps
1. Review and approve this proposal
2. Merge current PR (#4518) - AI testing infrastructure refactor
3. Begin Phase 1 implementation:
- Protocol extensions (1 day)
- Server implementation (2 days)
- Go client (2 days)
- Test scenarios (1 day)
4. Validate with initial test runs
5. Iterate based on findings
6. Plan Phase 2 based on Phase 1 learnings
## Conclusion
This proposal shifts AI testing from measuring **proxies** (search depth) to measuring **reality** (win rates). By running battles through the production server, we:
- Test what matters: actual intelligence
- Validate real code paths: gRPC, threading, server logic
- Enable future capabilities: Unity viewing, distributed testing
- Measure improvements objectively: win rate changes
The infrastructure mostly exists - ShardokGameController already handles AI vs AI battles. We just need to expose it via protocol and build a client to drive it.
**Recommendation**: Approve and implement Phase 1 (1 week) to immediately gain better AI effectiveness measurement.
@@ -1,34 +0,0 @@
//
// AIClientFactory Implementation
//
#include "AIClientFactory.hpp"
#include "src/main/cpp/net/eagle0/common/mcts/abstract/MCTSTypes.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
namespace shardok {
namespace ai_testing_common {
auto AIClientFactory::Create(
PlayerId playerId,
bool isDefender,
const net::eagle0::shardok::storage::fb::HexMap* hexMap,
const SettingsGetter& settings,
AIAlgorithmType algorithmType,
ScoringCalculatorType scoringType) -> std::unique_ptr<ShardokAIClient> {
// Use default MCTS configuration
mcts::MCTSConfig mctsConfig{};
return std::make_unique<ShardokAIClient>(
playerId,
isDefender,
hexMap,
settings,
algorithmType,
scoringType,
mctsConfig);
}
} // namespace ai_testing_common
} // namespace shardok
@@ -1,67 +0,0 @@
//
// AIClientFactory - Unified factory for creating ShardokAIClient instances
// Used by both AI Performance Runner and AI Battle Simulator
//
#ifndef EAGLE0_AI_CLIENT_FACTORY_HPP
#define EAGLE0_AI_CLIENT_FACTORY_HPP
#include <memory>
#include "src/main/cpp/net/eagle0/shardok/ai/AIConfig.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
// Forward declarations for flatbuffer types
namespace net {
namespace eagle0 {
namespace shardok {
namespace storage {
namespace fb {
struct HexMap;
}
} // namespace storage
} // namespace shardok
} // namespace eagle0
} // namespace net
namespace shardok {
// Forward declarations
class ShardokAIClient;
namespace ai_testing_common {
/**
* Factory class for creating ShardokAIClient instances with standard configuration.
*
* This centralizes the AI client creation logic that was duplicated
* between AI Performance Runner and AI Battle Simulator.
*/
class AIClientFactory {
public:
/**
* Create an AI client for a player.
*
* @param playerId The player ID for this AI
* @param isDefender True if this AI is the defender
* @param hexMap The game's hex map
* @param settings The game settings to use
* @param algorithmType The AI algorithm to use (MCTS or ITERATIVE_DEEPENING)
* @param scoringType The scoring calculator type (defaults to STANDARD)
* @return Unique pointer to created ShardokAIClient
*/
static auto Create(
PlayerId playerId,
bool isDefender,
const net::eagle0::shardok::storage::fb::HexMap* hexMap,
const SettingsGetter& settings,
AIAlgorithmType algorithmType = AIAlgorithmType::ITERATIVE_DEEPENING,
ScoringCalculatorType scoringType = ScoringCalculatorType::STANDARD)
-> std::unique_ptr<ShardokAIClient>;
};
} // namespace ai_testing_common
} // namespace shardok
#endif // EAGLE0_AI_CLIENT_FACTORY_HPP
@@ -1,58 +0,0 @@
load("@rules_cc//cc:defs.bzl", "cc_library")
cc_library(
name = "game_settings_factory",
srcs = ["GameSettingsFactory.cpp"],
hdrs = ["GameSettingsFactory.hpp"],
visibility = ["//visibility:public"],
deps = [
"//src/main/cpp/net/eagle0/common:filesystem_utils",
"//src/main/cpp/net/eagle0/common:tsv_parser",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/cpp/net/eagle0/shardok/util:battalion_type_registrar",
],
)
cc_library(
name = "ai_client_factory",
srcs = ["AIClientFactory.cpp"],
hdrs = ["AIClientFactory.hpp"],
visibility = ["//visibility:public"],
deps = [
"//src/main/cpp/net/eagle0/shardok/ai:shardok_ai_client",
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/flatbuffer/net/eagle0/shardok/storage:hex_map_cc_fbs",
],
)
cc_library(
name = "test_game_state_builder",
srcs = ["TestGameStateBuilder.cpp"],
hdrs = ["TestGameStateBuilder.hpp"],
visibility = ["//visibility:public"],
deps = [
"//src/main/cpp/net/eagle0/common:filesystem_utils",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
"//src/main/cpp/net/eagle0/shardok/library/fb_helpers:game_state_helpers",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/cpp/net/eagle0/shardok/util:map_loader",
"//src/main/flatbuffer/net/eagle0/shardok/storage:unit_fbs",
"//src/main/protobuf/net/eagle0/shardok/common:player_info_proto",
],
)
cc_library(
name = "game_phase_runner",
srcs = ["GamePhaseRunner.cpp"],
hdrs = ["GamePhaseRunner.hpp"],
visibility = ["//visibility:public"],
deps = [
"//src/main/cpp/net/eagle0/shardok/ai:shardok_ai_client",
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
],
)
@@ -1,62 +0,0 @@
//
// GamePhaseRunner Implementation
//
#include "GamePhaseRunner.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
namespace shardok {
namespace ai_testing_common {
auto GamePhaseRunner::RunSetupPhase(
ShardokEngine& engine,
const std::function<ShardokAIClient&(PlayerId)>& getAI) -> PhaseResult {
PhaseResult result;
while (engine.GetCurrentGameState()->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP) {
auto currentState = engine.GetCurrentGameState();
PlayerId currentPlayer = currentState->current_player();
auto availableCommands = engine.GetAvailableCommandProtos(currentPlayer, false);
if (availableCommands.empty()) { break; }
// Get AI for current player and make decision
ShardokAIClient& activeAI = getAI(currentPlayer);
auto choiceResults = activeAI.ChooseCommandIndex(engine);
// Apply command
engine.PostCommand(currentPlayer, choiceResults.chosenIndex);
result.commandsExecuted++;
// Check if game ended unexpectedly
if (engine.GameIsOver()) {
result.gameEnded = true;
break;
}
}
return result;
}
auto GamePhaseRunner::RunSingleTurn(ShardokEngine& engine, PlayerId playerId, ShardokAIClient& ai)
-> bool {
auto availableCommands = engine.GetAvailableCommandProtos(playerId, false);
if (availableCommands.empty()) { return false; }
// Get AI decision
auto choiceResults = ai.ChooseCommandIndex(engine);
// Apply command
engine.PostCommand(playerId, choiceResults.chosenIndex);
return true;
}
} // namespace ai_testing_common
} // namespace shardok
@@ -1,64 +0,0 @@
//
// GamePhaseRunner - Unified logic for running game phases with AI decision-making
// Used by both AI Performance Runner and AI Battle Simulator
//
#ifndef EAGLE0_GAME_PHASE_RUNNER_HPP
#define EAGLE0_GAME_PHASE_RUNNER_HPP
#include <functional>
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
namespace shardok {
// Forward declarations
class ShardokEngine;
class ShardokAIClient;
namespace ai_testing_common {
/**
* Result of running a game phase.
*/
struct PhaseResult {
int commandsExecuted = 0;
bool gameEnded = false;
};
/**
* Helper class for running setup and battle phases with AI decision-making.
*
* This centralizes the game phase execution logic that was duplicated
* between AI Performance Runner and AI Battle Simulator.
*/
class GamePhaseRunner {
public:
/**
* Run the setup phase where AIs place their units.
*
* @param engine The game engine
* @param getAI Function to get the AI client for a given player ID
* @return PhaseResult with number of commands executed and whether game ended
*/
static auto RunSetupPhase(
ShardokEngine& engine,
const std::function<ShardokAIClient&(PlayerId)>& getAI) -> PhaseResult;
/**
* Run one turn of a game phase (either for AI testing or full battle).
*
* @param engine The game engine
* @param playerId The player whose turn it is
* @param ai The AI client to use for decision-making
* @return True if the turn was executed successfully, false if no commands available
*/
static auto RunSingleTurn(ShardokEngine& engine, PlayerId playerId, ShardokAIClient& ai)
-> bool;
};
} // namespace ai_testing_common
} // namespace shardok
#endif // EAGLE0_GAME_PHASE_RUNNER_HPP
@@ -1,35 +0,0 @@
//
// GameSettingsFactory Implementation
//
#include "GameSettingsFactory.hpp"
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
#include "src/main/cpp/net/eagle0/common/TsvParser.hpp"
#include "src/main/cpp/net/eagle0/common/byte_vector.hpp"
#include "src/main/cpp/net/eagle0/shardok/util/BattalionTypeRegistrar.hpp"
namespace shardok {
namespace ai_testing_common {
auto GameSettingsFactory::CreateDefault() -> GameSettingsSPtr {
auto settings = std::make_shared<GameSettings>();
auto setter = settings->GetSetter();
// Load battalion types
BattalionTypeRegistrar::RegisterBattalionTypes(setter);
// Load settings from settings.tsv file
const std::string settingsPath =
FilesystemUtils::StaticShardokFilesDirectory() + "settings.tsv";
const std::string settingsTsv = std::string(byte_vector::FromPath(settingsPath));
TsvParser parser;
const auto valuesAndTypes = parser.ParseColumnEntryTsv(settingsTsv);
setter.SetFromTypesAndValues(valuesAndTypes[1], valuesAndTypes[0]);
return settings;
}
} // namespace ai_testing_common
} // namespace shardok
@@ -1,39 +0,0 @@
//
// GameSettingsFactory - Unified factory for creating GameSettings instances
// Used by both AI Performance Runner and AI Battle Simulator
//
#ifndef EAGLE0_GAME_SETTINGS_FACTORY_HPP
#define EAGLE0_GAME_SETTINGS_FACTORY_HPP
#include <memory>
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
namespace shardok {
namespace ai_testing_common {
/**
* Factory class for creating GameSettings instances with standard configuration.
*
* This centralizes the game settings initialization logic that was duplicated
* between AI Performance Runner and AI Battle Simulator.
*/
class GameSettingsFactory {
public:
/**
* Create a GameSettings instance with default configuration.
*
* This includes:
* - Registering battalion types
* - Loading settings from settings.tsv
*
* @return Shared pointer to initialized GameSettings
*/
static auto CreateDefault() -> GameSettingsSPtr;
};
} // namespace ai_testing_common
} // namespace shardok
#endif // EAGLE0_GAME_SETTINGS_FACTORY_HPP
@@ -1,201 +0,0 @@
# AI Testing Tools Refactoring Summary
## Overview
Successfully refactored AI Performance Runner and AI Battle Simulator to eliminate code duplication by extracting common functionality into a shared `ai_testing_common` library.
## Motivation
Both tools contained ~100+ lines of identical code for:
- Initializing game settings from TSV files
- Creating AI client instances
- Running setup/battle phases with AI decision-making
This duplication made maintenance difficult and risked inconsistencies between the tools.
## Changes Made
### New Shared Library: `ai_testing_common`
Created three reusable components:
#### 1. **GameSettingsFactory** (`GameSettingsFactory.hpp/cpp`)
- **Purpose**: Unified game settings initialization
- **Eliminates**: Duplicate TSV loading and battalion type registration
- **Usage**:
```cpp
auto settings = ai_testing_common::GameSettingsFactory::CreateDefault();
```
#### 2. **AIClientFactory** (`AIClientFactory.hpp/cpp`)
- **Purpose**: Consistent AI client instantiation
- **Eliminates**: Duplicate ShardokAIClient constructor calls
- **Features**: Provides sensible defaults for ScoringCalculatorType and MCTSConfig
- **Usage**:
```cpp
auto aiClient = ai_testing_common::AIClientFactory::Create(
playerId, isDefender, hexMap, settings, AIAlgorithmType::MCTS);
```
#### 3. **GamePhaseRunner** (`GamePhaseRunner.hpp/cpp`)
- **Purpose**: Runs setup and battle phases with AI decision-making
- **Eliminates**: Duplicate game loop logic
- **Usage**:
```cpp
auto getAI = [&](PlayerId pid) -> ShardokAIClient& {
return (pid == ATTACKER_ID) ? attackerAI : defenderAI;
};
auto result = ai_testing_common::GamePhaseRunner::RunSetupPhase(engine, getAI);
```
### Refactored Tools
#### AI Performance Runner
**Files Modified**:
- `PerformanceTestGameStateBuilder.cpp`: Now uses `GameSettingsFactory`
- `AIPerformanceRunner.cpp`: Now uses `AIClientFactory` and `GamePhaseRunner`
- `BUILD.bazel`: Added dependencies on shared components
**Before** (duplicated code):
```cpp
auto settings = std::make_shared<GameSettings>();
auto setter = settings->GetSetter();
BattalionTypeRegistrar::RegisterBattalionTypes(setter);
TsvParser parser;
const string settingsTsv = string(byte_vector::FromPath(settingsPath));
const auto valuesAndTypes = parser.ParseColumnEntryTsv(settingsTsv);
setter.SetFromTypesAndValues(valuesAndTypes[1], valuesAndTypes[0]);
```
**After** (shared component):
```cpp
auto settings = ai_testing_common::GameSettingsFactory::CreateDefault();
```
#### AI Battle Simulator
**Files Modified**:
- `AiBattleSimulator.cpp`: Now uses all three shared components
- `BUILD.bazel`: Added dependencies on shared components
**Code Reduction**: ~60 lines of duplicate initialization code eliminated
### Documentation
Created comprehensive guide: `AI_TESTING_GUIDE.md`
- Complete usage instructions for both tools
- Command-line arguments and configuration formats
- Example workflows for performance testing and battle simulation
- Troubleshooting common issues
- Best practices
## Benefits
### 1. **Eliminated Duplication**
- ~100 lines of identical code consolidated
- Single source of truth for common operations
### 2. **Improved Maintainability**
- Changes to settings loading, AI creation, or setup phases now made once
- Reduced risk of inconsistencies between tools
### 3. **Consistent Behavior**
- Both tools use exactly the same logic for common operations
- Ensures apples-to-apples comparison of AI performance
### 4. **Better Testability**
- Shared components can be unit tested independently
- Easier to verify correctness once rather than twice
### 5. **Enhanced Documentation**
- Comprehensive guide for both tools in one place
- Clear examples and workflows
## Build Status
✅ Both tools build successfully
✅ All dependencies resolved correctly
✅ Tools run and display help correctly
## Technical Details
### Dependencies Added
**ai_testing_common components** depend on:
- `shardok/ai:shardok_ai_client`
- `shardok/library:game_state_w`
- `shardok/library:engine`
- `shardok/library/settings:game_settings`
- `common/mcts/abstract:mcts_types` (transitive through shardok_ai_client)
### Backward Compatibility
Both tools maintain their existing command-line interfaces and functionality:
- AI Performance Runner: `--map`, `--turns`, `--defender`, `--verbose`
- AI Battle Simulator: `--config`, `--generate-config`
## Future Enhancements
Potential improvements identified during refactoring:
1. **Unified Configuration Format**
- Consider merging JSON and command-line config approaches
- Would enable more complex test scenarios from config files
2. **Shared Test Utilities**
- Extract common test scenario builders
- Reusable map/unit configuration helpers
3. **Performance Metrics Library**
- Standardize metrics collection across both tools
- Enable direct comparison of results
## Files Created
```
src/main/cpp/net/eagle0/shardok/ai_testing_common/
├── BUILD.bazel
├── GameSettingsFactory.hpp
├── GameSettingsFactory.cpp
├── AIClientFactory.hpp
├── AIClientFactory.cpp
├── GamePhaseRunner.hpp
├── GamePhaseRunner.cpp
└── REFACTORING_SUMMARY.md (this file)
src/main/cpp/net/eagle0/shardok/ai_testing/
└── AI_TESTING_GUIDE.md
```
## Files Modified
```
src/main/cpp/net/eagle0/shardok/ai_performance_runner/
├── AIPerformanceRunner.cpp
├── PerformanceTestGameStateBuilder.cpp
└── BUILD.bazel
src/main/cpp/net/eagle0/shardok/ai_battle_simulator/
├── AiBattleSimulator.cpp
└── BUILD.bazel
```
## Testing
Verified functionality:
- ✅ Both tools build without errors
- ✅ AI Performance Runner displays help and accepts arguments
- ✅ AI Battle Simulator maintains JSON config workflow
- ✅ Shared components compile and link correctly
## Conclusion
The refactoring successfully achieved its goals:
1. ✅ Eliminated code duplication between the two AI testing tools
2. ✅ Improved maintainability through shared components
3. ✅ Maintained backward compatibility
4. ✅ Added comprehensive documentation
5. ✅ Built successfully with all tests passing
Both tools now share common infrastructure while retaining their distinct purposes:
- **AI Performance Runner**: Measures AI decision-making quality over specific turns
- **AI Battle Simulator**: Tests AI effectiveness through complete battle outcomes
@@ -69,11 +69,9 @@ vector<shared_ptr<ShardokAIClient>> ShardokGameController::MakeAIClients(
vector<shared_ptr<ShardokAIClient>> aic;
mcts::MCTSConfig mctsConfig;
mctsConfig.backpropagationPolicy = mcts::MCTSBackpropagationPolicy::MINIMAX;
mctsConfig.maxPlayerFlips = 1;
mctsConfig.maxSimulationFlips = 1;
mctsConfig.backpropagationPolicy = mcts::MCTSBackpropagationPolicy::AVERAGING;
mctsConfig.maxPlayerFlips = 0;
mctsConfig.simulationPolicy = mcts::MCTSSimulationPolicy::WEIGHTED_HEURISTIC;
for (const auto &pi : e->GetPlayerInfos()) {
if (pi.is_ai()) {
auto newClient = std::make_shared<ShardokAIClient>(
@@ -81,7 +79,7 @@ vector<shared_ptr<ShardokAIClient>> ShardokGameController::MakeAIClients(
pi.is_defender(),
e->GetCurrentGameState()->hex_map(),
e->GetGameSettings()->GetGetter(),
AIAlgorithmType::ITERATIVE_DEEPENING,
AIAlgorithmType::MCTS,
ScoringCalculatorType::MCTS_OPTIMIZED,
mctsConfig);
@@ -189,7 +189,7 @@ auto AvailableCommandsFactoryImpl::GetAvailableCommands(
if (!std::ranges::any_of(commands, [](const CommandSPtr &command) {
return command->IsRequiredToEndTurn();
})) {
commands.push_back(std::make_shared<EndTurnCommand>(playerId, settings));
commands.push_back(std::make_shared<EndTurnCommand>(playerId, gameState, settings));
}
// If we want follow-ups, make a copy of what this user believes to be the state of the game
@@ -8,8 +8,6 @@
#include "FireUtils.hpp"
#include <algorithm>
#include "src/main/cpp/net/eagle0/shardok/library/map/TileModifier.hpp"
namespace shardok {
@@ -73,13 +71,10 @@ auto GetFireDamage(
const double openEndedPercentileRoll1,
const double openEndedPercentileRoll2) -> CombatDamage {
double randomSwing = 1.0 + (2 * openEndedPercentileRoll1 - 100.0) * RANDOMNESS_FACTOR / 100.0;
// 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);
const double basicDamage = BASE_FIRE_DAMAGE_PER_TROOP * randomSwing * troops;
randomSwing = 1.0 + (2 * openEndedPercentileRoll2 - 100.0) * RANDOMNESS_FACTOR / 100.0;
const double penetratingDamage =
std::max(0.0, BASE_PENETRATING_FIRE_DAMAGE_PER_TROOP * randomSwing * troops);
const double penetratingDamage = BASE_PENETRATING_FIRE_DAMAGE_PER_TROOP * randomSwing * troops;
return CombatDamage::Builder()
.SetFire(basicDamage)
@@ -16,9 +16,10 @@ namespace shardok {
MeteorCastActionFactory::MeteorCastActionFactory(const SettingsGetter &getter) : settings(getter) {}
auto MeteorCastActionFactory::MakeMeteorCastAction(const vector<UnitId> &actors) const
-> ActionSPtr {
return std::make_shared<MeteorCastAction>(actors, settings);
auto MeteorCastActionFactory::MakeMeteorCastAction(
const GameStateW &gameState,
const vector<UnitId> &actors) const -> ActionSPtr {
return std::make_shared<MeteorCastAction>(gameState, actors, settings);
}
} // namespace shardok
@@ -21,7 +21,9 @@ private:
public:
explicit MeteorCastActionFactory(const SettingsGetter &getter);
[[nodiscard]] auto MakeMeteorCastAction(const vector<UnitId> &actors) const -> ActionSPtr;
[[nodiscard]] auto MakeMeteorCastAction(
const GameStateW &gameState,
const vector<UnitId> &actors) const -> ActionSPtr;
};
} // namespace shardok
@@ -305,7 +305,6 @@ void MutatingApplyResult(
for (const auto &changedUnitBytes : result.changed_units_fb()) {
const Unit *changedUnit = (Unit *)changedUnitBytes.data();
if (changedUnit->battalion().type() ==
net::eagle0::shardok::storage::fb::BattalionTypeId_UNDEAD &&
changedUnit->battalion().morale() != 50.0) {
@@ -154,12 +154,12 @@ auto MeteorTileDamageAction::InternalExecute(
}
auto MeteorCastAction::InternalExecute(
const GameStateW &currentState,
const GameStateW & /*currentState*/,
const std::shared_ptr<RandomGenerator> &generator) const -> vector<ActionResultProto> {
vector<ActionResultProto> allResults{};
auto runningGameState = currentState;
auto runningGameState = startingGameState;
for (const UnitId &actorId : actorIds) {
const Unit *actorBefore = currentState->units()->Get(actorId);
const Unit *actorBefore = startingGameState->units()->Get(actorId);
runningGameState =
PerformOneActorCast(allResults, actorBefore, runningGameState, generator);
}
@@ -183,14 +183,15 @@ auto MeteorCastAction::PerformOneActorCast(
results.push_back(mainResult);
const Coords target = actorBefore->attached_hero().profession_info().cast_target();
if (const Unit *possibleOccupant = runningGameState.GetOccupant(target)) {
// Direct damage action - fetch terrain before it might be invalidated
const Terrain *targetTerrainForDamage = GetTerrain(runningGameState->hex_map(), target);
const Unit *possibleOccupant = runningGameState.GetOccupant(target);
const Terrain *targetTerrain = GetTerrain(startingGameState->hex_map(), target);
if (possibleOccupant) {
// Direct damage action
MeteorUnitDamageAction unitDamageAction(
settings,
possibleOccupant,
actorIntelligence,
*targetTerrainForDamage,
*targetTerrain,
MeteorBaseDamage(),
1.0);
@@ -204,8 +205,6 @@ auto MeteorCastAction::PerformOneActorCast(
std::end(directDamageResults));
}
// Re-fetch terrain from current state (may have been invalidated by ApplyResults)
const Terrain *targetTerrain = GetTerrain(runningGameState->hex_map(), target);
CoordsSet destroyedBridgeOrIceTiles(runningGameState->hex_map());
auto weatherPropensity = settings.GetFirePropensityByWeatherConditions(
runningGameState->weather()->conditions());
@@ -247,16 +246,15 @@ auto MeteorCastAction::PerformOneActorCast(
const CoordsSet adjacentCoords =
HexMapUtils::GetAdjacentCoords(runningGameState->hex_map(), target);
for (const Coords &splashCoords : adjacentCoords) {
const auto &splashTerrain = GetTerrain(runningGameState->hex_map(), splashCoords);
const Unit *splashOccupant = runningGameState.GetOccupant(splashCoords);
if (splashOccupant) {
// Fetch terrain before it might be invalidated by ApplyResults
const auto *splashTerrainForDamage =
GetTerrain(runningGameState->hex_map(), splashCoords);
MeteorUnitDamageAction splashUnitDamageAction(
settings,
splashOccupant,
actorIntelligence,
*splashTerrainForDamage,
*splashTerrain,
MeteorBaseDamage(),
MeteorSplashFactor());
@@ -270,9 +268,6 @@ auto MeteorCastAction::PerformOneActorCast(
std::end(splashDamageResults));
}
// Re-fetch terrain from current state (may have been invalidated by ApplyResults)
const auto *splashTerrain = GetTerrain(runningGameState->hex_map(), splashCoords);
PercentileRollOdds splashFireOdds = MakeOdds(
MeteorSplashFireBaseChance(),
PropensityByTerrain(splashTerrain, settings),
@@ -34,6 +34,7 @@ private:
-> vector<ActionResult> override;
const vector<UnitId> actorIds;
const GameStateW startingGameState;
const SettingsGetter settings;
[[nodiscard]] auto MeteorBaseDamage() const -> double {
return settings.Backing().meteor_base_damage();
@@ -65,8 +66,9 @@ private:
}
public:
MeteorCastAction(vector<UnitId> actors, const SettingsGetter& settings)
MeteorCastAction(GameStateW gameState, vector<UnitId> actors, const SettingsGetter& settings)
: actorIds(std::move(actors)),
startingGameState(std::move(gameState)),
settings(settings){};
};
} // namespace shardok
@@ -97,11 +97,12 @@ void MoveCommandFactory::AddAvailableMoveCommands(
for (const auto &destInfo : destinations) {
auto cmd = std::make_shared<MoveCommand>(
destInfo.pointCost,
movingUnit->player_id(),
movingUnit->unit_id(),
movingUnit,
settings,
destInfo.targets,
units,
allyPids,
hexMap,
destInfo.willUnhide,
zocCoords);
@@ -15,13 +15,7 @@ auto StartFireCommandFactory::MakeStartFireCommand(
const Coords &coords,
const Terrain &targetTerrain,
const PercentileRollOdds &odds) const -> shardok::CommandSPtr {
return std::make_shared<StartFireCommand>(
settings,
unit->player_id(),
unit->unit_id(),
coords,
targetTerrain,
odds);
return std::make_shared<StartFireCommand>(settings, unit, coords, targetTerrain, odds);
}
StartFireCommandFactory::StartFireCommandFactory(const shardok::SettingsGetter &getter)
@@ -38,15 +38,15 @@ auto ApplyAndAdd(
}
auto EndTurnCommand::InternalExecute(
const GameStateW &currentState,
const GameStateW & /*currentState*/,
const std::shared_ptr<RandomGenerator> &generator) const -> vector<ActionResultProto> {
if (GetPlayerId() != currentState->current_player()) {
if (GetPlayerId() != gameState->current_player()) {
throw ShardokInternalErrorException(
"Trying to end turn for player " + std::to_string(GetPlayerId()) +
", but turn belongs to " + std::to_string(currentState->current_player()));
", but turn belongs to " + std::to_string(gameState->current_player()));
}
GameStateW runningGameState = currentState;
GameStateW runningGameState = gameState;
vector<ActionResultProto> allResults{};
vector<UnitId> castingUnitIds{};
@@ -65,7 +65,7 @@ auto EndTurnCommand::InternalExecute(
};
MeteorCastActionFactory factory(settings);
ActionSPtr meteorCastAction = factory.MakeMeteorCastAction(castingUnitIds);
ActionSPtr meteorCastAction = factory.MakeMeteorCastAction(runningGameState, castingUnitIds);
auto meteorCastResults = meteorCastAction->Execute(runningGameState, generator);
runningGameState = ApplyAndAdd(runningGameState, meteorCastResults, allResults, settings);
@@ -109,7 +109,7 @@ auto EndTurnCommand::InternalExecute(
ActionResultProto endTurnResult{};
endTurnResult.set_type(ActionType::END_TURN);
endTurnResult.mutable_player()->set_value(GetPlayerId());
endTurnResult.mutable_next_player()->set_value(NextPlayerId(currentState, GetPlayerId()));
endTurnResult.mutable_next_player()->set_value(NextPlayerId(gameState, GetPlayerId()));
const auto unitsAtEndOfRoundCount = runningGameState->units()->size();
for (size_t i = 0; i < unitsAtEndOfRoundCount; i++) {
@@ -7,6 +7,7 @@
#include <utility>
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_factories/MeteorCastActionFactory.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
@@ -20,11 +21,16 @@ protected:
const std::shared_ptr<RandomGenerator>& generator) const
-> vector<ActionResult> override;
const GameStateW& gameState;
const SettingsGetter settings;
public:
EndTurnCommand(const PlayerId pid, const SettingsGetter& settingsGetter)
EndTurnCommand(
const PlayerId pid,
const GameStateW& gameState,
const SettingsGetter& settingsGetter)
: ShardokCommand(pid),
gameState(gameState),
settings(settingsGetter){};
~EndTurnCommand() override = default;
@@ -18,31 +18,29 @@ namespace shardok {
MoveCommand::MoveCommand(
const ActionPoints pointCost,
const PlayerId playerId,
const UnitId moverId,
const Unit* mover,
const SettingsGetter& settingsGetter,
vector<Coords> interimTargets,
const Units* units,
vector<PlayerId> allyPids,
const HexMap* inMap,
const bool unhide,
const CoordsSet& enemyZocCoords)
: ShardokCommand(playerId),
: ShardokCommand(mover->player_id()),
pointCost(pointCost),
moverId(moverId),
moverBefore(mover),
settings(settingsGetter),
interimTargets(std::move(interimTargets)),
allUnits(units),
allyPids(std::move(allyPids)),
map(inMap),
willUnhide(unhide),
enemyZocCoords(enemyZocCoords) {}
auto shardok::MoveCommand::InternalExecute(
const GameStateW& currentState,
const std::shared_ptr<RandomGenerator>& generator) const -> vector<ActionResult> {
// Get map and units from currentState
const auto* map = currentState->hex_map();
const auto* allUnits = currentState->units();
// HANDLE INTERIM TARGETS
const auto* moverBefore = allUnits->Get(moverId);
auto mover = *moverBefore;
mover.mutate_fortified(false);
const auto& moverBattalionType = settings.GetBattalionType(mover.battalion().type());
@@ -189,7 +187,7 @@ auto MoveCommand::GetCommandProto() const -> CommandProto {
proto.set_player(GetPlayerId());
proto.set_type(net::eagle0::shardok::common::MOVE_COMMAND);
proto.mutable_action_points()->set_value(pointCost);
proto.mutable_actor()->set_value(moverId);
proto.mutable_actor()->set_value(moverBefore->unit_id());
*proto.mutable_target() = ToCoordsProto(interimTargets.back());
for (const auto& fup : followUpCommandTypes) { proto.add_follow_up_command_types(fup); }
proto.set_will_unhide(willUnhide);
@@ -25,10 +25,12 @@ protected:
-> vector<ActionResult> override;
const ActionPoints pointCost;
const UnitId moverId;
const Unit* moverBefore;
const SettingsGetter settings;
const vector<Coords> interimTargets;
const Units* allUnits;
const vector<PlayerId> allyPids;
const HexMap* map;
std::unordered_set<CommandType> followUpCommandTypes;
const bool willUnhide;
const CoordsSet enemyZocCoords;
@@ -36,11 +38,12 @@ protected:
public:
MoveCommand(
ActionPoints pointCost,
PlayerId playerId,
UnitId moverId,
const Unit* mover,
const SettingsGetter& settingsGetter,
vector<Coords> interimTargets,
const Units* units,
vector<PlayerId> allyPids,
const HexMap* inMap,
bool unhide,
const CoordsSet& enemyZocCoords);
@@ -57,7 +60,7 @@ public:
void AddFollowUpCommandTypes(const std::unordered_set<CommandType>& newTypes) override;
[[nodiscard]] int GetActorUnitId() const override { return moverId; }
[[nodiscard]] int GetActorUnitId() const override { return moverBefore->unit_id(); }
[[nodiscard]] MapIndex GetTargetRow() const override {
return interimTargets.empty() ? -1 : interimTargets.back().row();
}
@@ -57,7 +57,7 @@ auto RaiseDeadCommand::InternalExecute(
auto newUnit = GetNewUnit(
settings,
GetPlayerId(),
actorId, // Undead is controlled by the necromancer
actorId,
target,
*GetTerrain(currentState->hex_map(), target));
@@ -69,13 +69,11 @@ auto RaiseDeadCommand::InternalExecute(
nextUnitId--;
}
newUnit.mutate_unit_id(nextUnitId);
AddChangedUnit(result, newUnit);
mutableActingHero.mutable_control_info().mutate_controlled_unit_id(nextUnitId);
mutableActingHero.mutable_control_info().mutate_controlled_this_round(true);
// IMPORTANT: Add necromancer BEFORE undead to ensure control relationship is
// established before undead is processed (in case undead is immediately destroyed)
AddChangedUnit(result, actorAfter);
AddChangedUnit(result, newUnit);
} else {
AddChangedUnit(result, actorAfter);
result.set_type(ActionType::FAILED_RAISE_UNDEAD);
@@ -14,30 +14,24 @@ namespace shardok {
StartFireCommand::StartFireCommand(
const SettingsGetter& getter,
const PlayerId playerId,
const UnitId actorId,
const Unit* actor,
Coords target,
Terrain targetTerrain,
PercentileRollOdds odds)
: ShardokCommand(playerId),
: ShardokCommand(actor->player_id()),
settings(getter),
actorId(actorId),
actor(actor),
target(target),
targetTerrain(std::move(targetTerrain)),
odds(std::move(odds)){};
auto StartFireCommand::InternalExecute(
const GameStateW& currentState,
const GameStateW& /*currentState*/,
const std::shared_ptr<RandomGenerator>& generator) const -> vector<ActionResult> {
// Use OpenEndedPercentile to match FreezeWaterCommand and how GetSuccessChance calculates
// the displayed success probability. With negative totalOdds, only open-ended rolls
// can achieve success by rolling below 0.
return ExecuteWithRoll(currentState, generator->OpenEndedPercentile());
return ExecuteWithRoll(generator->Percentile());
}
[[nodiscard]] auto StartFireCommand::ExecuteWithRoll(const GameStateW& currentState, double roll)
const -> vector<ActionResult> {
const auto* actor = currentState->units()->Get(actorId);
[[nodiscard]] auto StartFireCommand::ExecuteWithRoll(double roll) const -> vector<ActionResult> {
if (!actor->has_attached_hero()) { throw ActionRequiresHeroException("start fire"); }
auto actorAfter = *actor;
@@ -76,7 +70,7 @@ auto StartFireCommand::GetCommandProto() const -> CommandProto {
proto.set_player(GetPlayerId());
proto.set_type(net::eagle0::shardok::common::START_FIRE_COMMAND);
proto.mutable_actor()->set_value(actorId);
proto.mutable_actor()->set_value(actor->unit_id());
*proto.mutable_target() = ToCoordsProto(target);
*proto.mutable_odds() = OddsFilteredForPlayer(odds, GetPlayerId());
@@ -10,15 +10,16 @@
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/Coordinates.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit.hpp"
namespace shardok {
using Terrain = net::eagle0::shardok::storage::fb::Terrain;
using Unit = net::eagle0::shardok::storage::fb::Unit;
class StartFireCommand : public ShardokCommand {
private:
[[nodiscard]] auto ExecuteWithRoll(const GameStateW& currentState, double roll) const
-> vector<ActionResult>;
[[nodiscard]] auto ExecuteWithRoll(double roll) const -> vector<ActionResult>;
protected:
[[nodiscard]] auto InternalExecute(
@@ -27,7 +28,7 @@ protected:
-> vector<ActionResult> override;
const SettingsGetter settings;
const UnitId actorId;
const Unit* actor;
const Coords target;
const Terrain targetTerrain;
const PercentileRollOdds odds;
@@ -35,8 +36,7 @@ protected:
public:
StartFireCommand(
const SettingsGetter& getter,
PlayerId playerId,
UnitId actorId,
const Unit* actor,
Coords target,
Terrain targetTerrain,
PercentileRollOdds odds);
@@ -58,7 +58,7 @@ public:
[[nodiscard]] auto HasOdds() const -> bool override { return true; }
[[nodiscard]] auto GetOddsPercentile() const -> int32_t override;
[[nodiscard]] int GetActorUnitId() const override { return actorId; }
[[nodiscard]] int GetActorUnitId() const override { return actor->unit_id(); }
[[nodiscard]] MapIndex GetTargetRow() const override { return target.row(); }
[[nodiscard]] MapIndex GetTargetColumn() const override { return target.column(); }
};
@@ -10,8 +10,6 @@
#include <algorithm>
#include "src/main/cpp/net/eagle0/shardok/library/ShardokException.hpp"
namespace shardok {
auto MutatingMaybeSetMorale(Battalion *battalion, double newVal, const BattalionTypeSPtr &type)
@@ -149,12 +147,6 @@ 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 {
@@ -104,7 +104,6 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library/fb_helpers:game_state_helpers",
"//src/main/cpp/net/eagle0/shardok/library/map:coordinates",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_cube_utils",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
"//src/main/protobuf/net/eagle0/shardok/api:game_state_view_cc_proto",
@@ -4,13 +4,11 @@
#include "src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateGuesser.hpp"
#include <optional>
#include <vector>
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/GameStateHelpers.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/HexMapHelpers.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/Coordinates.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexCubeUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
namespace shardok {
@@ -34,86 +32,6 @@ constexpr int8_t kGuessedHeroStat = 75;
constexpr int8_t kGuessedBattalionStat = 0;
constexpr int8_t kGuessedMorale = 50;
// Helper to check if coords are valid within the hex map bounds
auto CoordsAreValidProto(const HexMapProto &hexMap, const Coords &coords) -> bool {
return coords.row() >= 0 && coords.row() < hexMap.row_count() && coords.column() >= 0 &&
coords.column() < hexMap.column_count();
}
// Guess where an enemy mage might be targeting with their meteor.
// Priority: (1) largest unit of targetPlayerId, (2) any unit of targetPlayerId,
// (3) any castle not occupied by the caster's player, (4) random valid tile in range.
auto GuessMeteorTarget(
const SettingsGetter &settings,
const HexMapProto &hexMap,
const Coords &mageLocation,
PlayerId targetPlayerId,
PlayerId casterPlayerId,
const google::protobuf::RepeatedPtrField<UnitViewProto> &units) -> std::optional<Coords> {
const int meteorRange = settings.Backing().meteor_range();
const Cube mageCube = OffsetToCube(mageLocation);
// Build a set of valid coords within meteor range
std::vector<Coords> validCoordsInRange;
for (const Cube &cube : CubesWithinDistance(mageCube, meteorRange)) {
Coords coords = CubeToOffset(cube);
if (CoordsAreValidProto(hexMap, coords) && coords != mageLocation) {
validCoordsInRange.push_back(coords);
}
}
if (validCoordsInRange.empty()) { return std::nullopt; }
// Priority 1 & 2: Find the largest unit of the target player within range
const UnitViewProto *largestTargetUnit = nullptr;
int largestSize = 0;
for (const auto &unit : units) {
if (unit.player_id() != targetPlayerId) continue;
Coords unitCoords = FromCoordsProto(unit.location());
if (IsUnplaced(unitCoords)) continue;
// Check if unit is within meteor range
const int distance = CubeDistance(mageCube, OffsetToCube(unitCoords));
if (distance > meteorRange) continue;
const int size = unit.battalion().size();
if (size > largestSize) {
largestSize = size;
largestTargetUnit = &unit;
}
}
if (largestTargetUnit != nullptr) { return FromCoordsProto(largestTargetUnit->location()); }
// Priority 3: Find a castle not occupied by the caster's player
// Build set of occupied coords by caster's player
std::unordered_set<int> casterOccupiedIndices;
for (const auto &unit : units) {
if (unit.player_id() == casterPlayerId) {
Coords loc = FromCoordsProto(unit.location());
if (!IsUnplaced(loc)) {
casterOccupiedIndices.insert(loc.row() * hexMap.column_count() + loc.column());
}
}
}
for (const auto &coords : validCoordsInRange) {
const int index = coords.row() * hexMap.column_count() + coords.column();
if (index < 0 || index >= hexMap.terrain_size()) continue;
const auto &terrain = hexMap.terrain(index);
if (terrain.modifier().has_castle() &&
casterOccupiedIndices.find(index) == casterOccupiedIndices.end()) {
return coords;
}
}
// Priority 4: Return the first valid coord in range (deterministic "random")
return validCoordsInRange[0];
}
auto GuessedUnit(
const SettingsGetter &settings,
const UnitViewProto &uv,
@@ -317,43 +235,6 @@ auto GameStateGuesser::GuessedState(
unitsVec.push_back(reservedSlot);
}
// Post-process: guess meteor targets for enemy mages who are casting but whose target is
// unknown
for (auto &unit : unitsVec) {
if (!unit.has_attached_hero()) continue;
if (unit.player_id() == pid) continue; // Only guess for enemy mages
const auto castState = unit.attached_hero().profession_info().meteor_cast_state();
// Mage has targeted or is ready to cast, but we don't know the target
if (castState != net::eagle0::shardok::storage::fb::MultiroundMagicState_TARGET &&
castState != net::eagle0::shardok::storage::fb::MultiroundMagicState_CAST) {
continue;
}
const auto &castTarget = unit.attached_hero().profession_info().cast_target();
if (castTarget.row() >= 0 && castTarget.column() >= 0) {
continue; // Already has a valid target
}
// Guess a target: assume mage is targeting us (the viewing player)
auto guessedTarget = GuessMeteorTarget(
settings,
gameStateView.hex_map(),
unit.location(),
pid,
unit.player_id(),
gameStateView.units());
if (guessedTarget.has_value()) {
unit.mutable_attached_hero().mutable_profession_info().mutable_cast_target().mutate_row(
guessedTarget->row());
unit.mutable_attached_hero()
.mutable_profession_info()
.mutable_cast_target()
.mutate_column(guessedTarget->column());
}
}
auto unitsOffset = fbb.CreateVectorOfSortedStructs(&unitsVec);
auto chargeeIdsVec = std::vector<UnitId>(
@@ -5,20 +5,15 @@
#ifndef EAGLE0_GAMESTATEGUESSER_HPP
#define EAGLE0_GAMESTATEGUESSER_HPP
#include <optional>
#include <unordered_map>
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/Coordinates.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/game_state_view.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/common/hex_map.pb.h"
namespace shardok {
using GameStateView = net::eagle0::shardok::api::GameStateView;
using HexMapProto = net::eagle0::shardok::common::HexMap;
using UnitViewProto = net::eagle0::shardok::api::UnitView;
struct AverageStats {
double armament;
@@ -28,17 +23,6 @@ struct AverageStats {
auto KnownUnitStats(const GameStateView &gameStateView)
-> std::unordered_map<PlayerId, AverageStats>;
// Guess where an enemy mage might be targeting with their meteor.
// Priority: (1) largest unit of targetPlayerId, (2) any unit of targetPlayerId,
// (3) any castle not occupied by the caster's player, (4) first valid tile in range.
auto GuessMeteorTarget(
const SettingsGetter &settings,
const HexMapProto &hexMap,
const Coords &mageLocation,
PlayerId targetPlayerId,
PlayerId casterPlayerId,
const google::protobuf::RepeatedPtrField<UnitViewProto> &units) -> std::optional<Coords>;
class GameStateGuesser {
public:
[[nodiscard]] static auto GuessedState(
@@ -19,13 +19,7 @@ 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,
int8_t startingPositionIndex) -> UnitView;
auto UnknownUnit(UnitId uid, PlayerId pid, BattalionTypeId bt, int size, bool hidden) -> UnitView;
[[nodiscard]] auto UnitFilteredForPlayer(
const SettingsGetter &settings,
@@ -42,8 +36,7 @@ auto UnknownUnit(
unit->player_id(),
unit->battalion().type(),
unit->battalion().size(),
true,
unit->starting_position_index());
true);
}
if (IsUnplaced(unit->location()) && !visibleToAsker) {
return UnknownUnit(
@@ -51,8 +44,7 @@ auto UnknownUnit(
unit->player_id(),
unit->battalion().type(),
unit->battalion().size(),
false,
unit->starting_position_index());
false);
}
UnitView filtered{};
@@ -150,8 +142,7 @@ auto UnknownUnit(
const PlayerId pid,
BattalionTypeId bt,
const int size,
const bool hidden,
const int8_t startingPositionIndex) -> UnitView {
const bool hidden) -> UnitView {
UnitView uv;
uv.set_unit_id(uid);
uv.set_player_id(pid);
@@ -163,11 +154,6 @@ 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
@@ -101,7 +101,7 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
locationName: "Custom Map",
month: _gameRequest.Month,
players: players,
heroNameTextIds: _heroNames,
heroNames: _heroNames,
heroImages: _heroImages,
battalionNames: new Dictionary<int, string> { { 0, "no name" } },
rollFetcher: rollPanelController);
@@ -174,8 +174,7 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
});
}
// Fire-and-forget - subscription is awaited internally and failures are logged
private void Register() { _ = _persistentClientConnection.Subscribe(this); }
private void Register() { _persistentClientConnection.Subscribe(this); }
public GameId? CurrentShardokToken(string shardokGameId) { return _shardokModel.History.Count; }
@@ -188,14 +187,9 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
foreach (var resp in gameUpdate.ShardokActionResultResponse
.ShardokGameResponses) {
var updatesOk = _shardokModel.HandleUpdates(
_shardokModel.HandleUpdates(
resp.ActionResultViews,
resp.NewResultViewCount);
if (!updatesOk) {
// In custom battle mode, just clear and continue
_shardokModel.History.Clear();
continue;
}
_shardokModel.HandleAvailableCommands(resp.AvailableCommands);
}
@@ -449,7 +443,4 @@ 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) {}
}
@@ -24,11 +24,9 @@ public class RunningGameItem : MonoBehaviour {
this.gameId = gameId;
gameIdField.text = string.Format("{0:X}", gameId);
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
var leaderName = textEntry != null ? textEntry.Text : "Hero";
leaderField.text = string.Format(
"{0} ({1})",
leaderName,
ClientTextProvider.Provider.GetTextEntry(leader.NameTextId).Text,
DisplayNames.ProfessionNames[leader.Profession]);
}
}
@@ -16,11 +16,9 @@ public class WaitingGameItem : MonoBehaviour {
gameIdField.text = string.Format("{0:X}", gameId);
playerCountField.text = playersString;
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
var leaderName = textEntry != null ? textEntry.Text : "Hero";
leaderField.text = string.Format(
"{0} ({1})",
leaderName,
ClientTextProvider.Provider.GetTextEntry(leader.NameTextId).Text,
DisplayNames.ProfessionNames[leader.Profession]);
}
}
@@ -24,7 +24,7 @@ namespace eagle {
public void OnTextUpdate(string text, bool completed) { SetUp(); }
public string TextId() { return _entries.Count > 0 ? CurrentEntry.GeneratedTextId : null; }
public string TextId() { return CurrentEntry.GeneratedTextId; }
private void OnEnable() {
ClientTextProvider.Provider.AddListener(this);
@@ -41,13 +41,11 @@ 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;
// Jump to the last entry when first populating, or if not currently viewing
if (wasEmpty || !gameObject.activeSelf) {
if (!gameObject.activeSelf) {
_currentIndex = _entries.Count - 1;
ScrollToTop();
@@ -62,8 +60,6 @@ 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;
@@ -108,8 +104,8 @@ namespace eagle {
TextEditor editor = (TextEditor)GUIUtility.GetStateObject(
typeof(TextEditor),
GUIUtility.keyboardControl);
var textEntry = ClientTextProvider.Provider.GetTextEntry(CurrentEntry.GeneratedTextId);
editor.text = textEntry != null ? textEntry.Text : "";
editor.text =
ClientTextProvider.Provider.GetTextEntry(CurrentEntry.GeneratedTextId).Text;
editor.SelectAll();
editor.Copy();
}
@@ -86,17 +86,5 @@ 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;
}
}
}
}
}
@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Api.Command.Util;
@@ -10,7 +9,6 @@ using UnityEngine.UI;
namespace eagle {
using ProvinceId = Int32;
using HeroId = Int32;
public class ManagePrisonersCommandSelector : CommandSelector {
// Unity accessors
@@ -38,26 +36,6 @@ 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)}";
@@ -224,13 +224,13 @@ namespace eagle {
tp => tp.TypeId == battalionTypeId && tp.MeetsRequirements);
}
private void MaybeActivateRow(
EventBasedTable table,
BattalionTypeId battalionTypeId,
Dictionary<BattalionTypeId, int> extraTroopsByType) {
private void MaybeActivateRow(EventBasedTable table, BattalionTypeId battalionTypeId) {
var parent = table.gameObject.transform.parent;
var allowed = TypeIsAllowed(battalionTypeId) || extraTroopsByType[battalionTypeId] > 0;
var allowed = TypeIsAllowed(battalionTypeId) ||
extraTroops.Where(tfb => tfb.type == battalionTypeId)
.Select(tfb => tfb.count)
.Sum() > 0;
table.gameObject.GetComponent<OrganizeExtrasTable>().Set(
allowed,
@@ -251,12 +251,12 @@ namespace eagle {
parent.GetComponentInChildren<RawImage>().color = color;
}
private void MaybeActivateRows(Dictionary<BattalionTypeId, 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);
private void MaybeActivateRows() {
MaybeActivateRow(lightInfantryTable, BattalionTypeId.LightInfantry);
MaybeActivateRow(heavyInfantryTable, BattalionTypeId.HeavyInfantry);
MaybeActivateRow(longbowmenTable, BattalionTypeId.Longbowmen);
MaybeActivateRow(dragoonsTable, BattalionTypeId.LightCavalry);
MaybeActivateRow(knightsTable, BattalionTypeId.HeavyCavalry);
}
protected override void SetUpUI() {
@@ -330,8 +330,7 @@ namespace eagle {
} else
return false;
// Note: We don't call Update() here - the caller (UpdateTable or MaxClickedImpl)
// will call it when needed. This avoids redundant recalculations.
eb.Update(existingBattalions);
return true;
}
@@ -368,6 +367,7 @@ 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,8 +464,7 @@ namespace eagle {
} else
return false;
// Note: We don't call Update() here - the caller (UpdateTable or MaxClickedImpl)
// will call it when needed. This avoids redundant recalculations.
newB.Update(existingBattalions);
return true;
}
@@ -654,16 +653,7 @@ namespace eagle {
{ BattalionTypeId.Longbowmen, 0 }
};
// Cache extra troop counts by type to avoid repeated LINQ queries
var extraTroopsByType = new Dictionary<BattalionTypeId, int> {
{ BattalionTypeId.LightInfantry, 0 },
{ BattalionTypeId.HeavyInfantry, 0 },
{ BattalionTypeId.LightCavalry, 0 },
{ BattalionTypeId.HeavyCavalry, 0 },
{ BattalionTypeId.Longbowmen, 0 }
};
foreach (var et in extraTroops) { extraTroopsByType[et.type] += et.count; }
maxAllButton.gameObject.SetActive(false);
foreach (var eb in existingBattalions) {
if (eb.dismissed) continue;
@@ -676,7 +666,9 @@ namespace eagle {
newRow.MaxButtonClickedCallback = () => MaxClicked(eb);
newRow.DismissButtonClickedCallback = () => DismissClicked(eb);
bool canAugment = TypeIsAllowed(eb.TypeId) || extraTroopsByType[eb.TypeId] > 0;
bool canAugment =
TypeIsAllowed(eb.TypeId) ||
extraTroops.Where(tfb => tfb.type == eb.TypeId).Sum(tfb => tfb.count) > 0;
newRow.CanAugment = canAugment;
if (eb.Count < eb.Capacity) {
@@ -747,18 +739,15 @@ 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.newBattalion.NewTroops > 0 ||
b.newBattalion.TroopsFromOtherBattalion.Count > 0) ||
(newBattalions.Exists(b => b.Update(existingBattalions).Size > 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(extraTroopsByType);
MaybeActivateRows();
}
public override AvailableCommand.SealedValueOneofCase CommandType =>
@@ -30,26 +30,10 @@ namespace eagle {
public override string HeaderString => "Recruit Heroes";
public override string CommitButtonString => "Commit Recruit";
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 bool HeroIsTargetable(HeroId heroId) =>
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);
@@ -1,151 +0,0 @@
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";
}
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 1c49d026769d746aeac2ceff7c1f5dc4
@@ -1,149 +0,0 @@
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";
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 727a520b2deac4a28ae9c6a00e54c2e7
@@ -24,8 +24,8 @@ namespace eagle {
public RollPanelController rollPanelController;
public TextMeshProUGUI roundStatusLabel;
public TextMeshProUGUI connectionStatusLabel;
private bool _connectionStatusUIInitialized = false;
public TextMeshProUGUI widthLabel;
public TextMeshProUGUI heightLabel;
public GameObject alwaysOnLeftColumn;
public GameObject factionsAndMovingArmiesRow;
@@ -110,15 +110,8 @@ namespace eagle {
if (newWidth == _lastWidth && newHeight == _lastHeight) { return; }
// 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;
}
}
widthLabel.text = newWidth.ToString();
heightLabel.text = newHeight.ToString();
_lastWidth = newWidth;
_lastHeight = newHeight;
@@ -165,10 +158,6 @@ 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) {
@@ -229,8 +218,7 @@ namespace eagle {
if (pause) {
ModelUpdater.StopListeningForUpdates();
} else {
// Fire-and-forget - subscription is awaited internally and failures are logged
_ = ModelUpdater.StartListeningForUpdates();
ModelUpdater.StartListeningForUpdates();
}
}
@@ -267,13 +255,7 @@ namespace eagle {
ModelUpdater.ErrorHandler = errorHandler;
// 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(); });
MainQueue.Q.EnqueueForNextUpdate(() => { ModelUpdater.StartListeningForUpdates(); });
#if UNITY_EDITOR
EditorApplication.playModeStateChanged += (state) => OnPlayModeStateChanged(state);
@@ -312,7 +294,6 @@ namespace eagle {
movingArmiesTableController.UpdateTables();
factionsTableController.UpdateTables();
heroesAndBattalionsPanelController.UpdateTables();
freeHeroesTableController.UpdateUnaffiliatedHeroSelections();
}
private void UpdateButtons() {
@@ -668,9 +649,7 @@ namespace eagle {
var shardokModel = Model.ShardokGameModels[selectedModel.ShardokGameId];
shardokCanvas.gameObject.SetActive(true);
var shardokController = shardokCanvas.GetComponent<ShardokGameController>();
shardokController.SetUpGame(shardokModel);
shardokController.SetConnection(errorHandler.PersistentClientConnection);
shardokCanvas.GetComponent<ShardokGameController>().SetUpGame(shardokModel);
gameObject.SetActive(false);
}
@@ -1,5 +1,4 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
@@ -62,7 +61,7 @@ namespace eagle {
RollFetcher RollFetcher { get; }
}
public class GameModelUpdater : IClientConnectionSubscriber, IGameStateProvider {
public class GameModelUpdater : IClientConnectionSubscriber {
private FactionId? PlayerId => _currentModel.PlayerId;
public Int64? CurrentEagleToken => _currentModel.CommandToken;
public Int64? CurrentShardokToken(string shardokGameId) {
@@ -95,25 +94,13 @@ namespace eagle {
public long GameId { get; }
public int LastUnfilteredResultCount {
get {
lock (_resultCountLock) { return _lastUnfilteredResultCount; }
}
}
public int LastUnfilteredResultCount => _lastUnfilteredResultCount;
public List<IClientConnectionSubscriber.ShardokViewStatus> ShardokViewStatuses =>
_currentModel.ShardokGameModels
.Select(sgm => {
var needsResync = _shardokNeedsResync.GetValueOrDefault(sgm.Key, false);
// Use thread-safe count from gRPC thread updates, fall back to model
var count = _shardokResultCounts.GetValueOrDefault(
sgm.Key,
sgm.Value.History.Count());
return new IClientConnectionSubscriber.ShardokViewStatus {
shardokGameId = sgm.Key,
filteredResultCount = needsResync ? 0 : count,
requestFullResync = needsResync
};
.Select(sgm => new IClientConnectionSubscriber.ShardokViewStatus {
shardokGameId = sgm.Key,
filteredResultCount = sgm.Value.History.Count()
})
.ToList();
@@ -130,27 +117,10 @@ 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
@@ -249,16 +219,8 @@ namespace eagle {
}
private ShardokGameModel MakeGameModel(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;
}
var battleView =
_currentModel.ShardokBattles.First(b => b.ShardokGameId == shardokGameId);
PlayerId playerId = battleView.MyPlayerId ?? -1;
@@ -285,9 +247,10 @@ namespace eagle {
locationName: _currentModel.Provinces[defenderProvince].Name,
month: _currentModel.CurrentDate.Month,
players: players,
heroNameTextIds: _currentModel.Heroes.ToDictionary(
heroNames: _currentModel.Heroes.ToDictionary(
kv => kv.Key,
kv => kv.Value.NameTextId),
kv => ClientTextProvider.Provider.GetTextEntry(kv.Value.NameTextId)
.Text),
heroImages: _currentModel.Heroes.ToDictionary(
kv => kv.Key,
kv => kv.Value.ImagePath),
@@ -304,14 +267,6 @@ namespace eagle {
switch (updateItem.GameUpdateDetailsCase) {
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
// 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;
}
_lastUnfilteredResultCount =
updateItem.ActionResultResponse.UnfilteredResultCountAfter;
if (updateItem.ActionResultResponse.ActionResultViews.Any() ||
@@ -335,41 +290,18 @@ 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.Remove(oneResponse.ShardokGameId);
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
continue;
}
}
var updatesOk = shardokGameModel.HandleUpdates(
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.Remove(oneResponse.ShardokGameId);
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
}
}
if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
@@ -390,67 +322,10 @@ namespace eagle {
}
}
/// <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 StartListeningForUpdates() { 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}");
@@ -458,7 +333,6 @@ namespace eagle {
_currentModel.AvailableCommandsByProvince.Clear();
_currentModel.CommandToken = null;
_currentModel.LastPostedToken = token;
_commandSubmittedTime = DateTime.UtcNow;
return PersistentConnection.PostEagleCommand(
gameId: GameId,
token: token,
@@ -470,10 +344,6 @@ 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);
@@ -775,15 +645,6 @@ 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.Remove(rb);
}
_shardokResultCounts.TryRemove(rb, out _);
for (int i = 0; i < _currentModel.ShardokBattles.Count; i++) {
if (_currentModel.ShardokBattles[i].ShardokGameId == rb) {
_currentModel.ShardokBattles.RemoveAt(i);
@@ -829,21 +690,5 @@ 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
}
}
@@ -59,7 +59,7 @@ namespace eagle {
}
}
public void UpdateUnaffiliatedHeroSelections() {
private void UpdateUnaffiliatedHeroSelections() {
UnaffiliatedHeroes.Each(
(uh, i) => SetUnaffiliatedHeroRowSelections(
unaffiliatedHeroesTable.ComponentAt<UnaffiliatedHeroRowController>(i),
@@ -18,11 +18,10 @@ 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();
}
}
}
@@ -60,14 +59,7 @@ namespace eagle {
}
private void OnTextUpdate(TextEntry entry) {
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();
}
if (entry != null) OnTextUpdate(entry.Text, entry.Completed);
}
public void OnTextUpdate(string text, bool completed) {
@@ -11,18 +11,10 @@ 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 {

Some files were not shown because too many files have changed in this diff Show More