Compare commits

..
Author SHA1 Message Date
admin c691dc481a partial progress 2025-08-29 18:32:16 -07:00
admin 77cb1d8168 more fixes 2025-08-29 16:14:33 -07:00
admin 8ac81891d3 more 2025-08-29 13:27:10 -07:00
admin af3209e2b3 more 2025-08-29 13:24:38 -07:00
admin aa62555735 a few more 2025-08-29 13:16:28 -07:00
admin f3d09770ee a few more 2025-08-29 13:12:00 -07:00
admin 83d81dc033 cavalcade o'fixes 2025-08-29 13:09:19 -07:00
admin 3b05bc3f0b some more 2025-08-29 11:51:48 -07:00
admin c6c22778a7 fix a few 2025-08-29 10:53:02 -07:00
admin 11e81a639a move to opaque types, more fixes still needed 2025-08-29 10:36:29 -07:00
744 changed files with 26622 additions and 30817 deletions
+2 -1
View File
@@ -20,7 +20,7 @@ project/boot/
project/plugins/project/
project/target/
bazel-bin
bazel-eagle0*
bazel-eagle0
bazel-out
bazel-testlogs
.ijwb
@@ -32,6 +32,7 @@ buildWin.sh
__pycache__/
scripts/refresh_name_layers/vendor/
scripts/refresh_name_layers/refresh_name_layers.zip
.pre-commit-config.yaml
.bazelbsp
.bsp
.metals
-43
View File
@@ -1,43 +0,0 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
hooks:
- id: check-added-large-files
- id: no-commit-to-branch
args: [--branch, main]
- repo: https://github.com/pocc/pre-commit-hooks
rev: v1.3.5
hooks:
- id: clang-format
args: [-i, --no-diff]
types_or: ["c++", "c#"]
exclude: ^src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins
- repo: https://github.com/yoheimuta/protolint
rev: v0.42.2
hooks:
- id: protolint
args: [-fix]
exclude: ^src/main/protobuf/scalapb/
- repo: local
hooks:
- id: scalafmt
name: scalafmt
language: system
entry: scalafmt -i -f
types_or: ["scala"]
- repo: local
hooks:
- id: gazelle
name: gazelle
language: system
entry: bazel run //:gazelle
files: '(\.go|\.proto|BUILD\.bazel|BUILD|WORKSPACE|WORKSPACE\.bazel|\.bzl)$'
- repo: local
hooks:
- id: update-action-result-types
name: update-action-result-types
language: system
entry: ./scripts/updateActionResultTypes.sh
files: 'src/main/protobuf/net/eagle0/eagle/common/action_result_type.proto'
-41
View File
@@ -1,47 +1,6 @@
version = "3.9.9"
runner.dialect = scala3
rewrite.scala3.convertToNewSyntax = true
# Keep braces, don't use significant indentation
# rewrite.scala3.removeOptionalBraces = yes
rewrite.scala3.insertEndMarkerMinLines = 15
rewrite.scala3.removeEndMarkerMaxLines = 14
# Strip margin settings
assumeStandardLibraryStripMargin = false
align.stripMargin = true
# Code Style & Formatting
align.preset = more
align.multiline = true
align.arrowEnumeratorGenerator = true
spaces.inImportCurlyBraces = false
spaces.beforeContextBoundColon = Never
maxColumn = 120
docstrings.style = Asterisk
docstrings.wrap = yes
# Method chaining
newlines.beforeCurlyLambdaParams = multilineWithCaseOnly
optIn.breakChainOnFirstMethodDot = true
includeCurlyBraceInSelectChains = false
# Advanced Scala 3 Features
rewrite.scala3.countEndMarkerLines = all
rewrite.redundantBraces.stringInterpolation = true
rewrite.redundantBraces.parensForOneLineApply = true
# Project-Specific Considerations
optIn.annotationNewlines = true
runner.optimizer.forceConfigStyleMinArgCount = 3
# Import sorting configuration
rewrite.rules = [SortImports, RedundantBraces, RedundantParens]
rewrite.imports.sort = scalastyle
rewrite.imports.groups = [
["java\\..*"],
["javax\\..*"],
["scala\\..*"],
[".*"]
]
rewrite.imports.contiguousGroups = only
rewrite.trailingCommas.style = never
+2 -55
View File
@@ -35,12 +35,9 @@ Unity Client ↔ Eagle (gRPC streaming) ↔ Shardok (internal gRPC)
# Build Eagle server (Scala strategic layer)
bazel build //src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar
# Build Shardok server (C++ tactical layer)
# Build Shardok server (C++ tactical layer)
bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server
# Shardok server includes both AI algorithms
bazel build //src/main/cpp/net/eagle0/shardok:shardok-server
# Build Unity/C# client
./scripts/build_protos.sh # Protocol buffer generation for Unity
./scripts/build_plugins.sh # Native plugins for all platforms
@@ -97,54 +94,6 @@ bazel run @llvm_toolchain//:clang-tidy -- --checks='readability-*,bugprone-*,cla
bazel run @llvm_toolchain//:clang-tidy -- --checks='readability-*,bugprone-*,clang-analyzer-*' /Users/dancrosby/CodingProjects/github/eagle0/src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.cpp -- -I/Users/dancrosby/CodingProjects/github/eagle0 -std=c++20
```
## AI Algorithm Selection
Eagle0 supports two AI algorithms for tactical combat decision-making:
### Iterative Deepening AI (Default)
The original minimax-based AI with sophisticated randomness handling:
- **Advantages**: Proven, sophisticated randomness evaluation, comprehensive lookahead
- **Use cases**: Production builds, scenarios requiring precise evaluation
- **Performance**: Single-threaded, thorough evaluation
### Monte Carlo Tree Search AI (MCTS)
Modern MCTS-based AI with multithreading support:
- **Advantages**: Multithreaded, better performance on modern CPUs, anytime algorithm
- **Use cases**: Performance testing, scenarios requiring fast decisions
- **Performance**: Multithreaded, adaptive depth based on time budget
### Switching Between Algorithms
The algorithm is selected at **runtime** via the ShardokAIClient constructor:
```cpp
// Using Iterative Deepening AI (default)
ShardokAIClient client(playerId, isDefender, hexMap, settings);
// OR explicitly:
ShardokAIClient client(playerId, isDefender, hexMap, settings, AIAlgorithmType::ITERATIVE_DEEPENING);
// Using MCTS AI
ShardokAIClient client(playerId, isDefender, hexMap, settings, AIAlgorithmType::MCTS);
```
```bash
# Build the server (includes both AI algorithms)
bazel build //src/main/cpp/net/eagle0/shardok:shardok-server
# Test both algorithms
bazel test //src/test/cpp/net/eagle0/shardok/ai:ai_iterative_deepening_test
bazel test //src/test/cpp/net/eagle0/shardok/ai:ai_mcts_test # If available
# Performance tests
./scripts/ai_perf_test.sh # Uses whatever algorithm the server is configured to use
```
Both implementations are compatible with all existing interfaces and produce the same `SearchResult` structure.
**Note**: Both implementations are documented in `src/main/cpp/net/eagle0/shardok/ai/AI_SCORING_SYSTEM.md`, including recommendations for improving MCTS randomness handling.
The AI algorithm selection is made at runtime when creating ShardokAIClient instances, allowing different AI strategies to be used for different players or game situations within the same server process.
## Language-Specific Patterns
**Scala (Strategic Layer):**
@@ -229,6 +178,4 @@ done
- Bazel handles multi-language builds and dependencies
- CI/CD via GitHub Actions with platform-specific build scripts in `/ci/github_actions/`
- Docker containerization available via `ci/eagle_run.Dockerfile`
- Always run "bazel run //:gazelle" after editing any BUILD.bazel files
- *ALWAYS ALWAYS* run "bazel run gazelle" after any change that modifies a BUILD.bazel file
- Docker containerization available via `ci/eagle_run.Dockerfile`
-177
View File
@@ -1,177 +0,0 @@
# MCTS with Player Flips - Clean Design
## Core Principles
1. **Single Perspective**: All scoring is from our AI's perspective (positive = good for us, negative = bad for us)
2. **Player Flips**: Continue expansion/simulation until N player turn changes occur
3. **Minimax Integration**: Our turns maximize our score, opponent turns minimize our score
4. **Simplicity**: No special casing - just track when currentPlayer changes
## Node Structure
```cpp
struct MCTSNode {
// Command that led to this state
size_t commandIndex;
CommandType commandType;
// Game state after executing the command
GameStateW resultingGameState;
PlayerId currentPlayer; // Whose turn it is in this state
// Tree position
int playerFlipsFromRoot; // Number of player changes from root
bool isOurTurn; // currentPlayer == our AI's playerId
// MCTS statistics (always from our perspective)
int visitCount = 0;
double totalScore = 0.0;
double averageScore = 0.0;
// Tree structure
std::vector<std::unique_ptr<MCTSNode>> children;
std::vector<size_t> untriedCommands;
};
```
## Expansion Algorithm
```cpp
MCTSNode* Expand(MCTSNode* node) {
// Check if we've reached max player flips
if (node->playerFlipsFromRoot >= maxPlayerFlips) {
return node; // Don't expand further
}
// Pick untried command
size_t cmdIndex = PickUntriedCommand(node);
// Execute command to create child state
auto childEngine = std::make_shared<ShardokEngine>(parentEngine);
PlayerId playerBefore = childEngine->GetCurrentPlayerId();
childEngine->PostCommand(playerBefore, cmdIndex, averageGenerator);
PlayerId playerAfter = childEngine->GetCurrentPlayerId();
// Create child node
auto child = std::make_unique<MCTSNode>();
child->commandIndex = cmdIndex;
child->resultingGameState = childEngine->GetCurrentGameState();
child->currentPlayer = playerAfter;
child->isOurTurn = (playerAfter == ourPlayerId);
// Track player flips
child->playerFlipsFromRoot = node->playerFlipsFromRoot;
if (playerBefore != playerAfter) {
child->playerFlipsFromRoot++;
}
// Score from our perspective
child->immediateScore = ScoreFromOurPerspective(child->resultingGameState);
return child;
}
```
## Simulation Algorithm
```cpp
double Simulate(const GameStateW& startState, PlayerId startPlayer, int startFlips) {
auto simEngine = CreateEngine(startState);
int currentFlips = startFlips;
while (currentFlips < maxPlayerFlips) {
PlayerId currentPlayer = simEngine->GetCurrentPlayerId();
bool isOurTurn = (currentPlayer == ourPlayerId);
// Get available commands
auto commands = simEngine->GetAvailableCommandsForAIPlayer(currentPlayer);
if (commands->empty()) break;
// Pick best command based on whose turn it is
size_t bestCmd = 0;
double bestScore = isOurTurn ? -infinity : +infinity;
for (size_t i = 0; i < commands->size(); ++i) {
auto testEngine = CreateEngine(simEngine);
testEngine->PostCommand(currentPlayer, i, averageGenerator);
// Always score from our perspective
double score = ScoreFromOurPerspective(testEngine->GetCurrentGameState());
// Our turn: maximize our score, Opponent turn: minimize our score
bool shouldSelect = isOurTurn ? (score > bestScore) : (score < bestScore);
if (shouldSelect) {
bestScore = score;
bestCmd = i;
}
}
// Execute chosen command
PlayerId playerBefore = simEngine->GetCurrentPlayerId();
simEngine->PostCommand(currentPlayer, bestCmd, averageGenerator);
PlayerId playerAfter = simEngine->GetCurrentPlayerId();
// Track player flips
if (playerBefore != playerAfter) {
currentFlips++;
}
}
return ScoreFromOurPerspective(simEngine->GetCurrentGameState());
}
```
## Selection Algorithm
```cpp
MCTSNode* SelectChild(MCTSNode* node) {
MCTSNode* bestChild = nullptr;
double bestUCB1 = node->isOurTurn ? -infinity : +infinity;
for (auto& child : node->children) {
double ucb1 = child->averageScore + explorationTerm;
// Our turn: pick highest UCB1, Opponent turn: pick lowest UCB1
bool shouldSelect = node->isOurTurn ? (ucb1 > bestUCB1) : (ucb1 < bestUCB1);
if (shouldSelect) {
bestUCB1 = ucb1;
bestChild = child.get();
}
}
return bestChild;
}
```
## Backpropagation Algorithm
```cpp
void Backpropagate(MCTSNode* node, double score) {
while (node != nullptr) {
node->visitCount++;
node->totalScore += score; // Always from our perspective
node->averageScore = node->totalScore / node->visitCount;
node = node->parent;
}
}
```
## Key Simplifications
1. **No END_TURN special cases** - just check if currentPlayer changed after any command
2. **Consistent scoring** - always from our AI's perspective throughout
3. **Clear minimax** - our nodes maximize, opponent nodes minimize
4. **Simple state tracking** - just count player flips, no complex inheritance
5. **Unified command handling** - all commands handled the same way
## Implementation Plan
1. **Refactor MCTSNode structure** - simplify to core fields needed
2. **Rewrite MCTSExpansion** - remove END_TURN special cases, just track player changes
3. **Fix MCTSSimulation** - ensure consistent perspective and proper minimax
4. **Simplify MCTSSelection** - clean minimax logic
5. **Clean up MCTSBackpropagation** - single perspective throughout
6. **Add comprehensive testing** - verify player flips are tracked correctly
This design eliminates the confusion around perspectives and special cases, making the algorithm much easier to understand and debug.
+9 -1
View File
@@ -20,7 +20,13 @@ bazel_dep(name = "rules_pkg", version = "1.1.0")
# Language Support - Scala
#
bazel_dep(name = "rules_scala", version = "7.1.1")
bazel_dep(name = "rules_scala")
git_override(
commit = "f4523243ab5f946600c2b0d7a4b5791bb8758799",
module_name = "rules_scala",
remote = "https://github.com/bazel-contrib/rules_scala.git",
)
scala_config = use_extension(
"@rules_scala//scala/extensions:config.bzl",
@@ -77,6 +83,8 @@ use_repo(
"com_github_aws_aws_sdk_go_v2_credentials",
"com_github_aws_aws_sdk_go_v2_service_s3",
"org_golang_google_protobuf",
"org_golang_x_text",
"com_github_google_go_cmp",
)
#
+3 -5
View File
@@ -317,8 +317,6 @@
"https://bcr.bazel.build/modules/rules_python/1.0.0/MODULE.bazel": "898a3d999c22caa585eb062b600f88654bf92efb204fa346fb55f6f8edffca43",
"https://bcr.bazel.build/modules/rules_python/1.0.0/source.json": "b0162a65c6312e45e7912e39abd1a7f8856c2c7e41ecc9b6dc688a6f6400a917",
"https://bcr.bazel.build/modules/rules_rust/0.45.1/MODULE.bazel": "a69d0db3a958fab2c6520961e1b2287afcc8b36690fd31bbc4f6f7391397150d",
"https://bcr.bazel.build/modules/rules_scala/7.1.1/MODULE.bazel": "b1f80c52ae49b27d41b9291d8b328b69247de2b7596d35d09afe6147b82cf562",
"https://bcr.bazel.build/modules/rules_scala/7.1.1/source.json": "5038cb231d4020c5965c920681cf961a7bf137b40315025e40f3a7b6a0ac1f0f",
"https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/source.json": "c55ed591aa5009401ddf80ded9762ac32c358d2517ee7820be981e2de9756cf3",
@@ -1268,7 +1266,7 @@
"@@rules_scala~//scala/extensions:config.bzl%scala_config": {
"general": {
"bzlTransitiveDigest": "TdBxhkZTM7VU6teIFS+KoonKU7wmb5BL7leCWWx7yX8=",
"usagesDigest": "z8BblsnuAs+q3LEu90nfjZZasRDCB2K31wb7NXFSZVM=",
"usagesDigest": "6V8NLCEm/+v54a4/jhHtdftOmlUdkW4PPxw+IISD0Eo=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {
@@ -1293,8 +1291,8 @@
},
"@@rules_scala~//scala/extensions:deps.bzl%scala_deps": {
"general": {
"bzlTransitiveDigest": "F2PMm61fmZ/IE+VSw1rigJ71hBDD7k3vqyYR1/GgXeA=",
"usagesDigest": "kwo8oolISmSSITnit4b4S0vBiUtHlHK0WLDUwScxmOg=",
"bzlTransitiveDigest": "auQqXmoQ01Gebqkny5AjUoLJjButvOXfi1JYq7gucsI=",
"usagesDigest": "086XZ0aCCfOZmHhDfMCTOlr5OcQC3zEr2jiL87XtSm8=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
-305
View File
@@ -1,305 +0,0 @@
# Actions and Commands Model Usage Analysis
This document analyzes all actions and commands in `src/main/scala/net/eagle0/eagle/library/actions/impl` to determine which use Scala models vs protobuf models, based on BUILD.bazel dependencies.
**Legend:**
-**Scala Models Only** - Uses only `//src/main/scala/net/eagle0/eagle/model` dependencies
-**Uses Protobuf** - Has dependencies on `//src/main/protobuf` targets
- 🔄 **Partial Conversion** - Conversion attempted but blocked by dependencies
## Summary
Based on BUILD.bazel dependency analysis (2025-09-16, updated 2025-09-17):
- **Total Commands Analyzed:** 41
- **Commands Fully Migrated (No Protobuf):** 41 (100%) ✅
- **Commands Still Using Protobuf:** 0 (0%) ✅
- **Total Actions Analyzed:** 48
- **Actions Fully Migrated (No Protobuf):** 5 (10.4%)
- **Actions Partially Migrated:** 19 (39.6%)
- **Actions Still Using Protobuf:** 24 (50%)
- **Base Classes:** 8 protoless variants available, 6 still use protobuf
- **Shared Components:** `ResolvedEagleUnit` migrated to use `Option[BattalionT]` for proper null handling
## Conversion Insights
Based on conversion attempt of `ResolveTruceOfferCommand` (see [PR #4379](https://github.com/nolen777/eagle0/pull/4379)):
### Key Challenges Discovered
1. **LLM Integration Dependencies**: Commands that use `DiplomacyResolutionLlmRequestGenerator` face challenges because the LLM system still expects protobuf enum types, not Scala model enums.
2. **Inconsistent Package Naming**: Some files have inconsistent package declarations vs BUILD file locations (e.g., `generated_text_request_generators` in package vs `llm_request_generators` in BUILD).
3. **Model Constructor Differences**: Scala model constructors (e.g., `TruceOffer`) have different required parameters than their protobuf counterparts, requiring more complex data mapping.
4. **Type System Complexity**: Union types and type constraints become more complex when mixing protobuf and Scala model types during transition.
5. **Cascading Dependency Issues**: Converting to `ActionResultC` requires extensive trait dependencies (`ChangedBattalionT`, `ChangedHeroT`, `GeneratedTextRequestT`, etc.) that create complex BUILD dependency graphs, unlike simple protobuf `ActionResult`.
6. **BUILD Complexity**: Each Scala model conversion requires significantly more BUILD dependencies than protobuf equivalents, making incremental conversion difficult.
7. **Build Verification Critical**: Any conversion must maintain working build state - even simple commands like `DefendCommand` can break main server build due to dependency cascades.
### Successful Conversion Elements
- ✅ Base class conversion (`SimpleAction``ProtolessSimpleAction`)
- ✅ Import updates for most Scala model types
- ✅ BUILD.bazel dependency updates for core action result types
- ✅ Basic type conversions for simple cases
### Recommended Conversion Strategy
1. **Architecture-First Approach**: Convert base infrastructure (LLM generators, action result builders) before individual commands
2. **Wrapper Pattern**: Use existing `Protoless*ActionWrapper` classes as templates for gradual transition
3. **Dependency Analysis**: Map full dependency trees before attempting conversions to avoid cascading build failures
4. **Batch Conversions**: Convert related commands together to minimize dependency conflicts
5. **Build Verification**: **ALWAYS** verify `//src/main/scala/net/eagle0/eagle:eagle_server` and test suite build before creating PRs
### Conversion Requirements
**Before creating any PR:**
-`bazel build //src/main/scala/net/eagle0/eagle:eagle_server` succeeds
-`bazel test //src/test/scala/... --keep_going` passes (or doesn't introduce new failures)
- ✅ All BUILD dependencies are correctly specified
- ✅ Scalafmt and other linters pass
---
## Common Base Classes
| File | Type | Model Usage | Notes |
|------|------|-------------|-------|
| Action.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto`, `game_state_scala_proto` |
| ActionWithResultingState.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto`, `game_state_scala_proto` |
| DeterministicSingleResultAction.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto`, `game_state_scala_proto` |
| DeterministicSequentialResultsAction.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto`, `game_state_scala_proto` |
| ProtolessRandomSequentialResultsAction.scala | Base Class | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model/action_result` |
| ProtolessRandomSimpleAction.scala | Base Class | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model/action_result` |
| ProtolessSequentialResultsAction.scala | Base Class | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model/action_result` |
| ProtolessSimpleAction.scala | Base Class | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model/action_result` |
| RandomSequentialResultsAction.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto`, `game_state_scala_proto` |
| RandomSimpleAction.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto` |
| RandomStateProtoSequencer.scala | Sequencer | ❌ Uses Protobuf | Bridge class, depends on both protobuf and Scala models |
| RandomStateTSequencer.scala | Sequencer | ❌ Uses Protobuf | Bridge class, depends on both protobuf and Scala models |
| SimpleAction.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto` |
| VigorXPApplier.scala | Utility | ❌ Uses Protobuf | Depends on `action_result_scala_proto` |
---
## Actions
### ✅ Fully Migrated Actions (No Protobuf Dependencies)
These actions have been successfully migrated to use Scala models only:
| File | Base Class | Notes |
|------|------------|-------|
| HeroBackstoryUpdateAction.scala | ProtolessSequentialResultsAction | Processes hero backstory updates with LLM integration |
| ProvinceConqueredAction.scala | ProtolessSimpleAction | Uses component-based design (gameId, currentRoundId, currentDate, Scala models) |
| ProvinceHeldAction.scala | ProtolessSimpleAction | Uses specific components (gameId, currentRoundId, defendingProvince, etc.) instead of full GameState |
| UnaffiliatedHeroAppearedAction.scala | ProtolessSimpleAction | Handles unaffiliated hero appearance with name generation |
| WithdrawnArmiesReturnHomeAction.scala | ProtolessSequentialResultsAction | Manages army withdrawal and return mechanics |
### 🔄 Actions Partially Migrated (Using Protoless Base Classes)
These actions use protoless base classes but still have some protobuf dependencies:
| File | Model Usage | Notes |
|------|-------------|-------|
| CheckForFactionChangesAction.scala | ProtolessSequentialResultsAction | Still has some protobuf dependencies |
| CheckForFailedQuestsAction.scala | ProtolessSequentialResultsAction | Depends on `unaffiliated_hero_quest_scala_proto` |
| CheckForFulfilledQuestsAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
| EndAttackDecisionPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
| EndBattleAftermathPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
| EndFreeForAllDecisionPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
| EndPlayerCommandsPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
| EndUnaffiliatedHeroActionsPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
| EndVassalCommandsPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
| FreeForAllDrawAction.scala | ProtolessSimpleAction | Depends on multiple protobuf targets |
| FriendlyMoveAction.scala | ProtolessSimpleAction | Depends on multiple protobuf targets |
| PerformUncontestedConquestAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
| ProvinceConqueredAction.scala | ProtolessSimpleAction | **CONVERTED** - Uses specific components (gameId, currentRoundId, currentDate, Scala models) |
| SafePassageArmiesProceedAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
| ShipmentArrivedAction.scala | ProtolessSimpleAction | Depends on multiple protobuf targets |
| TruceTurnBackPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
| UnaffiliatedHeroRejoinedAction.scala | ProtolessSimpleAction | Depends on multiple protobuf targets |
| WonFreeForAllAction.scala | ProtolessSimpleAction | Depends on multiple protobuf targets |
### ❌ Actions Still Using Protobuf (Not Yet Using Protoless Base Classes)
| File | Notes |
|------|-------|
| ChronicleEventGenerator.scala | Depends on multiple protobuf targets |
| EndBattleRequestPhaseAction.scala | Depends on `diplomacy_offer_status_scala_proto` |
| EndBattleResolutionPhaseAction.scala | Depends on multiple protobuf targets |
| EndDefenseDecisionPhaseAction.scala | Depends on multiple protobuf targets |
| EndDiplomacyResolutionPhaseAction.scala | Depends on multiple protobuf targets |
| EndFreeForAllBattleRequestPhaseAction.scala | Depends on multiple protobuf targets |
| EndFreeForAllBattleResolutionPhaseAction.scala | Depends on multiple protobuf targets |
| EndHandleRiotsPhaseAction.scala | Depends on multiple protobuf targets |
| EndPleaseRecruitMePhaseAction.scala | Depends on multiple protobuf targets |
| EndProvinceMoveResolutionPhaseAction.scala | Depends on multiple protobuf targets |
| NewRoundAction.scala | Depends on multiple protobuf targets |
| NewYearAction.scala | Depends on multiple protobuf targets |
| PerformFoodConsumptionPhaseAction.scala | Depends on multiple protobuf targets |
| PerformForcedTurnBackAction.scala | Depends on multiple protobuf targets |
| PerformHeroDeparturesAction.scala | Depends on multiple protobuf targets |
| PerformHostileArmySetupAction.scala | Depends on multiple protobuf targets |
| PerformProvinceEventsAction.scala | Depends on `province_event_scala_proto` |
| PerformProvinceMoveResolutionAction.scala | Depends on multiple protobuf targets |
| PerformReconResolutionAction.scala | Depends on multiple protobuf targets |
| PerformUnaffiliatedHeroesAction.scala | Depends on `unaffiliated_hero_quest_scala_proto` |
| PerformVassalCommandsPhaseAction.scala | Depends on multiple protobuf targets |
| PerformVassalDefenseDecisionsAction.scala | Depends on multiple protobuf targets |
| PrisonerEscapeAction.scala | Depends on `game_state_scala_proto` |
| PrisonerExchangeAction.scala | Depends on multiple protobuf targets |
| RequestBattlesAction.scala | Depends on multiple protobuf targets |
| RequestFreeForAllBattlesAction.scala | Depends on multiple protobuf targets |
| ResolveBattleAction.scala | Depends on `shardok_internal_interface_scala_grpc` |
| UnaffiliatedHeroMovedAction.scala | Depends on multiple protobuf targets |
| UnaffiliatedHeroesChangedAction.scala | Depends on multiple protobuf targets |
---
## Commands
**ALL COMMANDS FULLY MIGRATED** (100% - 41/41 commands)
All 41 commands in the codebase have been successfully migrated to use Scala models only, with no protobuf dependencies. This includes:
- **Simple Actions**: Use `ProtolessSimpleAction` base class
- **Random Actions**: Use `ProtolessRandomSimpleAction` base class
- **Complex Domain Models**: Successfully integrated with LLM systems, diplomacy, quest fulfillment, and state management
- **Complete Type Safety**: All commands now use type-safe Scala domain models
**Key Migration Achievements:**
- ✅ All military commands (ArmTroops, Train, Organize, etc.)
- ✅ All diplomacy commands (Resolve Alliance/Truce/Ransom offers, etc.)
- ✅ All LLM-integrated commands (backstory generation, diplomacy resolution)
- ✅ All quest and event commands
- ✅ Final remaining command (FreeForAllDecisionCommand) migrated
---
## Diplomacy Helpers
All diplomacy helpers use **Scala models only**:
| File | Model Usage | Notes |
|------|-------------|-------|
| AllianceResolutionHelpers.scala | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model` only |
| BreakAllianceResolutionHelpers.scala | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model` only |
| InvitationResolutionHelpers.scala | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model` only |
| RansomResolutionHelpers.scala | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model` only |
| TruceResolutionHelpers.scala | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model` only |
---
## Migration Priority Analysis
Based on the BUILD.bazel dependency analysis, here are the key findings and recommendations:
### 🎯 High Impact Migration Targets
**Core Dependencies Blocking Multiple Commands:**
1. **`action_result_scala_proto`** - Used by 12+ commands
- Blocks: `DefendCommand`, `FreeForAllDecisionCommand`, diplomacy resolvers
- Impact: Would unlock many command migrations
2. **`available_command_scala_proto` / `selected_command_scala_proto`** - Used by 10+ commands
- Blocks: All UI-interactive commands
- Impact: Would enable client-server interaction model migration
3. **`game_state_scala_proto`** - Used by 8+ commands
- Blocks: Complex state-dependent commands
- Impact: Core state representation migration
### 📊 Migration Tiers by Complexity
**Tier 1 - Quick Wins (2 commands):**
- `ArmTroopsCommand` - Only `battalion_type` dependency
- `TrainCommand` - Only `battalion_type` dependency
- **Effort:** Low, **Impact:** Demonstrates battalion model usage
**Tier 2 - API Layer (5 commands):**
- Commands blocked by `available_command`/`selected_command`
- **Effort:** Medium, **Impact:** High (enables UI interaction models)
**Tier 3 - Diplomacy Suite (6 commands):**
- All `Resolve*Command` diplomacy commands
- **Effort:** High, **Impact:** High (complete diplomacy model migration)
- **Strategy:** Migrate as a group after diplomacy models are ready
### 🏆 Success Metrics
**Current Status:**
-**100% of commands fully migrated** (41/41) 🎉
-**All diplomacy helpers use Scala models**
-**All protoless base classes available**
-**ALL command migration completed**
**Completed Milestones:**
-**70% target:** Migrate Tier 1 + some Tier 2 commands **COMPLETED**
-**80% target:** Continue with remaining non-diplomacy commands **COMPLETED**
-**85% target:** Complete API layer migration **COMPLETED**
-**95% target:** Complete diplomacy migration **COMPLETED**
-**100% target:** Migrate final remaining command (FreeForAllDecisionCommand) **COMPLETED**
### 🎯 Action Migration Progress
**Migration Statistics:**
- 5/48 Actions fully migrated (10.4%)
- 20/48 Actions using protoless base classes but with protobuf dependencies (41.7%)
- 24/48 Actions still fully on protobuf (50%)
**Successfully Migrated Actions:**
1. **HeroBackstoryUpdateAction** - LLM integration for hero backstories
2. **ProvinceConqueredAction** - Component-based design with prisoner handling and province conquest
3. **ProvinceHeldAction** - Component-based design pattern (gameId, currentRoundId, specific models)
4. **UnaffiliatedHeroAppearedAction** - Hero appearance with name generation
5. **WithdrawnArmiesReturnHomeAction** - Army withdrawal mechanics
**Recent Migration Updates (2025-09-17):**
- **ResolvedEagleUnit** - Changed `battalion: BattalionT` to `battalion: Option[BattalionT]`
- Properly handles units without battalions (battalion ID -1)
- Updated `ShardokInterfaceGrpcClient` to check for `defaultBattalionId` and use `None`
- Updated `ResolveBattleAction`, `ProvinceConqueredAction`, `RequestBattlesAction`
- All tests updated to handle optional battalions
**Key Migration Patterns:**
- ✅ Use specific components instead of full GameState (see ProvinceHeldAction, ProvinceConqueredAction)
- ✅ Convert protobuf models to Scala models at Action boundaries
- ✅ Update BUILD.bazel to remove protobuf dependencies
- ✅ Update all call sites and tests
- ✅ Use `Option[T]` for optional fields instead of special sentinel values (e.g., battalion ID -1)
**Next Migration Candidates (Simple Actions with Protoless Base):**
1. **FreeForAllDrawAction** - Already uses ProtolessSimpleAction
2. **FriendlyMoveAction** - Already uses ProtolessSimpleAction
3. **ShipmentArrivedAction** - Already uses ProtolessSimpleAction
4. **WonFreeForAllAction** - Already uses ProtolessSimpleAction
5. **ProvinceConqueredAction** - Already uses ProtolessSimpleAction, only needs `common_unit` migration
### 🔄 Conversion Strategy Updates
**Revised Approach Based on Analysis:**
1. **Focus on Core Dependencies First**
- Migrate `battalion_type` model (unlocks 2 commands immediately)
- Migrate `action_result` model (unlocks 12+ commands)
- Migrate `available_command`/`selected_command` (unlocks UI layer)
2. **Leverage Existing Success**
- 77.5% of commands already fully migrated
- Use migrated commands as reference implementations
- Diplomacy helpers prove complex business logic can work with Scala models
3. **Group Related Migrations**
- Military commands: `ArmTroopsCommand`, `TrainCommand`, `OrganizeTroopsCommand`
- UI commands: All using `available_command`/`selected_command`
- Diplomacy commands: All `Resolve*Command` variants
---
*Updated on 2025-09-17 - Analysis based on BUILD.bazel dependencies and code review*
*Latest update: ResolvedEagleUnit migrated to use Option[BattalionT] for proper battalion handling*
+5
View File
@@ -0,0 +1,5 @@
//src/test/scala/net/eagle0/eagle/ai:ai_client_utils_test ✓ FIXED
//src/test/scala/net/eagle0/eagle/ai:faction_leader_province_ranker_test ✓ FIXED
//src/test/scala/net/eagle0/eagle/ai:fix_leader_alone_command_selector_test ✓ FIXED
//src/test/scala/net/eagle0/eagle/ai:invitation_command_selector_test ✓ FIXED
//src/test/scala/net/eagle0/eagle/ai:mid_game_ai_client_test
@@ -1,19 +0,0 @@
//
// AI System Types and Configuration
//
#ifndef EAGLE0_AI_CONFIG_HPP
#define EAGLE0_AI_CONFIG_HPP
namespace shardok {
// Enum for AI algorithm selection
enum class AIAlgorithmType {
ITERATIVE_DEEPENING, // Default: Minimax with sophisticated randomness
MCTS, // Monte Carlo Tree Search with multithreading (original)
MCTS_CLEAN // Clean MCTS implementation with fixed simulation perspective
};
} // namespace shardok
#endif // EAGLE0_AI_CONFIG_HPP
File diff suppressed because it is too large Load Diff
@@ -89,8 +89,8 @@ auto ContextFreeUnitValue(const Unit *unit) -> ScoreValue {
break;
}
const double battalionValue = battalionTypeMultiplier * (1.0 + armament / 100.0) *
(1.0 + training / 100.0) * (0.5 + morale / 100.0) *
const double battalionValue = battalionTypeMultiplier * (0.5 + armament / 100.0) *
(0.5 + training / 100.0) * (0.5 + morale / 100.0) *
unit->battalion().size();
const double heroValue =
@@ -210,556 +210,4 @@ Where:
- **Magnitude**: Indicates confidence/importance of the evaluation
- **Relative scoring**: Only score differences matter, not absolute values
This scoring system provides a robust framework for tactical AI decision-making, balancing immediate tactical gains with strategic objectives while handling the uncertainty inherent in combat outcomes.
## AIScoreCalculator Function Reference
### Public Interface Functions
#### `GuessedStateScore`
**Purpose**: Evaluates the score of a game state from the perspective of the current AI strategy without performing any commands.
**Parameters**:
- `isDefender`: Whether the AI is playing as defender
- `state`: Current game state to evaluate
- `aiStrategy`: Strategy being used (attack castles, hold castles, scatter, etc.)
- `allCastleCoords`: Set of all castle coordinates on the map
- `settingsGetter`: Game configuration and rules
- `apdCache`: Cached action point distances for movement calculations
- `alCache`: Cached attack locations for combat calculations
**Returns**: Score value representing how favorable the state is for the evaluating player (positive = good, negative = bad)
#### `CommandScore`
**Purpose**: Evaluates the score for a specific command using lookahead search to consider future consequences.
**Parameters**:
- `pid`: Player ID executing the command
- `isDefender`: Whether the player is a defender
- `remainingLookahead`: Depth of recursive search remaining
- `maxRepeatCount`: Number of random simulations for non-deterministic commands
- `guessedEngine`: Current game engine state
- `attackerStrategy`: Strategy being used by attackers
- `currentUtility`: Current game state score before command execution
- `settingsGetter`: Game configuration
- `allCastleCoords`: Castle locations
- `apdCache` & `alCache`: Cached distance/attack calculations
- `commandIndex`: Index of command to evaluate
- `deadline`: Time limit for computation
**Returns**: Future containing the final score after lookahead evaluation
### Internal Core Functions
#### `BuildDecisionTree` (NEW)
**Purpose**: Builds a complete decision tree containing all evaluated command paths up to the specified depth.
**Process**:
1. Filters commands using `AICommandFilter` to reduce search space
2. For each command, calls `ExecuteCommandForTree` to build complete subtrees
3. Returns full tree with all possible moves and their consequences
4. Identifies best command within the complete tree structure
**Returns**: `std::future<CommandDecisionTree>` containing the complete decision tree
#### `BestCommandIndex` (Legacy - Wrapper)
**Purpose**: Backward compatibility wrapper that uses `BuildDecisionTree` but returns traditional `IndexAndScore`.
**Process**:
1. Calls `BuildDecisionTree` to get complete tree
2. Extracts best command information for compatibility
3. Returns only the optimal command details in legacy format
#### `ExecuteCommandForTree` (NEW)
**Purpose**: Executes a command and creates a tree node with the resulting game state and scores.
**Process**:
1. Creates engine copy and executes the command with given random seed
2. Creates `CommandTreeNode` with command results and game state
3. Calculates immediate score using `GuessedStateScore`
4. Calls `RecursiveTreeBuilder` to populate child nodes if depth allows
5. Calculates lookahead score from children (or uses immediate score)
**Returns**: `std::unique_ptr<CommandTreeNode>` containing the command execution results and subtree
#### `RecursiveTreeBuilder` (NEW)
**Purpose**: Recursively populates child nodes of a tree node by building subtrees for subsequent moves.
**Process**:
1. Gets available commands for the next player
2. Filters commands to reduce search space
3. For each command, calls `ExecuteCommandForTree` to create child nodes
4. Handles different command types (deterministic, odds-based, random)
5. Populates the parent node's children vector with complete subtrees
#### `CalcOne` (Legacy)
**Purpose**: Executes a single command simulation with specified randomness and returns both immediate and lookahead scores.
**Process**:
1. Creates engine copy and executes the command with given random seed
2. Calculates immediate score using `GuessedStateScore`
3. Initiates recursive lookahead calculation if depth remains
4. Handles timeouts gracefully by returning default scores
#### `EvaluateCommand`
**Purpose**: Lower-level command evaluation that handles different command types appropriately.
**Command Type Handling**:
- **Deterministic**: Single evaluation with average randomness (0.5)
- **Odds-based**: Two evaluations (success/failure) weighted by success probability
- **Non-deterministic**: Multiple evaluations with distributed random values, averaged
#### `BasicLookaheadCalculator`
**Purpose**: Recursive lookahead search that finds the best future command sequence and propagates scores backward.
**Features**:
- Uses transposition table to cache previously computed positions
- Handles depth limits and terminal states
- Returns futures for asynchronous computation
- Stores results in transposition table for reuse
### Strategy-Specific Scoring Functions
#### `AttackerScoreForState`
**Purpose**: Calculates state score from attacker perspective based on strategy type.
**Strategy Support**:
- `STRATEGY_ATTACK_CASTLES`: Prioritizes capturing castle positions
- `STRATEGY_ATTACK_UNITS`: Focuses on eliminating defender units
- `STRATEGY_HOLD_CASTLES`: Maintains control of captured castles
- `STRATEGY_CROSS_RIVERS`: Special water crossing objectives
- `STRATEGY_FLEE`: Escape-focused scoring
#### `DefenderScoreForState`
**Purpose**: Calculates state score from defender perspective.
**Strategy Support**:
- `STRATEGY_HOLD_CASTLES`: Defend critical castle positions
- `STRATEGY_SCATTER`: Spread units to avoid elimination
- `STRATEGY_FLEE`: Escape-focused scoring
#### `AttackerUnitsScore`
**Purpose**: Core unit valuation function that calculates total value of all units on the board with contextual modifiers.
**Features**:
- Uses `UnitValue` for individual unit calculations
- Applies distance multipliers based on proximity to objectives
- Handles special cases like undead, VIP units, and scattered defenders
- Incorporates castle bonuses and environmental penalties
### Specialized Strategy Functions
#### `DefenderScatterStrategyScoreForState`
**Purpose**: Implements scatter strategy scoring that rewards defensive units for staying far from enemies and friendlies.
#### `DefenderHoldCastlesStrategyScoreForState`
**Purpose**: Implements castle defense strategy with victory condition scoring.
#### `FleeStrategyScoreForState`
**Purpose**: Implements flee strategy that heavily penalizes remaining on the battlefield.
### Utility Functions
#### `AttackerMultiplierForTargetDistance`
**Purpose**: Calculates distance-based scoring multipliers for attackers based on proximity to priority targets.
**Features**:
- Uses recursive priority list evaluation
- Accounts for occupied vs. unoccupied targets
- Incorporates brave water crossing capabilities
- Uses cached action point distances for efficiency
#### `CommandSorter`
**Purpose**: Comparison function for ranking commands by lookahead score (primary) and immediate score (tiebreaker).
#### `IsDeterministic`
**Purpose**: Determines if a command type has predictable outcomes or requires random simulation.
### Performance and Caching
#### `EffectiveDistanceCache`
**Purpose**: Memoization cache for expensive distance calculations between units and targets.
#### `AttackerScorePerformanceLogger`
**Purpose**: Performance monitoring system that tracks call frequency and timing for `AttackerScoreForState`.
The function architecture supports parallel evaluation, caching, and recursive lookahead while maintaining separation between strategy-specific logic and core evaluation mechanics.
## Decision Tree Data Structures (NEW)
### CommandTreeNode
**Purpose**: Represents a single command execution and its consequences in the decision tree.
**Key Fields**:
- `commandIndex`: Index of the command in the original command list
- `commandType`: Type of command (MOVE, MELEE, END_TURN, etc.)
- `immediateScore`: Score of the game state immediately after this command
- `lookaheadScore`: Best achievable score considering future moves
- `resultingGameState`: Game state after command execution
- `children`: Vector of child nodes representing subsequent possible moves
- `playerId`, `depth`, `isDefender`: Metadata about the command context
**Features**:
- Stores complete game state for each decision point
- Maintains parent-child relationships for tree traversal
- Supports both immediate and lookahead scoring
- Contains metadata for debugging and analysis
### CommandDecisionTree
**Purpose**: Complete decision tree containing all evaluated command paths from a given position.
**Key Fields**:
- `rootNodes`: All possible first moves from the starting position
- `bestCommand`: Pointer to the optimal root command
- `maxDepth`: Maximum lookahead depth of the tree
- `totalNodes`: Total number of nodes in the tree (for statistics)
**Features**:
- Provides complete visibility into AI decision-making process
- Enables analysis of alternative moves and their consequences
- Supports tree statistics and debugging information
- Maintains backward compatibility through `GetBestCommandIndex()`
**Memory Management**:
- Uses `std::unique_ptr` for automatic memory cleanup
- `GameStateW` objects are stored directly (not shared pointers for simplicity)
- Tree structure ensures proper cleanup when nodes go out of scope
### Tree vs. Legacy Approach Comparison
| Aspect | Legacy (Single Best) | Tree-Based (Complete) |
|--------|---------------------|----------------------|
| **Output** | Best command only | Complete decision tree |
| **Memory** | Minimal | Higher (stores all paths) |
| **Analysis** | Limited visibility | Full decision transparency |
| **Debugging** | Single command info | Complete move sequences |
| **Performance** | Slightly faster | Comparable (same calculations) |
| **Compatibility** | Direct usage | Wrapper maintains compatibility |
### Usage Patterns
**For AI Decision Making**:
```cpp
auto treeFuture = BuildDecisionTree(pid, isDefender, depth, maxRepeat,
engine, strategy, utility, settings,
castles, apdCache, alCache, deadline);
CommandDecisionTree tree = treeFuture.get();
size_t bestCommand = tree.bestCommand->commandIndex;
```
**For Analysis and Debugging**:
```cpp
CommandDecisionTree tree = treeFuture.get();
// Examine all possible moves
for (const auto& rootNode : tree.rootNodes) {
std::cout << "Command " << rootNode->commandIndex
<< " Score: " << rootNode->lookaheadScore << std::endl;
// Traverse children to see consequences
for (const auto& child : rootNode->children) {
// ... analyze child moves
}
}
```
**Legacy Compatibility**:
```cpp
// Existing code continues to work unchanged
auto indexScoreFuture = BestCommandIndex(pid, isDefender, ...);
IndexAndScore result = indexScoreFuture.get();
size_t bestCommand = result.index;
```
The tree-based approach provides complete decision transparency while maintaining full backward compatibility with existing AI code.
## MCTS Alternative: Randomness Handling Recommendations
The new MCTS-based AI system is available in `MCTSAI.hpp/.cpp` and provides an alternative to the iterative deepening approach. However, the current MCTS implementation uses simplified randomness handling compared to the sophisticated approach in the original system.
### Current MCTS Limitations
1. **Expansion Phase**: Uses average rolls (0.5) for all commands during tree expansion
2. **Simulation Phase**: Uses random command selection with average rolls
3. **Missing**: No explicit chance nodes for commands with `HasOdds()`
4. **Missing**: No multi-sample evaluation for stochastic commands
### Recommended Improvements: Chance Node Integration
#### 1. **Explicit Chance Nodes** (Highest Priority)
For commands with `HasOdds()`, create explicit chance nodes in the MCTS tree:
```cpp
// During MCTSExpansion
if (descriptor->HasOdds()) {
// Create TWO child nodes: success and failure
auto successNode = CreateMCTSNode(commandIndex, SUCCESS_VARIANT);
auto failureNode = CreateMCTSNode(commandIndex, FAILURE_VARIANT);
// Execute with deterministic rolls (matching original system)
ExecuteWithRoll(successNode, 1.0 - successChance/2.0); // High roll
ExecuteWithRoll(failureNode, (1.0 - successChance)/2.0); // Low roll
// Set probability weights for selection
successNode->probabilityWeight = successChance;
failureNode->probabilityWeight = 1.0 - successChance;
}
```
#### 2. **Weighted Selection for Chance Nodes**
Modify `MCTSSelection` to handle chance nodes:
```cpp
if (node->isChanceNode) {
// Select based on probability distribution, not UCB1
return SelectByProbability(node->children);
} else {
// Normal UCB1 selection for decision nodes
return node->GetBestChild(explorationConstant);
}
```
#### 3. **Probability-Weighted Backpropagation**
Update backpropagation to account for chance node probabilities:
```cpp
void MCTSBackpropagation(MCTSNode* node, double reward) {
while (node) {
node->visitCount++;
// Weight reward by probability for chance nodes
double weightedReward = reward;
if (node->parent && node->parent->isChanceNode) {
weightedReward *= node->probabilityWeight;
}
node->totalReward += weightedReward;
node->averageReward = node->totalReward / node->visitCount;
node = node->parent;
}
}
```
#### 4. **Multi-Sample Commands**
For commands without explicit odds but with randomness, use stratified sampling:
```cpp
// During expansion, create multiple child nodes with different rolls
for (int sample = 0; sample < numSamples; ++sample) {
double roll = static_cast<double>(sample) / (numSamples - 1);
auto sampleNode = CreateMCTSNodeWithRoll(commandIndex, roll);
sampleNode->probabilityWeight = 1.0 / numSamples;
}
```
### Benefits of Chance Node Integration
1. **Accurate Evaluation**: Preserves the sophisticated randomness handling from the original system
2. **Better Convergence**: MCTS can properly explore both success/failure outcomes
3. **Realistic Simulations**: Tree accurately represents game's probability distributions
4. **Comparable Results**: Makes MCTS results directly comparable to iterative deepening
### Implementation Priority
1. **Phase 1**: Add explicit chance nodes for `HasOdds()` commands
2. **Phase 2**: Implement probability-weighted selection and backpropagation
3. **Phase 3**: Add multi-sample support for general stochastic commands
4. **Phase 4**: Optimize performance with lazy expansion of chance nodes
### Alternative: Determinization Approach
If explicit chance nodes prove too complex, consider **determinization**:
- Run multiple MCTS trees with different fixed random seeds
- Aggregate results across all determinizations
- Simpler to implement but potentially less accurate than explicit chance nodes
### Switching Between AI Systems
Both AI systems (`IterativeDeepeningAI` and `MCTSAI`) implement compatible interfaces. The algorithm is selected at **runtime** via the ShardokAIClient constructor:
```cpp
// Using Iterative Deepening (default)
ShardokAIClient client(playerId, isDefender, hexMap, settings);
// Or explicitly:
ShardokAIClient client(playerId, isDefender, hexMap, settings,
AIAlgorithmType::ITERATIVE_DEEPENING);
// Using MCTS
ShardokAIClient client(playerId, isDefender, hexMap, settings,
AIAlgorithmType::MCTS);
// Note: MCTS configuration can be customized via MCTSConfig:
// - maxIterations: 10000 (max MCTS iterations per move)
// - maxSimulationDepth: 10 (depth for rollout phase)
// - maxTreeDepth: 20 (max tree depth to prevent stack overflow)
// - explorationConstant: 1.414 (UCB1 exploration vs exploitation)
// - useMultithreading: true (APD cache is thread-safe with TLS + mutex protection)
// - numThreads: 4
```
The selection is made per AI client instance, allowing different algorithms for different players or game situations within the same server process.
#### Direct AI Usage (Lower Level)
Both AI systems can also be used directly:
```cpp
// Using Iterative Deepening directly
auto iterativeAI = IterativeDeepeningAI(playerId, isDefender, strategy,
castleCoords, apdCache, alCache);
auto result = iterativeAI.IterativeSearch(settings, state, commands, budget);
// Using MCTS directly
auto mctsAI = MCTSAI(playerId, isDefender, strategy,
castleCoords, apdCache, alCache);
auto result = mctsAI.Search(settings, state, commands, budget);
```
#### Algorithm Comparison
| Feature | Iterative Deepening | MCTS |
|---------|-------------------|------|
| **Randomness Handling** | Sophisticated (chance nodes, multi-sample) | Simplified (average rolls) |
| **Performance** | Single-threaded | Multithreaded |
| **Search Type** | Fixed depth with iterative deepening | Adaptive with time budget |
| **Memory Usage** | Lower | Higher (maintains tree) |
| **Max Tree Depth** | Limited by lookahead setting | Limited by `maxTreeDepth` config (default: 20) |
| **Tree Destruction** | Not applicable | Iterative (avoids stack overflow) |
| **Best For** | Precise evaluation, production | Performance testing, fast decisions |
The MCTS implementation provides a solid foundation. Known limitations:
1. **Randomness Handling**: Simplified compared to iterative deepening (no explicit chance nodes)
2. **Simulation Quality**: Uses random rollouts instead of sophisticated evaluation
Note: The APD cache is fully thread-safe using thread-local storage and mutex-protected shared cache.
<<<<<<< HEAD
Adding chance node handling and ensuring thread safety would make it a superior replacement for the iterative deepening approach while maintaining the sophisticated randomness evaluation that makes the current system effective.
## MCTS Configuration Options
The MCTS AI system provides extensive configuration through the `MCTSConfig` structure:
### Core MCTS Parameters
```cpp
struct MCTSConfig {
double explorationConstant = 1.414; // UCB1 constant (sqrt(2) by default)
int maxSimulationDepth = 1000; // Maximum depth for rollout
int maxTreeDepth = 2000; // Maximum tree depth to prevent stack overflow
bool useMultithreading = true; // Enable parallel MCTS
int numThreads = 16; // Number of threads for parallel MCTS (when enabled)
MCTSSimulationPolicy simulationPolicy = MCTSSimulationPolicy::BEST_IMMEDIATE;
bool enableTranspositionDetection = true; // Enable pruning of duplicate states
double immediateScoreTieBreakThreshold = 5.0; // When avg rewards differ by less than this, prefer higher immediate score
double visitCountTolerance = 0.05; // Treat visit counts as equal if within this % of best count
bool enableImmediateScoreInUCB1 = true; // Apply immediate score tie-breaking in UCB1 selection too
};
```
### Exploration vs Exploitation
- **`explorationConstant`**: Controls the exploration vs exploitation balance in UCB1 selection
- Higher values (>1.414): More exploration of unvisited nodes
- Lower values (<1.414): More exploitation of known good moves
- Default: 1.414 (√2, theoretical optimum for UCB1)
### Tree Structure Limits
- **`maxTreeDepth`**: Prevents stack overflow in deep game trees
- Default: 2000 (very high limit for most tactical scenarios)
- Terminal detection stops expansion when this depth is reached
- **`maxSimulationDepth`**: Controls rollout length during simulation phase
- Default: 1000 (sufficient for most tactical scenarios)
- Longer simulations provide more accurate estimates but use more time
### Multithreading Configuration
- **`useMultithreading`**: Enable/disable parallel MCTS execution
- Default: true (takes advantage of modern multi-core CPUs)
- Requires thread-safe game engine and scoring components
- **`numThreads`**: Number of worker threads for parallel tree building
- Default: 16 (adjust based on available CPU cores)
- More threads can improve search speed but with diminishing returns
### Simulation Policies
The `MCTSSimulationPolicy` enum controls how commands are selected during the rollout phase:
- **`RANDOM`**: Pure random selection from all available commands
- Fastest but least informed simulations
- Good baseline for testing MCTS convergence
- **`FILTERED_RANDOM`**: Random selection from AICommandFilter-approved commands
- Eliminates obviously bad moves (moving away from objectives, etc.)
- Better simulation quality with minimal overhead
- **`BEST_IMMEDIATE`**: Always choose command with highest immediate score
- Most informed simulations
- Slower but higher quality rollouts
- Default setting for production use
- **`WEIGHTED_BEST_IMMEDIATE`**: Random selection weighted by immediate score ranking
- Balances exploration with informed choice
- Alternative to pure greedy selection
### Transposition Detection
- **`enableTranspositionDetection`**: Enable pruning of duplicate game states
- Default: true (improves search efficiency)
- Uses hash-based state identification
- Prevents wasted computation on equivalent positions reached via different move sequences
### Immediate Score Tie-Breaking
These settings address MCTS's tendency to choose indirect paths when direct paths lead to the same outcome:
- **`immediateScoreTieBreakThreshold`**: Score difference threshold for tie-breaking
- Default: 5.0 (when backpropagated rewards differ by less than this, prefer immediate score)
- Helps AI choose direct moves over equivalent indirect sequences
- Improves user experience by reducing unnecessary intermediate moves
- **`visitCountTolerance`**: Visit count equality threshold for tie-breaking
- Default: 0.05 (5% tolerance - visit counts within this percentage are considered equal)
- Prevents minor visit count differences from overriding immediate score preferences
- **`enableImmediateScoreInUCB1`**: Apply immediate score tie-breaking during exploration
- Default: true (consistent tie-breaking in both exploration and final selection)
- When UCB1 values are very close, prefer nodes with higher immediate scores
- Improves convergence on direct paths to objectives
### Usage Example
```cpp
// Custom MCTS configuration for performance testing
MCTSConfig config;
config.explorationConstant = 2.0; // More exploration
config.simulationPolicy = MCTSSimulationPolicy::FILTERED_RANDOM; // Faster rollouts
config.numThreads = 8; // Reduce threads for testing environment
config.immediateScoreTieBreakThreshold = 10.0; // More aggressive tie-breaking
MCTSAI ai(playerId, isDefender, strategy, castleCoords, apdCache, alCache, config);
```
### Configuration Recommendations
**For Production Use:**
- Use default settings for balanced performance and quality
- Consider reducing `numThreads` on systems with limited CPU cores
- `BEST_IMMEDIATE` simulation policy provides highest quality decisions
**For Performance Testing:**
- `FILTERED_RANDOM` or `RANDOM` simulation policies for faster rollouts
- Lower `explorationConstant` (1.0) for more exploitation
- Disable transposition detection for baseline comparison
**For Analysis/Debugging:**
- Single-threaded execution (`useMultithreading = false`) for deterministic results
- Higher `immediateScoreTieBreakThreshold` to emphasize direct paths
- `BEST_IMMEDIATE` simulation for most predictable behavior
The configuration system allows fine-tuning MCTS behavior for different scenarios while maintaining compatibility with the existing AI infrastructure.
This scoring system provides a robust framework for tactical AI decision-making, balancing immediate tactical gains with strategic objectives while handling the uncertainty inherent in combat outcomes.
+1 -55
View File
@@ -154,7 +154,6 @@ cc_library(
hdrs = ["AICommandFilter.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
@@ -189,7 +188,6 @@ cc_library(
hdrs = ["AIScoreCalculator.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
@@ -211,7 +209,6 @@ cc_library(
hdrs = ["AIStrategy.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
],
@@ -299,7 +296,6 @@ cc_library(
hdrs = ["AITimeBudget.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
@@ -318,7 +314,6 @@ cc_library(
hdrs = ["IterativeDeepeningAI.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
@@ -335,53 +330,6 @@ cc_library(
],
)
cc_library(
name = "ai_config",
hdrs = ["AIConfig.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
)
cc_library(
name = "ai_mcts_clean",
srcs = ["MCTSCleanAI.cpp"],
hdrs = ["MCTSCleanAI.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
deps = [
":ai_attack_locations",
":ai_config",
":ai_iterative_deepening", # For SearchResult compatibility
":ai_score_calculator",
":ai_strategy",
":ai_time_budget",
"//src/main/cpp/net/eagle0/common:random_generator",
"//src/main/cpp/net/eagle0/common:sequence_random_generator",
"//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/action_point_distances:action_point_distances_cache",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
],
)
cc_library(
name = "ai_config",
hdrs = ["AIConfig.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
)
cc_library(
name = "shardok_ai_client",
srcs = ["ShardokAIClient.cpp"],
@@ -390,15 +338,13 @@ cc_library(
visibility = ["//visibility:public"],
deps = [
":ai_attacker_strategy_selector",
":ai_config",
":ai_defender_strategy_selector",
":ai_flee_decision_calculator",
":ai_iterative_deepening", # Direct dependency for runtime selection
":ai_iterative_deepening",
":ai_score_calculator",
":ai_time_budget",
":ai_water_crossing_command_chooser",
"//src/main/cpp/net/eagle0/common:time_utils",
"//src/main/cpp/net/eagle0/shardok/ai/mcts:mcts_ai", # Direct dependency for runtime selection
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
"@com_google_protobuf//:protobuf",
File diff suppressed because it is too large Load Diff
@@ -1,105 +0,0 @@
//
// MCTS-based AI system for Shardok
// Alternative to IterativeDeepeningAI using Monte Carlo Tree Search
//
#ifndef EAGLE0_MCTSAI_HPP
#define EAGLE0_MCTSAI_HPP
#include <chrono>
#include <future>
#include <memory>
#include <vector>
#include "AIStrategy.hpp"
#include "AITimeBudget.hpp"
#include "IterativeDeepeningAI.hpp" // For SearchResult compatibility
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
namespace shardok {
// Forward declarations
class ShardokEngine;
struct MCTSNode;
// Configuration for MCTS algorithm
struct MCTSConfig {
double explorationConstant = 1.414; // UCB1 constant (sqrt(2) by default)
int maxPlayerFlips = 1; // Number of player turn changes to evaluate
bool useMultithreading = true; // Enable parallel MCTS
int numThreads = 16; // Number of threads for parallel MCTS (when enabled)
};
class MCTSAI {
public:
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
using SearchResult = IterativeDeepeningAI::SearchResult;
MCTSAI(PlayerId playerId,
bool isDefender,
AIStrategy strategy,
const CoordsSet& castleCoords,
const APDCache& apdCache,
const ALCache& alCache,
MCTSConfig config = MCTSConfig{});
// Main search interface - compatible with IterativeDeepeningAI
[[nodiscard]] auto Search(
const GameSettingsSPtr& settings,
const GameStateW& state,
const std::vector<CommandProto>& commands,
const AITimeBudget& budget) const -> SearchResult;
// Get/set configuration
[[nodiscard]] auto GetConfig() const -> const MCTSConfig& { return config; }
void SetConfig(const MCTSConfig& newConfig) { config = newConfig; }
private:
PlayerId playerId;
bool isDefender;
AIStrategy strategy;
const CoordsSet& castleCoords;
const APDCache& apdCache;
const ALCache& alCache;
MCTSConfig config;
// Internal MCTS tree building
[[nodiscard]] auto BuildMCTSTree(
const ShardokEngine& engine,
const SettingsGetter& settingsGetter,
std::chrono::steady_clock::time_point deadline) const -> std::unique_ptr<MCTSNode>;
// MCTS algorithm phases
auto MCTSSelection(MCTSNode* root) const -> MCTSNode*;
auto MCTSExpansion(
MCTSNode* node,
const ShardokEngine& engine,
const SettingsGetter& settingsGetter) const -> MCTSNode*;
auto MCTSSimulation(
const ShardokEngine& engineState,
PlayerId currentPlayer,
const SettingsGetter& settingsGetter) const -> double;
auto MCTSBackpropagation(MCTSNode* node, double reward) const -> void;
// Helper functions
[[nodiscard]] auto IsTerminalForPlayer(
const GameStateW& gameState,
PlayerId currentPlayer,
const SettingsGetter& settingsGetter) const -> bool;
// Get coordinate information for logging
[[nodiscard]] std::string GetCommandCoordinateInfo(
net::eagle0::shardok::common::CommandType commandType,
size_t commandIndex,
const GameStateW& gameState,
const GameSettingsSPtr& settings) const;
};
} // namespace shardok
#endif // EAGLE0_MCTSAI_HPP
@@ -1,292 +0,0 @@
#include "MCTSCleanAI.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <limits>
#include "AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/common/SequenceRandomGenerator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
namespace shardok {
MCTSCleanAI::MCTSCleanAI(
PlayerId playerId,
bool isDefender,
const AIStrategy& strategy,
const CoordsSet& castleCoords,
const APDCache& apdCache,
const ALCache& alCache)
: ourPlayerId(playerId),
isDefender(isDefender),
strategy(strategy),
castleCoords(castleCoords),
apdCache(apdCache),
alCache(alCache),
rng(std::chrono::steady_clock::now().time_since_epoch().count()) {}
IterativeDeepeningAI::SearchResult MCTSCleanAI::Search(
const GameSettingsSPtr& settings,
const GameStateW& gameState,
const std::vector<CommandProto>& availableCommands,
const AITimeBudget& timeBudget) {
const auto startTime = std::chrono::steady_clock::now();
const auto maxTime = std::chrono::duration_cast<std::chrono::milliseconds>(
timeBudget.remainingBudget * 0.9); // 90% of budget
// Create root node
auto root = std::make_unique<MCTSNode>();
root->resultingGameState = gameState;
root->currentPlayer = ourPlayerId;
root->isOurTurn = true;
root->playerFlipsFromRoot = 0;
// Initialize untried commands for root
for (size_t i = 0; i < availableCommands.size(); ++i) { root->untriedCommands.push_back(i); }
int iterationCount = 0;
// Main MCTS loop
while (std::chrono::steady_clock::now() - startTime < maxTime) {
// 1. Selection - find leaf node to expand
MCTSNode* selected = Selection(root.get());
// 2. Expansion - add new child if possible
MCTSNode* expanded = Expansion(selected, settings, availableCommands);
// 3. Simulation - run random playout
double reward = Simulation(
expanded->resultingGameState,
expanded->currentPlayer,
expanded->playerFlipsFromRoot,
settings);
// 4. Backpropagation - update statistics
Backpropagation(expanded, reward);
iterationCount++;
// Early exit if all commands tried
if (root->untriedCommands.empty() && root->children.size() == availableCommands.size()) {
bool allChildrenFullyExplored = true;
for (const auto& child : root->children) {
if (child->visitCount < 10) { // Minimum visits per child
allChildrenFullyExplored = false;
break;
}
}
if (allChildrenFullyExplored) { break; }
}
}
// Select best command based on visit counts
size_t bestCommandIndex = 0;
int maxVisits = 0;
for (size_t i = 0; i < root->children.size(); ++i) {
if (root->children[i]->visitCount > maxVisits) {
maxVisits = root->children[i]->visitCount;
bestCommandIndex = root->children[i]->commandIndex;
}
}
// Create search result
IterativeDeepeningAI::SearchResult result;
result.bestCommandIndex = bestCommandIndex;
result.availableCommandCount = availableCommands.size();
result.depthAchieved = maxPlayerFlips; // Our max search depth
result.commandCountEvaluated = iterationCount;
result.completionReason = EvaluationCompletionReason::RAN_OUT_OF_TIME;
printf("MCTS Clean: %d iterations, best command %zu with %d visits\n",
iterationCount,
bestCommandIndex,
maxVisits);
return result;
}
// Selection - navigate to leaf using UCB1
MCTSCleanAI::MCTSNode* MCTSCleanAI::Selection(MCTSNode* root) {
MCTSNode* current = root;
while (!current->children.empty() && current->untriedCommands.empty()) {
current = SelectChild(current);
}
return current;
}
// Expansion - add new child node
MCTSCleanAI::MCTSNode* MCTSCleanAI::Expansion(
MCTSNode* node,
const GameSettingsSPtr& settings,
const std::vector<CommandProto>& availableCommands) {
// If we've reached max player flips, don't expand
if (node->playerFlipsFromRoot >= maxPlayerFlips) { return node; }
// If no untried commands, return current node
if (node->untriedCommands.empty()) { return node; }
// Pick an untried command
size_t cmdIndex = PickUntriedCommand(node);
// Create child engine and execute command
auto childEngine = CreateEngine(node->resultingGameState, settings);
PlayerId playerBefore = childEngine->GetCurrentPlayerId();
// Use sequence random generator for deterministic results
auto averageGenerator = std::make_shared<SequenceRandomGenerator>(std::vector<double>{0.5});
childEngine->PostCommand(playerBefore, cmdIndex, averageGenerator);
PlayerId playerAfter = childEngine->GetCurrentPlayerId();
// Create child node
bool isOurTurnAfter = (playerAfter == ourPlayerId);
auto child = std::make_unique<MCTSNode>(
cmdIndex,
availableCommands[cmdIndex].type(),
playerAfter,
isOurTurnAfter);
child->resultingGameState = childEngine->GetCurrentGameState();
child->parent = node;
// Track player flips
child->playerFlipsFromRoot = node->playerFlipsFromRoot;
if (playerBefore != playerAfter) { child->playerFlipsFromRoot++; }
// Initialize child's untried commands if we haven't hit max flips
if (child->playerFlipsFromRoot < maxPlayerFlips) {
auto childCommands = childEngine->GetAvailableCommandsForAIPlayer(playerAfter);
for (size_t i = 0; i < childCommands->size(); ++i) { child->untriedCommands.push_back(i); }
}
MCTSNode* childPtr = child.get();
node->children.push_back(std::move(child));
return childPtr;
}
// Simulation - run random playout from current state
double MCTSCleanAI::Simulation(
const GameStateW& startState,
PlayerId /* startPlayer */,
int startFlips,
const GameSettingsSPtr& settings) {
auto simEngine = CreateEngine(startState, settings);
int currentFlips = startFlips;
auto averageGenerator = std::make_shared<SequenceRandomGenerator>(std::vector<double>{0.5});
while (currentFlips < maxPlayerFlips) {
PlayerId currentPlayer = simEngine->GetCurrentPlayerId();
bool isOurTurn = (currentPlayer == ourPlayerId);
// Get available commands
auto commands = simEngine->GetAvailableCommandsForAIPlayer(currentPlayer);
if (commands->empty()) break;
// Pick best command based on whose turn it is
size_t bestCmd = 0;
double bestScore = isOurTurn ? -std::numeric_limits<double>::infinity()
: std::numeric_limits<double>::infinity();
for (size_t i = 0; i < commands->size(); ++i) {
auto testEngine = CreateEngine(simEngine->GetCurrentGameState(), settings);
testEngine->PostCommand(currentPlayer, i, averageGenerator);
// Always score from our perspective
double score = ScoreFromOurPerspective(
testEngine->GetCurrentGameState(),
settings->GetGetter());
// Our turn: maximize our score, Opponent turn: minimize our score
bool shouldSelect = isOurTurn ? (score > bestScore) : (score < bestScore);
if (shouldSelect) {
bestScore = score;
bestCmd = i;
}
}
// Execute chosen command
PlayerId playerBefore = simEngine->GetCurrentPlayerId();
simEngine->PostCommand(currentPlayer, bestCmd, averageGenerator);
PlayerId playerAfter = simEngine->GetCurrentPlayerId();
// Track player flips
if (playerBefore != playerAfter) { currentFlips++; }
}
return ScoreFromOurPerspective(simEngine->GetCurrentGameState(), settings->GetGetter());
}
// Backpropagation - update node statistics
void MCTSCleanAI::Backpropagation(MCTSNode* node, double score) {
while (node != nullptr) {
node->visitCount++;
node->totalScore += score; // Always from our perspective
node->averageScore = node->totalScore / node->visitCount;
node = node->parent;
}
}
// Helper functions
double MCTSCleanAI::ScoreFromOurPerspective(
const GameStateW& gameState,
const SettingsGetter& settingsGetter) {
return AIScoreCalculator::GuessedStateScore(
isDefender,
gameState,
strategy,
castleCoords,
settingsGetter,
apdCache,
alCache);
}
MCTSCleanAI::MCTSNode* MCTSCleanAI::SelectChild(MCTSNode* node) {
MCTSNode* bestChild = nullptr;
double bestUCB1 = node->isOurTurn ? -std::numeric_limits<double>::infinity()
: std::numeric_limits<double>::infinity();
for (const auto& child : node->children) {
double exploitation = child->averageScore;
double exploration =
explorationConstant * std::sqrt(std::log(node->visitCount) / child->visitCount);
double ucb1 = exploitation + exploration;
// Our turn: pick highest UCB1, Opponent turn: pick lowest UCB1
bool shouldSelect = node->isOurTurn ? (ucb1 > bestUCB1) : (ucb1 < bestUCB1);
if (shouldSelect) {
bestUCB1 = ucb1;
bestChild = child.get();
}
}
return bestChild;
}
size_t MCTSCleanAI::PickUntriedCommand(MCTSNode* node) {
if (node->untriedCommands.empty()) {
return 0; // Should not happen
}
// Pick random untried command
std::uniform_int_distribution<size_t> indexDist(0, node->untriedCommands.size() - 1);
size_t randomIndex = indexDist(rng);
size_t cmdIndex = node->untriedCommands[randomIndex];
node->untriedCommands.erase(node->untriedCommands.begin() + randomIndex);
return cmdIndex;
}
std::shared_ptr<ShardokEngine> MCTSCleanAI::CreateEngine(
const GameStateW& gameState,
const GameSettingsSPtr& settings) {
return std::make_shared<ShardokEngine>(settings, gameState);
}
} // namespace shardok
@@ -1,133 +0,0 @@
#pragma once
#include <memory>
#include <random>
#include <vector>
#include "AIAttackLocations.hpp"
#include "AIConfig.hpp"
#include "AIStrategy.hpp"
#include "AITimeBudget.hpp"
#include "IterativeDeepeningAI.hpp" // For SearchResult
#include "src/main/cpp/net/eagle0/common/RandomGenerator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.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"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
namespace shardok {
// Forward declare cache type aliases
using APDCache = std::shared_ptr<ActionPointDistancesCache>;
using ALCache = std::unique_ptr<AttackLocationsCache>;
} // namespace shardok
namespace shardok {
// Clean MCTS implementation following the design document
class MCTSCleanAI {
public:
// Clean node structure with single perspective scoring
struct MCTSNode {
// Command that led to this state
size_t commandIndex;
net::eagle0::shardok::common::CommandType commandType;
// Game state after executing the command
GameStateW resultingGameState;
PlayerId currentPlayer; // Whose turn it is in this state
// Tree position
int playerFlipsFromRoot; // Number of player changes from root
bool isOurTurn; // currentPlayer == our AI's playerId
// MCTS statistics (always from our perspective)
int visitCount = 0;
double totalScore = 0.0;
double averageScore = 0.0;
// Tree structure
std::vector<std::unique_ptr<MCTSNode>> children;
std::vector<size_t> untriedCommands;
MCTSNode* parent = nullptr;
// Constructor
MCTSNode(
size_t cmdIndex,
net::eagle0::shardok::common::CommandType cmdType,
PlayerId currentPlayerId,
bool ourTurn)
: commandIndex(cmdIndex),
commandType(cmdType),
currentPlayer(currentPlayerId),
playerFlipsFromRoot(0),
isOurTurn(ourTurn) {}
// Root constructor
MCTSNode()
: commandIndex(0),
commandType(net::eagle0::shardok::common::UNKNOWN_COMMAND),
currentPlayer(0),
playerFlipsFromRoot(0),
isOurTurn(true) {}
};
MCTSCleanAI(
PlayerId playerId,
bool isDefender,
const AIStrategy& strategy,
const CoordsSet& castleCoords,
const APDCache& apdCache,
const ALCache& alCache);
// Main search function compatible with existing interface
IterativeDeepeningAI::SearchResult Search(
const GameSettingsSPtr& settings,
const GameStateW& gameState,
const std::vector<CommandProto>& availableCommands,
const AITimeBudget& timeBudget);
private:
PlayerId ourPlayerId;
bool isDefender;
AIStrategy strategy;
CoordsSet castleCoords;
const APDCache& apdCache;
const ALCache& alCache;
// MCTS parameters
static constexpr int maxPlayerFlips = 5; // Stop after N player changes
static constexpr double explorationConstant = 1.414; // sqrt(2)
// Random number generation
std::mt19937 rng;
std::uniform_real_distribution<double> uniformDist{0.0, 1.0};
// Core MCTS algorithms
MCTSNode* Selection(MCTSNode* root);
MCTSNode* Expansion(
MCTSNode* node,
const GameSettingsSPtr& settings,
const std::vector<CommandProto>& availableCommands);
double Simulation(
const GameStateW& startState,
PlayerId startPlayer,
int startFlips,
const GameSettingsSPtr& settings);
void Backpropagation(MCTSNode* node, double score);
// Helper functions
double ScoreFromOurPerspective(
const GameStateW& gameState,
const SettingsGetter& settingsGetter);
MCTSNode* SelectChild(MCTSNode* node);
size_t PickUntriedCommand(MCTSNode* node);
std::shared_ptr<ShardokEngine> CreateEngine(
const GameStateW& gameState,
const GameSettingsSPtr& settings);
};
} // namespace shardok
@@ -1,147 +0,0 @@
# MCTS Player-Flip Depth System Design
## Overview
Replace command-count-based depth limits with player-turn-aware depth that naturally aligns with game structure. All nodes evaluate to the same player-flip depth to ensure comparable scores.
## Core Concepts
### Player-Flip Depth
- **Depth 1**: Evaluate until first player flip (complete our turn)
- **Depth 2**: Continue through opponent's full turn
- **Depth 3**: Continue through our next full turn
- **Depth N**: N complete player turn changes
### Minimax Selection
- **Always evaluate from original AI's perspective**
- **Our turn**: Select moves that maximize our score
- **Opponent's turn**: Select moves that minimize our score
- Same backpropagation value regardless of whose turn
## Implementation Plan
### 1. Configuration Changes
```cpp
struct MCTSConfig {
double explorationConstant = 1.414;
int maxPlayerFlips = 2; // How many player changes to evaluate
bool useMultithreading = true;
int numThreads = 16;
// REMOVED: maxTreeDepth - all nodes go to same player-flip depth
// REMOVED: maxSimulationDepth - replaced by maxPlayerFlips
};
```
### 2. Node Structure Updates
```cpp
struct MCTSNode {
// Existing fields...
// New fields for player-flip tracking
int playerFlipsFromRoot = 0;
bool isMaximizingPlayer = true; // true = our turn, false = opponent's
// Modified selection for minimax
MCTSNode* GetBestChild(double explorationConstant) {
if (isMaximizingPlayer) {
return GetChildWithHighestUCB1(explorationConstant);
} else {
return GetChildWithLowestUCB1(explorationConstant);
}
}
};
```
### 3. Expansion Rules
- **Continue expanding** until reaching `maxPlayerFlips` player changes
- **No arbitrary depth limit** - accept theoretical stack overflow risk
- **Mark as terminal** only when:
- Game is over
- Reached `maxPlayerFlips` player changes
- No available commands
### 4. Key Implementation Details
#### Terminal Detection
```cpp
bool IsTerminalForExpansion() {
return playerFlipsFromRoot >= maxPlayerFlips ||
gameIsOver ||
noCommandsAvailable;
}
```
#### Player Tracking
```cpp
// When expanding END_TURN or END_PLAYER_SETUP
child->playerFlipsFromRoot = parent->playerFlipsFromRoot + 1;
child->isMaximizingPlayer = !parent->isMaximizingPlayer;
```
#### UCB1 for Minimax
- Maximizing player: Choose highest UCB1
- Minimizing player: Choose lowest UCB1
- Unvisited nodes: Extreme values to force exploration
### 5. Simulation Strategy
Simulations run until `maxPlayerFlips` is reached:
- Random moves for both players
- Stop at player-flip boundaries
- Always evaluate from original AI perspective
## Benefits
### Consistent Evaluation
- All leaf nodes at same player-flip depth
- Scores are directly comparable
- No apples-to-oranges comparison issues
### Natural Game Structure
- Respects turn boundaries
- Complete tactical sequences evaluated
- Opponent responses properly modeled
### Strategic Depth Control
- **Setup/Early**: `maxPlayerFlips = 1` (fast, local tactics)
- **Mid-game**: `maxPlayerFlips = 2` (balanced)
- **Critical**: `maxPlayerFlips = 3+` (deep strategy)
## Implementation Order
1. **Phase 1**: Update MCTSConfig
- Remove `maxTreeDepth` and `maxSimulationDepth`
- Add `maxPlayerFlips`
2. **Phase 2**: Modify MCTSNode
- Add `playerFlipsFromRoot` and `isMaximizingPlayer`
- Update child selection for minimax
3. **Phase 3**: Update Expansion
- Track player flips
- Remove depth-based termination
- Only terminate at player-flip boundaries
4. **Phase 4**: Fix Selection
- Implement minimax selection based on `isMaximizingPlayer`
- Modify UCB1 interpretation
5. **Phase 5**: Update Simulation
- Run until `maxPlayerFlips` reached
- Handle both player perspectives
## Testing Strategy
1. Verify all evaluations reach same player-flip depth
2. Confirm opponent chooses minimizing moves
3. Test with different `maxPlayerFlips` settings
4. Validate score consistency across tree
## Notes
- Stack overflow risk accepted for evaluation consistency
- Each unit can make multiple moves per turn (move + scout + attack)
- Maximum ~10 units per player limits practical depth
- END_TURN and END_PLAYER_SETUP both count as player flips
@@ -13,13 +13,11 @@
#include <google/protobuf/util/message_differencer.h>
#include "AIAttackerStrategySelector.hpp"
#include "AIConfig.hpp" // Must come before other AI includes
#include "AIDefenderStrategySelector.hpp"
#include "AIFleeDecisionCalculator.hpp"
#include "AIScoreUtilities.hpp"
#include "AITimeBudget.hpp"
#include "IterativeDeepeningAI.hpp"
#include "mcts/MCTSAI.hpp"
#include "src/main/cpp/net/eagle0/common/TimeUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateGuesser.hpp"
@@ -44,11 +42,9 @@ ShardokAIClient::ShardokAIClient(
const PlayerId playerId,
const bool isDefender,
const HexMap *hexMap,
const SettingsGetter &settings,
const AIAlgorithmType aiAlgorithmType)
const SettingsGetter &settings)
: playerId(playerId),
isDefender(isDefender),
aiAlgorithmType(aiAlgorithmType),
alCache(std::make_unique<AttackLocationsCache>(hexMap, settings)),
waterCrossingCommandChooser(playerId, apdCache) {
// Pre-generate the most common cache entries for better performance
@@ -125,19 +121,11 @@ auto ShardokAIClient::StandardChooseCommandIndex(
waterCrossingCommandChooser,
realAvailableCommands);
// AI implementation chosen at runtime via constructor parameter
IterativeDeepeningAI::SearchResult search_result;
if (aiAlgorithmType == AIAlgorithmType::MCTS) {
// Using Monte Carlo Tree Search AI
MCTSAI ai(playerId, isDefender, strategy, castleCoords, apdCache, alCache);
search_result = ai.Search(settings, guessedState, realAvailableCommands, timeBudget);
} else {
// Using Iterative Deepening AI (default)
IterativeDeepeningAI ai(playerId, isDefender, strategy, castleCoords, apdCache, alCache);
search_result =
ai.IterativeSearch(settings, guessedState, realAvailableCommands, timeBudget);
}
// Use iterative deepening AI for Phase 2 implementation
IterativeDeepeningAI
iterativeAI(playerId, isDefender, strategy, castleCoords, apdCache, alCache);
auto search_result =
iterativeAI.IterativeSearch(settings, guessedState, realAvailableCommands, timeBudget);
CommandChoiceResults result{};
result.chosenIndex = search_result.bestCommandIndex;
@@ -12,7 +12,6 @@
#include <vector>
#include "src/main/cpp/net/eagle0/common/RandomGenerator.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIConfig.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AITimeBudget.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCommandChooser.hpp"
@@ -39,7 +38,6 @@ class ShardokAIClient {
private:
const PlayerId playerId;
const bool isDefender;
const AIAlgorithmType aiAlgorithmType;
APDCache apdCache = std::make_shared<ActionPointDistancesCache>();
ALCache alCache;
@@ -69,8 +67,7 @@ public:
PlayerId playerId,
bool isDefender,
const HexMap* hexMap,
const SettingsGetter& settings,
AIAlgorithmType aiAlgorithmType = AIAlgorithmType::ITERATIVE_DEEPENING);
const SettingsGetter& settings);
~ShardokAIClient() = default;
[[nodiscard]] auto GetPlayerId() const -> PlayerId { return playerId; }
@@ -1,29 +0,0 @@
load("//tools:copts.bzl", "COPTS")
cc_library(
name = "mcts_ai",
srcs = ["MCTSAI.cpp"],
hdrs = ["MCTSAI.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai:__pkg__",
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
deps = [
"//src/main/cpp/net/eagle0/common:random_generator",
"//src/main/cpp/net/eagle0/common:sequence_random_generator",
"//src/main/cpp/net/eagle0/shardok/ai:ai_command_filter",
"//src/main/cpp/net/eagle0/shardok/ai:ai_iterative_deepening", # For SearchResult compatibility
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_calculator",
"//src/main/cpp/net/eagle0/shardok/ai:ai_strategy",
"//src/main/cpp/net/eagle0/shardok/ai:ai_time_budget",
"//src/main/cpp/net/eagle0/shardok/ai/mcts/internal:mcts_node",
"//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/action_point_distances:action_point_distances_cache",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
],
)
@@ -1,869 +0,0 @@
//
// MCTS-based AI implementation for Shardok
//
#include "MCTSAI.hpp"
#include <algorithm>
#include <cmath>
#include <future>
#include <limits>
#include <mutex>
#include <random>
#include <thread>
#include "internal/MCTSNode.hpp"
#include "src/main/cpp/net/eagle0/common/SequenceRandomGenerator.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AICommandFilter.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
namespace shardok {
// Type alias for internal MCTSNode
using MCTSNode = internal::MCTSNode;
// Static helper for average random generator
static const std::vector _averageSequence = {0.5};
static const auto _averageGenerator = std::make_shared<SequenceRandomGenerator>(_averageSequence);
MCTSAI::MCTSAI(
const PlayerId playerId,
const bool isDefender,
AIStrategy strategy,
const CoordsSet& castleCoords,
const APDCache& apdCache,
const ALCache& alCache,
MCTSConfig config)
: playerId(playerId),
isDefender(isDefender),
strategy(std::move(strategy)),
castleCoords(castleCoords),
apdCache(apdCache),
alCache(alCache),
config(std::move(config)) {}
auto MCTSAI::Search(
const GameSettingsSPtr& settings,
const GameStateW& state,
const std::vector<CommandProto>& commands,
const AITimeBudget& budget) const -> SearchResult {
const auto startTime = std::chrono::steady_clock::now();
SearchResult result;
if (commands.empty()) {
result.searchCompleted = true;
result.completionReason = EvaluationCompletionReason::RAN_OUT_OF_COMMANDS;
return result;
}
if (commands.size() == 1) {
result.searchCompleted = true;
result.bestCommandIndex = 0;
result.bestScore = 0;
result.availableCommandCount = 1;
result.depthAchieved = 1;
result.commandCountEvaluated = 1;
result.completionReason = EvaluationCompletionReason::RAN_OUT_OF_COMMANDS;
return result;
}
const auto& settingsGetter = settings->GetGetter();
// Compute critical tiles once to avoid 8.5% runtime overhead in ShardokEngine construction
const auto criticalTiles = GetCriticalTileLocations(state->hex_map());
const auto guessedEngine = ShardokEngine(settings, state, criticalTiles);
const auto deadline = startTime + budget.remainingBudget;
// Build MCTS tree
auto rootNode = BuildMCTSTree(guessedEngine, settingsGetter, criticalTiles, deadline);
if (rootNode) {
// Get best command from tree
const MCTSNode* bestChild = rootNode->GetBestFinalChild();
if (bestChild) {
result.searchCompleted = true;
result.bestCommandIndex = bestChild->commandIndex;
result.bestScore = bestChild->lookaheadScore;
result.completionReason = EvaluationCompletionReason::RAN_OUT_OF_COMMANDS;
// Calculate max depth reached in the tree
std::function<int(const MCTSNode*)> getMaxDepth = [&](const MCTSNode* node) -> int {
int maxChildDepth = node->depth;
for (const auto& child : node->children) {
maxChildDepth = std::max(maxChildDepth, getMaxDepth(child.get()));
}
return maxChildDepth;
};
result.depthAchieved = getMaxDepth(rootNode.get());
// Count total nodes visited
std::function<size_t(const MCTSNode*)> countVisited =
[&](const MCTSNode* node) -> size_t {
size_t count = (node->visitCount > 0) ? 1 : 0;
for (const auto& child : node->children) { count += countVisited(child.get()); }
return count;
};
result.commandCountEvaluated = countVisited(rootNode.get());
result.availableCommandCount = commands.size();
// MCTS-specific logging
printf("MCTS: Selected command %zu (visit:%d, reward:%.2f, lookahead:%.2f) from %zu "
"options\n",
bestChild->commandIndex,
bestChild->visitCount,
bestChild->averageReward,
bestChild->lookaheadScore,
rootNode->children.size());
// Log top 3 commands for debugging with their best sequences
std::vector<MCTSNode*> sortedChildren;
for (const auto& child : rootNode->children) { sortedChildren.push_back(child.get()); }
std::ranges::sort(sortedChildren, [](const MCTSNode* a, const MCTSNode* b) {
return a->visitCount > b->visitCount;
});
printf("MCTS: Top commands by visits:\n");
for (size_t i = 0; i < std::min(static_cast<size_t>(3), sortedChildren.size()); ++i) {
auto* child = sortedChildren[i];
printf(" [%zu] cmd:%zu visits:%d immediate:%.2f backprop:%.2f type:%s",
i,
child->commandIndex,
child->visitCount,
child->immediateScore,
child->averageReward,
CommandType_Name(child->commandType).c_str());
// Show unit and target info for commands that have them
if (child->actorUnitId >= 0) { printf(" unit:%d", child->actorUnitId); }
if (child->targetRow >= 0 && child->targetCol >= 0) {
printf(" target:(%d,%d)", child->targetRow, child->targetCol);
}
// Show sequence preview for this command
if (!child->children.empty()) {
// Find best child by visits
MCTSNode* bestNext = nullptr;
int maxVisits = 0;
for (const auto& grandchild : child->children) {
if (grandchild->visitCount > maxVisits) {
maxVisits = grandchild->visitCount;
bestNext = grandchild.get();
}
}
if (bestNext) {
printf(" -> %s", CommandType_Name(bestNext->commandType).c_str());
}
}
printf("\n");
}
printf("MCTS: Tree stats - max depth:%zu, total nodes:%zu, root visits:%d\n",
result.depthAchieved,
result.commandCountEvaluated,
rootNode->visitCount);
// Log the best command sequence from the chosen command
struct SequenceNode {
size_t commandIndex;
std::string commandType;
int actorUnitId;
int targetRow;
int targetCol;
double immediateScore;
double backpropScore;
};
std::vector<SequenceNode> bestSequence;
bestSequence.reserve(5);
auto current = const_cast<MCTSNode*>(bestChild);
double sequenceScore = bestChild->averageReward;
// Trace the best path from chosen command (most visited child at each level)
while (current) {
bestSequence.push_back(
{current->commandIndex,
CommandType_Name(current->commandType),
current->actorUnitId,
current->targetRow,
current->targetCol,
current->immediateScore,
current->averageReward});
// If no children, we've reached the end of the sequence
if (current->children.empty()) { break; }
// Find most visited child
MCTSNode* bestChildNode = nullptr;
int maxVisits = 0;
for (const auto& child : current->children) {
if (child->visitCount > maxVisits) {
maxVisits = child->visitCount;
bestChildNode = child.get();
}
}
current = bestChildNode;
if (current) {
sequenceScore = current->averageReward; // Update to final score
}
}
if (!bestSequence.empty()) {
printf("MCTS: Best sequence from chosen command (final: %.2f):\n", sequenceScore);
for (size_t i = 0; i < bestSequence.size(); ++i) {
const auto& [commandIndex, commandType, actorUnitId, targetRow, targetCol, immediateScore, backpropScore] =
bestSequence[i];
printf(" %zu. cmd:%zu %s", i + 1, commandIndex, commandType.c_str());
// Add unit and target info if present
if (actorUnitId >= 0) { printf(" unit:%d", actorUnitId); }
if (targetRow >= 0 && targetCol >= 0) {
printf(" target:(%d,%d)", targetRow, targetCol);
}
printf(" (immediate:%.2f, backprop:%.2f)\n", immediateScore, backpropScore);
}
}
}
}
const auto endTime = std::chrono::steady_clock::now();
result.timeUsed = std::chrono::duration_cast<std::chrono::milliseconds>(endTime - startTime);
return result;
}
auto MCTSAI::BuildMCTSTree(
const ShardokEngine& engine,
const SettingsGetter& settingsGetter,
const CoordsSet& criticalTileCoords,
const std::chrono::steady_clock::time_point deadline) const -> std::unique_ptr<MCTSNode> {
// Clear transposition registry for this search
if (config.enableTranspositionDetection) { stateRegistry.clear(); }
// Create root node
auto root = std::make_unique<MCTSNode>(
0,
net::eagle0::shardok::common::END_TURN_COMMAND,
playerId,
0,
isDefender);
root->resultingGameState = engine.GetCurrentGameState();
// Register root node in transposition table if enabled
if (config.enableTranspositionDetection) {
root->stateHash = root->resultingGameState.ComputeFNV1aHash();
stateRegistry[root->stateHash] = root.get();
}
// Initialize root with available commands
const CommandListSPtr rootCommands = engine.GetAvailableCommandsForAIPlayer(playerId);
if (!rootCommands || rootCommands->empty()) { return root; }
// Filter commands for better performance
const std::vector<size_t> filteredIndices = AICommandFilter::FilterCommands(
rootCommands,
playerId,
isDefender,
engine.GetCurrentGameState(),
settingsGetter,
apdCache);
root->untriedCommands = filteredIndices;
root->fullyExpanded = filteredIndices.empty();
// Main MCTS loop
int iterations = 0;
printf("MCTS: Starting search with %zu filtered commands (budget: %.0fms)\n",
filteredIndices.size(),
std::chrono::duration<double, std::milli>(deadline - std::chrono::steady_clock::now())
.count());
if (config.useMultithreading) {
// Parallel MCTS: Run iterations until time budget expires
const int numThreads =
std::min(config.numThreads, static_cast<int>(std::thread::hardware_concurrency()));
std::vector<std::future<void>> futures;
std::mutex treeMutex; // Protect tree updates
std::atomic totalIterations{0}; // Track iterations across threads
for (int t = 0; t < numThreads; ++t) {
futures.push_back(std::async(std::launch::async, [&, this] {
while (std::chrono::steady_clock::now() < deadline) {
++totalIterations;
// Selection and expansion need locking
MCTSNode* selected;
{
std::lock_guard lock(treeMutex);
selected = MCTSSelection(root.get());
if (selected && !selected->isTerminal && selected->CanExpand()) {
selected = MCTSExpansion(
selected,
engine,
settingsGetter,
criticalTileCoords);
}
}
// Skip simulation if selection failed (all children redundant)
if (!selected) continue;
// Simulation can run in parallel from the selected node's state
// Note: Creating engine from node state is correct for MCTS simulation
// Backpropagation needs locking
{
ShardokEngine nodeEngine(
engine.GetGameSettings(),
selected->resultingGameState,
criticalTileCoords);
const double reward =
MCTSSimulation(nodeEngine, selected->playerId, settingsGetter);
std::lock_guard lock(treeMutex);
MCTSBackpropagation(selected, reward);
}
}
}));
}
// Wait for all threads to complete
for (auto& future : futures) { future.get(); }
iterations = totalIterations.load(); // Get total from all threads
} else {
// Sequential MCTS - run until time budget expires
while (std::chrono::steady_clock::now() < deadline) {
// Selection
MCTSNode* selected = MCTSSelection(root.get());
// Skip if selection failed (all children redundant)
if (!selected) continue;
// Expansion
if (!selected->isTerminal && selected->CanExpand()) {
selected = MCTSExpansion(selected, engine, settingsGetter, criticalTileCoords);
}
// Simulation (from selected node's state)
ShardokEngine nodeEngine(
engine.GetGameSettings(),
selected->resultingGameState,
criticalTileCoords);
double reward = MCTSSimulation(
nodeEngine,
selected->playerId,
settingsGetter); // Use node's player, not original player
// Backpropagation
MCTSBackpropagation(selected, reward);
iterations++;
}
}
printf("MCTS: Completed %d iterations, root has %zu children\n",
iterations,
root->children.size());
return root;
}
auto MCTSAI::MCTSSelection(MCTSNode* root) const -> MCTSNode* {
MCTSNode* current = root;
while (!current->isTerminal && !current->isRedundant) {
if (current->CanExpand()) {
return current; // Node has untried commands
} else if (!current->children.empty()) {
current = current->GetBestChild(config.explorationConstant);
if (!current) break;
} else {
break; // Leaf node
}
}
return current;
}
auto MCTSAI::MCTSExpansion(
MCTSNode* node,
const ShardokEngine& engine,
const SettingsGetter& settingsGetter,
const CoordsSet& criticalTileCoords) const -> MCTSNode* {
static int expansionCallCount = 0;
if (expansionCallCount < 3) {
printf("MCTSExpansion called %d: node depth:%d untried:%zu\n",
expansionCallCount++,
node->depth,
node->untriedCommands.size());
}
if (node->untriedCommands.empty()) return node;
// Don't expand beyond maximum depth to prevent unbounded tree growth
if (node->depth >= config.maxTreeDepth) {
node->fullyExpanded = true;
node->untriedCommands.clear();
return node;
}
// Pick a random untried command
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution dis(0, static_cast<int>(node->untriedCommands.size() - 1));
const size_t randomIndex = dis(gen);
const auto commandIndex = node->untriedCommands[randomIndex];
node->untriedCommands.erase(node->untriedCommands.begin() + randomIndex);
// Create engine from the node's current state (not root state!)
const auto nodeEngine = std::make_shared<ShardokEngine>(
engine.GetGameSettings(),
node->resultingGameState,
criticalTileCoords);
// Get command descriptor from the node's state
const CommandListSPtr commands = nodeEngine->GetAvailableCommandsForAIPlayer(node->playerId);
if (!commands || commandIndex >= commands->size()) return node;
const auto& command = commands->at(commandIndex);
const auto commandType = command->GetCommandType();
const auto descriptor = command->GetCommandProto();
// Create child node
auto child = std::make_unique<MCTSNode>(
commandIndex,
commandType,
node->playerId,
node->depth + 1,
node->isDefender);
child->parent = node;
// Extract actor unit ID if present
if (descriptor.has_actor()) { child->actorUnitId = descriptor.actor().value(); }
// Extract target coordinates if present
// Note: In protobuf3, target is always present but may have default values
// We'll always capture the coordinates - commands without targets will have (-1,-1) by default
const auto& target = descriptor.target();
child->targetRow = target.row();
child->targetCol = target.column();
// Declare variables that will be used later
// Handle randomness appropriately based on command type
if (command->HasOdds()) {
// For commands with odds, use average roll for expansion
// For expansion, use average roll regardless of success chance
const auto generator = std::make_shared<SequenceRandomGenerator>(std::vector{0.5});
nodeEngine->PostCommand(node->playerId, commandIndex, generator);
} else {
// Use average generator for deterministic evaluation
nodeEngine->PostCommand(node->playerId, commandIndex, _averageGenerator);
}
child->resultingGameState = nodeEngine->GetCurrentGameState();
// Check whose turn it is after executing the command
PlayerId currentPlayer = nodeEngine->GetCurrentPlayerId();
bool isOurTurn = (currentPlayer == playerId);
// Update child's player ID to reflect whose turn it actually is
child->playerId = currentPlayer;
// Calculate immediate score (always from our perspective)
child->immediateScore = AIScoreCalculator::GuessedStateScore(
isDefender, // Use our original role, not node's
child->resultingGameState,
strategy,
castleCoords,
settingsGetter,
apdCache,
alCache);
// Initially, lookahead score equals immediate score
child->lookaheadScore = child->immediateScore;
// Debug: Log first few expansions to see what's happening
static int expansionCount = 0;
if (expansionCount < 5) {
printf("MCTS Expansion %d: cmd:%zu type:%s immediate_score:%.2f\n",
expansionCount++,
commandIndex,
CommandType_Name(commandType).c_str(),
child->immediateScore);
}
// Check if terminal
child->isTerminal = IsTerminalForPlayer(child->resultingGameState, playerId, settingsGetter);
// Transposition detection
if (config.enableTranspositionDetection) {
child->stateHash = child->resultingGameState.ComputeFNV1aHash();
auto existingIt = stateRegistry.find(child->stateHash);
if (existingIt != stateRegistry.end()) {
MCTSNode* existingNode = existingIt->second;
// Apply tie-breaking rules to determine which node to keep
bool shouldPruneChild = false;
if (child->depth > existingNode->depth) {
// Rule 1: Prune deeper node (current child is deeper)
shouldPruneChild = true;
} else if (child->depth == existingNode->depth) {
// Rule 2: At same depth, prune node with higher command index
if (child->commandIndex > existingNode->commandIndex) {
shouldPruneChild = true;
} else {
// Current child wins - mark existing node as redundant
existingNode->isRedundant = true;
stateRegistry[child->stateHash] = child.get(); // Update registry
}
} else {
// Child is shallower - mark existing node as redundant
existingNode->isRedundant = true;
stateRegistry[child->stateHash] = child.get(); // Update registry
}
if (shouldPruneChild) {
child->isRedundant = true;
// Don't expand redundant nodes
}
} else {
// New state - register it
stateRegistry[child->stateHash] = child.get();
}
}
// Get available commands for child - only if it's still our turn and not redundant
if (!child->isTerminal && !child->isRedundant && isOurTurn) {
const CommandListSPtr childCommands =
nodeEngine->GetAvailableCommandsForAIPlayer(currentPlayer);
if (childCommands) {
const std::vector<size_t> childFiltered = AICommandFilter::FilterCommands(
childCommands,
currentPlayer,
isDefender, // Use our original role
child->resultingGameState,
settingsGetter,
apdCache);
child->untriedCommands = childFiltered;
child->fullyExpanded = childFiltered.empty();
}
} else if (!isOurTurn) {
// Mark as terminal if it's not our turn - we can't expand opponent moves
child->isTerminal = true;
child->fullyExpanded = true;
}
// Update parent's expansion status
if (node->untriedCommands.empty()) { node->fullyExpanded = true; }
MCTSNode* childPtr = child.get();
node->children.push_back(std::move(child));
return childPtr;
}
auto MCTSAI::MCTSSimulation(
const ShardokEngine& engineState,
PlayerId startingPlayer,
const SettingsGetter& settingsGetter) const -> double {
// Create copy for simulation
auto simEngine = std::make_shared<ShardokEngine>(engineState, false);
// Always evaluate from our AI's perspective (not the startingPlayer's perspective)
const double initialScore = AIScoreCalculator::GuessedStateScore(
isDefender,
simEngine->GetCurrentGameState(),
strategy,
castleCoords,
settingsGetter,
apdCache,
alCache);
// Debug: Note that we're always scoring from our AI's perspective regardless of startingPlayer
(void)startingPlayer; // Acknowledge parameter to avoid warning
// Fast rollout with random/heuristic moves until terminal
int simulationSteps = 0;
for (int step = 0; step < config.maxSimulationDepth; ++step) {
const GameStateW& currentState = simEngine->GetCurrentGameState();
// Get whose turn it is
const PlayerId currentPlayer = simEngine->GetCurrentPlayerId();
// Check if terminal
if (IsTerminalForPlayer(currentState, playerId, settingsGetter)) { break; }
// Continue as long as it's still our turn (don't stop after each individual command)
// In this game, a player can move multiple units before turn switches
if (currentPlayer != playerId) {
// Turn switched to opponent - stop simulation immediately
break;
}
simulationSteps++;
// Get available commands for current player
const CommandListSPtr commands = simEngine->GetAvailableCommandsForAIPlayer(currentPlayer);
if (!commands || commands->empty()) break;
// Select command based on simulation policy
const auto commandIndex = static_cast<int>(
SelectSimulationCommand(commands, currentPlayer, simEngine, settingsGetter));
// Execute command
simEngine->PostCommand(currentPlayer, commandIndex, _averageGenerator);
// Debug: Only log if turn changed unexpectedly
const PlayerId newPlayer = simEngine->GetCurrentPlayerId();
static int debugCount = 0;
if (newPlayer != currentPlayer && debugCount < 5) {
const auto commandType = commands->at(commandIndex)->GetCommandType();
printf("MCTS Sim step %d: cmd_type:%s player_before:%d player_after:%d\n",
simulationSteps,
CommandType_Name(commandType).c_str(),
currentPlayer,
newPlayer);
printf(" WARNING: Turn changed after command!\n");
debugCount++;
}
}
// Evaluate final position
const double finalScore = AIScoreCalculator::GuessedStateScore(
isDefender,
simEngine->GetCurrentGameState(),
strategy,
castleCoords,
settingsGetter,
apdCache,
alCache);
// Debug: Log first few simulations to see depth and score change
static int simCount = 0;
if (simCount < 3) {
printf("MCTS Simulation %d: steps:%d initial:%.2f final:%.2f delta:%.2f\n",
simCount++,
simulationSteps,
initialScore,
finalScore,
finalScore - initialScore);
}
return finalScore;
}
auto MCTSAI::MCTSBackpropagation(MCTSNode* node, double reward) -> void {
while (node) {
node->visitCount++;
node->totalReward += reward;
node->averageReward = node->totalReward / node->visitCount;
// Update lookahead score as weighted average
if (node->visitCount == 1) {
node->lookaheadScore = reward;
} else {
node->lookaheadScore =
(node->lookaheadScore * (node->visitCount - 1) + reward) / node->visitCount;
}
node = node->parent;
}
}
auto MCTSAI::IsTerminalForPlayer(
const GameStateW& gameState,
PlayerId /*currentPlayer*/,
const SettingsGetter& settingsGetter) -> bool {
// Check if game is over
if (gameState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY ||
gameState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW) {
return true;
}
// Check max rounds
if (gameState->current_round() >= settingsGetter.Backing().max_rounds()) { return true; }
return false;
}
auto MCTSAI::SelectSimulationCommand(
const CommandListSPtr& commands,
PlayerId currentPlayer,
const std::shared_ptr<ShardokEngine>& simEngine,
const SettingsGetter& settingsGetter) const -> size_t {
if (commands->size() == 1) {
return 0; // Only one choice
}
std::random_device rd;
std::mt19937 gen(rd());
switch (config.simulationPolicy) {
case MCTSSimulationPolicy::RANDOM: {
// Pure random selection
std::uniform_int_distribution<> dis(0, commands->size() - 1);
return dis(gen);
}
case MCTSSimulationPolicy::FILTERED_RANDOM: {
// Filter commands first, then random selection
const auto filteredIndices = AICommandFilter::FilterCommands(
commands,
currentPlayer,
isDefender,
simEngine->GetCurrentGameState(),
settingsGetter,
apdCache);
if (filteredIndices.empty()) {
// Fallback to random if no commands pass filter
std::uniform_int_distribution<> dis(0, commands->size() - 1);
return dis(gen);
}
std::uniform_int_distribution<> dis(0, filteredIndices.size() - 1);
return filteredIndices[dis(gen)];
}
case MCTSSimulationPolicy::BEST_IMMEDIATE: {
// Evaluate all commands and pick the best
double bestScore = -std::numeric_limits<double>::max();
size_t bestIndex = 0;
for (size_t i = 0; i < commands->size(); ++i) {
// Create a temporary engine to evaluate this command
const auto testEngine = std::make_shared<ShardokEngine>(*simEngine, false);
// Get score BEFORE executing command (for potential player flip comparison)
const double preScore = AIScoreCalculator::GuessedStateScore(
isDefender,
testEngine->GetCurrentGameState(),
strategy,
castleCoords,
settingsGetter,
apdCache,
alCache);
const PlayerId playerBefore = testEngine->GetCurrentPlayerId();
testEngine->PostCommand(currentPlayer, i, _averageGenerator);
const PlayerId playerAfter = testEngine->GetCurrentPlayerId();
double score;
if (playerAfter != playerBefore) {
// Player flipped - use pre-execution score to avoid opponent turn effects
score = preScore;
} else {
// Normal command - use post-execution score
score = AIScoreCalculator::GuessedStateScore(
isDefender,
testEngine->GetCurrentGameState(),
strategy,
castleCoords,
settingsGetter,
apdCache,
alCache);
}
if (score > bestScore) {
bestScore = score;
bestIndex = i;
}
}
return bestIndex;
}
case MCTSSimulationPolicy::WEIGHTED_BEST_IMMEDIATE: {
// Evaluate all commands and weight by ranking
struct CommandScore {
size_t index;
double score;
};
std::vector<CommandScore> commandScores;
commandScores.reserve(commands->size());
for (size_t i = 0; i < commands->size(); ++i) {
// Create a temporary engine to evaluate this command
const auto testEngine = std::make_shared<ShardokEngine>(*simEngine, false);
// Get score BEFORE executing command (for potential player flip comparison)
const double preScore = AIScoreCalculator::GuessedStateScore(
isDefender,
testEngine->GetCurrentGameState(),
strategy,
castleCoords,
settingsGetter,
apdCache,
alCache);
const PlayerId playerBefore = testEngine->GetCurrentPlayerId();
testEngine->PostCommand(currentPlayer, i, _averageGenerator);
const PlayerId playerAfter = testEngine->GetCurrentPlayerId();
double score;
if (playerAfter != playerBefore) {
// Player flipped - use pre-execution score to avoid opponent turn effects
score = preScore;
} else {
// Normal command - use post-execution score
score = AIScoreCalculator::GuessedStateScore(
isDefender,
testEngine->GetCurrentGameState(),
strategy,
castleCoords,
settingsGetter,
apdCache,
alCache);
}
commandScores.push_back({i, score});
}
// Sort by score (best first)
std::sort(
commandScores.begin(),
commandScores.end(),
[](const CommandScore& a, const CommandScore& b) { return a.score > b.score; });
// Assign weights: 1.0 for best, 0.5 for second, 0.33 for third, etc.
std::vector<double> weights;
weights.reserve(commandScores.size());
double totalWeight = 0.0;
for (size_t i = 0; i < commandScores.size(); ++i) {
double weight = 1.0 / (i + 1); // 1/1, 1/2, 1/3, ...
weights.push_back(weight);
totalWeight += weight;
}
// Random selection based on weights
std::uniform_real_distribution dis(0.0, totalWeight);
const double target = dis(gen);
double cumulative = 0.0;
for (size_t i = 0; i < weights.size(); ++i) {
cumulative += weights[i];
if (cumulative >= target) { return commandScores[i].index; }
}
// Fallback (shouldn't happen)
return commandScores[0].index;
}
}
// Fallback to random (shouldn't reach here)
std::uniform_int_distribution dis(0, static_cast<int>(commands->size() - 1));
return dis(gen);
}
} // namespace shardok
@@ -1,128 +0,0 @@
//
// MCTS-based AI system for Shardok
// Alternative to IterativeDeepeningAI using Monte Carlo Tree Search
//
#ifndef EAGLE0_MCTSAI_HPP
#define EAGLE0_MCTSAI_HPP
#include <chrono>
#include <future>
#include <memory>
#include <unordered_map>
#include <vector>
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AITimeBudget.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/IterativeDeepeningAI.hpp" // For SearchResult compatibility
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
namespace shardok {
// Forward declarations
class ShardokEngine;
// MCTSNode is defined in internal/MCTSNode.hpp
namespace internal {
struct MCTSNode;
}
// Simulation policy for MCTS rollouts
enum class MCTSSimulationPolicy {
RANDOM, // Pure random selection
FILTERED_RANDOM, // Random from filtered commands
BEST_IMMEDIATE, // Choose best immediate score
WEIGHTED_BEST_IMMEDIATE // Random weighted by score ranking
};
// Configuration for MCTS algorithm
struct MCTSConfig {
double explorationConstant = 1.414; // UCB1 constant (sqrt(2) by default)
int maxSimulationDepth = 1000; // Maximum depth for rollout
int maxTreeDepth = 2000; // Maximum tree depth to prevent stack overflow
bool useMultithreading = true; // Enable parallel MCTS
int numThreads = 16; // Number of threads for parallel MCTS (when enabled)
MCTSSimulationPolicy simulationPolicy = MCTSSimulationPolicy::BEST_IMMEDIATE;
bool enableTranspositionDetection = true; // Enable pruning of duplicate states
};
class MCTSAI {
public:
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
using SearchResult = IterativeDeepeningAI::SearchResult;
MCTSAI(PlayerId playerId,
bool isDefender,
AIStrategy strategy,
const CoordsSet& castleCoords,
const APDCache& apdCache,
const ALCache& alCache,
MCTSConfig config = MCTSConfig{});
// Main search interface - compatible with IterativeDeepeningAI
[[nodiscard]] auto Search(
const GameSettingsSPtr& settings,
const GameStateW& state,
const std::vector<CommandProto>& commands,
const AITimeBudget& budget) const -> SearchResult;
// Get/set configuration
[[nodiscard]] auto GetConfig() const -> const MCTSConfig& { return config; }
void SetConfig(const MCTSConfig& newConfig) { config = newConfig; }
private:
PlayerId playerId;
bool isDefender;
AIStrategy strategy;
const CoordsSet& castleCoords;
const APDCache& apdCache;
const ALCache& alCache;
MCTSConfig config;
// Transposition detection infrastructure
mutable std::unordered_map<uint64_t, internal::MCTSNode*>
stateRegistry; // Hash -> first node mapping
// Internal MCTS tree building
[[nodiscard]] auto BuildMCTSTree(
const ShardokEngine& engine,
const SettingsGetter& settingsGetter,
const CoordsSet& criticalTileCoords,
std::chrono::steady_clock::time_point deadline) const
-> std::unique_ptr<internal::MCTSNode>;
// MCTS algorithm phases
auto MCTSSelection(internal::MCTSNode* root) const -> internal::MCTSNode*;
auto MCTSExpansion(
internal::MCTSNode* node,
const ShardokEngine& engine,
const SettingsGetter& settingsGetter,
const CoordsSet& criticalTileCoords) const -> internal::MCTSNode*;
auto MCTSSimulation(
const ShardokEngine& engineState,
PlayerId currentPlayer,
const SettingsGetter& settingsGetter) const -> double;
static auto MCTSBackpropagation(internal::MCTSNode* node, double reward) -> void;
// Helper functions
[[nodiscard]] static auto IsTerminalForPlayer(
const GameStateW& gameState,
PlayerId currentPlayer,
const SettingsGetter& settingsGetter) -> bool;
// Simulation command selection based on policy
[[nodiscard]] auto SelectSimulationCommand(
const CommandListSPtr& commands,
PlayerId currentPlayer,
const std::shared_ptr<ShardokEngine>& simEngine,
const SettingsGetter& settingsGetter) const -> size_t;
};
} // namespace shardok
#endif // EAGLE0_MCTSAI_HPP
@@ -1,16 +0,0 @@
load("//tools:copts.bzl", "COPTS")
cc_library(
name = "mcts_node",
hdrs = ["MCTSNode.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
],
deps = [
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
],
)
@@ -1,210 +0,0 @@
//
// Internal MCTS Node structure for Shardok AI
// This is an implementation detail and should not be used by external code
//
#ifndef EAGLE0_INTERNAL_MCTSNODE_HPP
#define EAGLE0_INTERNAL_MCTSNODE_HPP
#include <cmath>
#include <cstdio>
#include <limits>
#include <memory>
#include <vector>
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
// Suppress the protobuf deprecation warning temporarily
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
#pragma GCC diagnostic pop
namespace shardok {
namespace internal {
// Import CommandType for use within the internal namespace
using CommandType = net::eagle0::shardok::common::CommandType;
// MCTS Node structure
struct MCTSNode {
// Command information
size_t commandIndex;
CommandType commandType;
int actorUnitId = -1; // Unit performing the command (-1 if not applicable)
int targetRow = -1; // Target coordinate row (-1 if not applicable)
int targetCol = -1; // Target coordinate column (-1 if not applicable)
// Score information
double immediateScore;
double lookaheadScore;
// Game state after this command
GameStateW resultingGameState;
// MCTS statistics
int visitCount = 0;
double totalReward = 0.0;
double averageReward = 0.0;
double ucb1Value = 0.0;
// Tree structure
std::vector<std::unique_ptr<MCTSNode>> children;
std::vector<size_t> untriedCommands;
bool fullyExpanded = false;
MCTSNode* parent = nullptr;
// Game context
PlayerId playerId;
int depth = 0;
bool isDefender = false;
bool isTerminal = false;
// Transposition detection
uint64_t stateHash = 0;
bool isRedundant = false; // True if this node represents a duplicate state
MCTSNode(
const size_t cmdIndex,
const CommandType cmdType,
const PlayerId pid,
const int d,
const bool defender)
: commandIndex(cmdIndex),
commandType(cmdType),
immediateScore(0.0),
lookaheadScore(0.0),
playerId(pid),
depth(d),
isDefender(defender) {}
// Iterative destructor to avoid stack overflow with deep trees
~MCTSNode() {
// Use iterative approach to destroy children
std::vector<std::unique_ptr<MCTSNode>> nodesToDestroy;
nodesToDestroy.swap(children);
while (!nodesToDestroy.empty()) {
// Take ownership of all children from the current batch
std::vector<std::unique_ptr<MCTSNode>> currentBatch;
currentBatch.swap(nodesToDestroy);
// Collect grandchildren for next iteration
for (const auto& node : currentBatch) {
if (node && !node->children.empty()) {
for (auto& child : node->children) {
nodesToDestroy.push_back(std::move(child));
}
node->children.clear();
}
}
// currentBatch goes out of scope here, destroying nodes with no children
}
}
// Calculate UCB1 value for this node
void CalculateUCB1(const double explorationConstant) {
if (visitCount == 0) {
ucb1Value = std::numeric_limits<double>::max();
} else if (parent && parent->visitCount > 0) {
ucb1Value = averageReward +
explorationConstant * std::sqrt(std::log(parent->visitCount) / visitCount);
} else {
ucb1Value = averageReward;
}
}
// Check if this node can be expanded
[[nodiscard]] bool CanExpand() const { return !fullyExpanded && !untriedCommands.empty(); }
// Get best child based on UCB1
[[nodiscard]] MCTSNode* GetBestChild(const double explorationConstant) const {
if (children.empty()) return nullptr;
MCTSNode* bestChild = nullptr;
double bestValue = -std::numeric_limits<double>::max();
static int selectionCallCount = 0;
const bool shouldDebug = selectionCallCount < 5;
for (auto& child : children) {
// Skip redundant nodes
if (child->isRedundant) continue;
child->CalculateUCB1(explorationConstant);
if (child->ucb1Value > bestValue) {
bestValue = child->ucb1Value;
bestChild = child.get();
}
if (shouldDebug && child->visitCount > 0) {
printf("UCB1 Debug: cmd:%zu visits:%d reward:%.2f ucb1:%.2f%s\n",
child->commandIndex,
child->visitCount,
child->averageReward,
child->ucb1Value,
child->isRedundant ? " [REDUNDANT]" : "");
}
}
if (shouldDebug) {
if (bestChild) {
printf("UCB1 Selected: cmd:%zu ucb1:%.2f\n",
bestChild->commandIndex,
bestChild->ucb1Value);
} else {
printf("UCB1 Selected: nullptr (all children redundant)\n");
}
selectionCallCount++;
}
return bestChild;
}
// Get best child based on average reward (for final selection)
[[nodiscard]] MCTSNode* GetBestFinalChild() const {
if (children.empty()) return nullptr;
MCTSNode* bestChild = nullptr;
double bestScore = -std::numeric_limits<double>::max();
int bestVisits = 0;
for (const auto& child : children) {
// Skip redundant nodes
if (child->isRedundant) continue;
// For final selection, prefer most-visited node (robust child selection)
// Only consider nodes that have been visited
if (child->visitCount > bestVisits) {
bestVisits = child->visitCount;
bestScore = child->averageReward;
bestChild = child.get();
} else if (child->visitCount == bestVisits && child->averageReward > bestScore) {
// Tie-break on average reward
bestScore = child->averageReward;
bestChild = child.get();
}
}
// If no child was visited (shouldn't happen), fall back to lookahead score
if (!bestChild && !children.empty()) {
for (const auto& child : children) {
// Skip redundant nodes
if (child->isRedundant) continue;
if (child->lookaheadScore > bestScore) {
bestScore = child->lookaheadScore;
bestChild = child.get();
}
}
}
return bestChild;
}
};
} // namespace internal
} // namespace shardok
#endif // EAGLE0_INTERNAL_MCTSNODE_HPP
@@ -11,7 +11,6 @@
#include "PerformanceTestGameStateBuilder.hpp"
#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/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
@@ -120,37 +119,21 @@ int main(int argc, char* argv[]) {
const auto* hexMap = currentState->hex_map();
const auto settingsGetter = settings->GetGetter();
ShardokAIClient aiClient(
aiPlayerId,
isDefender,
hexMap,
settingsGetter,
AIAlgorithmType::MCTS_CLEAN);
ShardokAIClient aiClient(aiPlayerId, isDefender, hexMap, settingsGetter);
// Create a second AI client for the human player during setup
// This ensures consistent state handling during setup phase
const PlayerId humanPlayerId = 1;
ShardokAIClient humanSetupAI(
humanPlayerId,
!isDefender,
hexMap,
settingsGetter,
AIAlgorithmType::MCTS_CLEAN);
ShardokAIClient humanSetupAI(humanPlayerId, !isDefender, hexMap, settingsGetter);
// Complete setup phase - AI makes intelligent placement decisions
if (currentState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP) {
std::cout << "Setup phase detected. Completing setup...\n";
int setupMoves = 0;
while (currentState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP) {
PlayerId currentPlayer = currentState->current_player();
auto availableCommands = engine.GetAvailableCommandProtos(currentPlayer, false);
std::cout << "Setup move " << setupMoves++ << ": player "
<< static_cast<int>(currentPlayer) << " has " << availableCommands.size()
<< " commands\n";
if (availableCommands.empty()) {
std::cout << "No commands available for player "
<< static_cast<int>(currentPlayer) << "\n";
@@ -160,19 +143,15 @@ int main(int argc, char* argv[]) {
if (currentPlayer == aiPlayerId) {
// Let AI make intelligent placement decisions
auto choiceResults = aiClient.ChooseCommandIndex(engine);
std::cout << "AI player chose command " << choiceResults.chosenIndex << "\n";
engine.PostCommand(currentPlayer, choiceResults.chosenIndex);
} else {
// Human player: use AI for setup to ensure consistent state handling
auto choiceResults = humanSetupAI.ChooseCommandIndex(engine);
std::cout << "Human player (AI) chose command " << choiceResults.chosenIndex
<< "\n";
engine.PostCommand(currentPlayer, choiceResults.chosenIndex);
}
currentState = engine.GetCurrentGameState();
}
std::cout << "Setup complete! Game state: " << currentState->status()->state() << "\n";
}
// Test AI performance for configured number of turns
@@ -181,25 +160,6 @@ int main(int argc, char* argv[]) {
std::vector<AIPerformanceMetrics> metrics;
for (int turn = 0; turn < config.numTurns; ++turn) {
// Update current state and check whose turn it is
currentState = engine.GetCurrentGameState();
PlayerId currentPlayer = currentState->current_player();
// If it's not the AI's turn, skip to next iteration
if (currentPlayer != aiPlayerId) {
std::cout << " Turn " << turn << ": It's player "
<< static_cast<int>(currentPlayer) << "'s turn (not AI). Skipping...\n";
// Have the opponent make a simple move to advance the game
const auto opponentCommands =
engine.GetAvailableCommandProtos(currentPlayer, false);
if (!opponentCommands.empty()) {
// For now, opponent chooses first available command
engine.PostCommand(currentPlayer, 0);
}
continue;
}
// Check if AI can make a move
const auto availableCommands = engine.GetAvailableCommandProtos(aiPlayerId, false);
if (availableCommands.empty()) {
@@ -74,8 +74,7 @@ vector<shared_ptr<ShardokAIClient>> ShardokGameController::MakeAIClients(
pi.player_id(),
pi.is_defender(),
e->GetCurrentGameState()->hex_map(),
e->GetGameSettings()->GetGetter(),
AIAlgorithmType::MCTS_CLEAN);
e->GetGameSettings()->GetGetter());
aic.push_back(newClient);
}
@@ -58,22 +58,6 @@ ShardokEngine::ShardokEngine(
startingHistoryState(gsBuffer),
criticalTileCoords(GetCriticalTileLocations(GetCurrentGameState()->hex_map())) {}
ShardokEngine::ShardokEngine(
const GameSettingsSPtr &settings,
const GameStateW &gsBuffer,
const CoordsSet &criticalTileCoords,
const int32_t startingHistoryCount,
const bool trackHistory)
: gameSettings(settings),
settingsGetter(settings->GetGetter()),
availableCommandsFactory(
AvailableCommandsFactory::MakeAvailableCommandsFactory(settingsGetter)),
gameState(gsBuffer),
trackHistory(trackHistory),
startingHistoryCount(startingHistoryCount),
startingHistoryState(gsBuffer),
criticalTileCoords(criticalTileCoords) {}
ShardokEngine::ShardokEngine(
const GameSettingsSPtr &settings,
vector<Unit> &&unplacedUnorderedUnits,
@@ -88,14 +88,6 @@ public:
int32_t startingHistoryCount = 0,
bool trackHistory = true);
// Constructor using existing game state with pre-computed critical tile coords
ShardokEngine(
const GameSettingsSPtr &settings,
const GameStateW &gsBuffer,
const CoordsSet &criticalTileCoords,
int32_t startingHistoryCount = 0,
bool trackHistory = true);
// Constructor for a brand new game
ShardokEngine(
const GameSettingsSPtr &settings,
@@ -135,27 +135,27 @@ auto ActionPointDistancesCache::GetRaw(
MakeCacheKey(mapId, battalionType, includeBravingWater, braveWaterActionPointCost);
// Check the persistent map first
if (auto it = persistentCache.find(cacheKey); it != persistentCache.end()) {
if (persistentCache.contains(cacheKey)) {
#if CACHE_STATS_LOGGING_
cacheStats.persistentHits++;
MaybePrintCacheStats();
#endif
// Return directly from persistent cache without TLS insertion
// This avoids the overhead of thread-local storage operations on hot path
return it->second.sharedPtr.get();
return persistentCache.at(cacheKey).rawPtr;
}
#if CACHE_STATS_LOGGING_
cacheStats.persistentMisses++;
#endif
// Check thread-local cache (no locks needed!)
if (auto it = tlsCache.find(cacheKey); it != tlsCache.end()) {
// Check thread-local cache first (no locks needed!)
if (tlsCache.contains(cacheKey)) {
#if CACHE_STATS_LOGGING_
cacheStats.localHits++;
MaybePrintCacheStats();
#endif
return it->second.sharedPtr.get(); // Compute raw pointer on demand
return tlsCache.at(cacheKey).rawPtr; // Raw pointer - zero overhead access!
}
#if CACHE_STATS_LOGGING_
@@ -224,10 +224,17 @@ auto ActionPointDistancesCache::GetRaw(
// Store both shared_ptr and raw pointer for hybrid access
tlsCache.emplace(cacheKey, CacheEntry(result));
// Note: No eviction logic needed here - ConsolidateThreadLocalCache_Racy()
// is called after each AI decision to clear the cache and prevent unbounded growth.
// Previous eviction logic was unsafe as it could free cache entries while raw
// pointers to those entries were still in use, causing use-after-free crashes.
// Prevent unbounded cache growth - limit to reasonable size
if (tlsCache.size() > 100) {
// Simple eviction: clear half the cache when it gets too large
#if CACHE_STATS_LOGGING_
cacheStats.evictionEvents++;
#endif
auto it = tlsCache.begin();
std::advance(it, tlsCache.size() / 2);
tlsCache.erase(tlsCache.begin(), it);
}
return result.get();
}
@@ -59,8 +59,11 @@ class ActionPointDistancesCache {
private:
struct CacheEntry {
shared_ptr<ActionPointDistances> sharedPtr;
const ActionPointDistances* rawPtr;
explicit CacheEntry(shared_ptr<ActionPointDistances> ptr) : sharedPtr(std::move(ptr)) {}
explicit CacheEntry(shared_ptr<ActionPointDistances> ptr)
: sharedPtr(std::move(ptr)),
rawPtr(sharedPtr.get()) {}
};
// Tier 1: persistent map. This is NOT safe to write to while reads may be happening.
@@ -24,7 +24,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai:__pkg__",
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
"//src/test/cpp/net/eagle0/shardok/library/action_point_distances:__pkg__",
],
deps = [
@@ -48,7 +48,6 @@ namespace eagle {
private PlayerId _playerId;
private int _recommendedFood;
private int _recommendedGold;
private List<AvailableDestinationProvince> _sortedAvailableDestinations;
public override AvailableCommand.SealedValueOneofCase CommandType =>
AvailableCommand.SealedValueOneofCase.MarchCommand;
@@ -121,13 +120,9 @@ namespace eagle {
DestinationProvince is { RulingFactionId : not null } &&
DestinationProvince.RulingFactionId.Value != _playerId;
private bool RequiresRiverCrossing => SelectedDestinationProvinceIndex is {}
idx && _sortedAvailableDestinations != null && idx < _sortedAvailableDestinations.Count &&
_sortedAvailableDestinations[idx].RequiresRiverCrossing;
public override bool WarnOnCommitButton =>
WouldAbandonProvince || NotEnoughFood || MarchingOnTrucePartner ||
BadlyConstructedUnits || NoBattalionsIntoEnemy || RequiresRiverCrossing;
public override bool WarnOnCommitButton => WouldAbandonProvince || NotEnoughFood ||
MarchingOnTrucePartner ||
BadlyConstructedUnits || NoBattalionsIntoEnemy;
public override string CommitWarningText {
get {
if (!(WarnOnCommitButton)) return null;
@@ -152,9 +147,6 @@ namespace eagle {
txt += "Some of your heroes are leading battalion types that would restrict their abilities\n\n";
}
}
if (RequiresRiverCrossing) {
txt += $"Assaulting {DestinationProvince.Name} may require crossing a river\n\n";
}
return txt;
}
@@ -218,12 +210,9 @@ namespace eagle {
}
private void SetUpDestinationProvinceDropdown() {
_sortedAvailableDestinations =
SelectedOneProvinceCommand.AvailableDestinationProvinces
.OrderBy(adp => _model.Provinces[adp.ProvinceId].Name)
.ToList();
_sortedDestinationProvinceIds =
_sortedAvailableDestinations.Select(adp => adp.ProvinceId).ToList();
_sortedDestinationProvinceIds = SelectedOneProvinceCommand.AvailableDestinationProvinces
.OrderBy(pid => _model.Provinces[pid].Name)
.ToList();
var destinationProvinceNames = _sortedDestinationProvinceIds
.Select(id => GUIUtils.DarkColoredProvinceName(
_model.Provinces[id],
@@ -862,7 +862,7 @@ namespace eagle {
}
private int? TroopCount() {
return existingBattalions.Where(x => !x.dismissed).Select(eb => eb.Count).Sum() +
return existingBattalions.Select(eb => eb.Count).Sum() +
newBattalions.Select(nb => nb.Count).Sum();
}
}
@@ -1,8 +1,6 @@
using System;
using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Views;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace eagle {
@@ -23,9 +21,6 @@ namespace eagle {
private TradeAvailableCommand TradeCommand => _availableCommand.TradeCommand;
private ProvinceId ActingProvinceId => TradeCommand.ActingProvinceId;
private ProvinceView ActingProvince => _model.Provinces[ActingProvinceId];
private int ProvinceGoldCap => ActingProvince.FullInfo.GoldCap;
private int ProvinceFoodCap => ActingProvince.FullInfo.FoodCap;
private int MaxFood =>
TradeCommand.FoodAvailable +
@@ -53,8 +48,7 @@ namespace eagle {
SetTextFields();
}
public override bool EnableCommitButton =>
!Mathf.Approximately(slider.value, TradeCommand.FoodAvailable);
public override bool EnableCommitButton => slider.value != TradeCommand.FoodAvailable;
public override string DisabledCommitButtonReason => "No change";
private bool IsBuy => FoodAmount > TradeCommand.FoodAvailable;
@@ -74,10 +68,7 @@ namespace eagle {
public void SetTextFields() {
goldText.text = GoldAmount.ToString();
goldText.color = GoldAmount > ProvinceGoldCap ? Color.red : Color.black;
foodText.text = FoodAmount.ToString();
foodText.color = FoodAmount > ProvinceFoodCap ? Color.red : Color.black;
description.text = IsBuy ? $"Buying {NetFood} food for {-NetGold} gold"
: $"Selling {-NetFood} food for {NetGold} gold";
@@ -244,11 +244,6 @@ message ManagePrisonersAvailableCommand {
repeated PrisonerToManage prisoners = 2;
}
message AvailableDestinationProvince {
int32 province_id = 1;
bool requires_river_crossing = 2;
}
message MarchCommandFromOneProvince {
map<int32, .net.eagle0.eagle.api.command.util.SuitableBattalions> suitable_battalions_for_heroes = 1;
@@ -259,7 +254,7 @@ message MarchCommandFromOneProvince {
int32 food_available = 6;
repeated AvailableDestinationProvince available_destination_provinces = 7;
repeated int32 available_destination_provinces = 7;
repeated int32 available_hero_ids = 8;
repeated .net.eagle0.eagle.api.command.util.BattalionWithFoodCost available_battalions = 10;
@@ -7,7 +7,7 @@ case class RandomState[+T](
newValue: T,
nextRandom: FunctionalRandom
) {
def map[U](f: T => U): RandomState[U] = RandomState(f(newValue), nextRandom)
def map[U](f: T => U): RandomState[U] = RandomState(f(newValue), nextRandom)
def continue[U](f: (T, FunctionalRandom) => RandomState[U]): RandomState[U] =
f(newValue, nextRandom)
@@ -62,9 +62,10 @@ abstract class FunctionalRandom {
else ni.nextRandom.nextInt(atLeast = atLeast, lessThan = lessThan)
} else {
val diff = lessThan - atLeast
val max = (Int.MaxValue / diff) * diff
val np = nextPositiveInt
if np.newValue > max then np.nextRandom.nextInt(atLeast = atLeast, lessThan = lessThan)
val max = (Int.MaxValue / diff) * diff
val np = nextPositiveInt
if np.newValue > max then
np.nextRandom.nextInt(atLeast = atLeast, lessThan = lessThan)
else RandomState(np.newValue % diff + atLeast, np.nextRandom)
}
}
@@ -105,7 +106,7 @@ abstract class FunctionalRandom {
nextItems[Double](count, _.nextDouble)
def nextPositiveInt: RandomState[Int] = {
val ni = nextInt
val ni = nextInt
val nonNegative =
if ni.newValue < 0 then -(ni.newValue + 1)
else ni.newValue
@@ -126,11 +127,12 @@ abstract class FunctionalRandom {
shuffledHead: Vector[T],
unshuffledTail: Vector[T],
fr: FunctionalRandom
): RandomState[Vector[T]] =
): RandomState[Vector[T]] = {
if unshuffledTail.isEmpty then RandomState(shuffledHead, fr)
else if unshuffledTail.length == 1 then RandomState(shuffledHead ++ unshuffledTail, fr)
else if unshuffledTail.length == 1 then
RandomState(shuffledHead ++ unshuffledTail, fr)
else {
val ni = fr.nextInt(0, unshuffledTail.length)
val ni = fr.nextInt(0, unshuffledTail.length)
val index = ni.newValue
if index == 0 then {
@@ -141,19 +143,20 @@ abstract class FunctionalRandom {
)
} else {
val nextHead = shuffledHead :+ unshuffledTail(index)
val newTail = unshuffledTail.splitAt(index) match {
val newTail = unshuffledTail.splitAt(index) match {
case (l, r) =>
(l.drop(1) :+ unshuffledTail.head) ++ r.drop(1)
}
go(nextHead, newTail, ni.nextRandom)
}
}
}
go(Vector(), seq, this)
}
def nextRandomSubset[T](size: Int, seq: Vector[T]): RandomState[Vector[T]] =
nextShuffled(seq).map(_.take(size))
nextShuffled(seq).map { _.take(size) }
private def nextFlatMapImpl[T, U, S[X] <: Seq[X], FACT <: SeqFactory[S]](
seq: S[T]
@@ -161,9 +164,8 @@ abstract class FunctionalRandom {
f: (T, FunctionalRandom) => RandomState[S[U]]
)(using xFactory: FACT): RandomState[S[U]] = {
@nowarn def foldGen(rsU: RandomState[S[U]], t: T): RandomState[S[U]] =
rsU.continue {
case (us: S[U], fr: FunctionalRandom) =>
f(t, fr).map(x => (us ++ x).asInstanceOf[S[U]])
rsU.continue { case (us: S[U], fr: FunctionalRandom) =>
f(t, fr).map(x => (us ++ x).asInstanceOf[S[U]])
}
FunctionalRandom.nextFoldLeft(
seq,
@@ -187,9 +189,8 @@ abstract class FunctionalRandom {
rsU: RandomState[S[U]],
t: T
): RandomState[S[U]] =
rsU.continue {
case (us: S[U], fr: FunctionalRandom) =>
f(t, fr).map(x => (us :+ x).asInstanceOf[S[U]])
rsU.continue { case (us: S[U], fr: FunctionalRandom) =>
f(t, fr).map(x => (us :+ x).asInstanceOf[S[U]])
}
FunctionalRandom.nextFoldLeft(seq, RandomState(xFactory.empty[U], this))(
@@ -212,10 +213,11 @@ abstract class FunctionalRandom {
): RandomState[Vector[U]] = remVec match {
case head +: tail =>
val newAccRS =
if pf.isDefinedAt(head) then pf(head)(accRS.nextRandom).map(accRS.newValue :+ _)
if pf.isDefinedAt(head) then
pf(head)(accRS.nextRandom).map(accRS.newValue :+ _)
else accRS
go(tail, newAccRS)
case _ => accRS
case _ => accRS
}
go(vec, RandomState(Vector(), this))
@@ -230,7 +232,7 @@ abstract class FunctionalRandom {
case head +: tail =>
if pf.isDefinedAt(head) then pf(head)(this).map(Option(_))
else go(tail)
case _ => ??? // above cases should cover
case _ => ??? // above cases should cover
}
go(seq)
@@ -247,10 +249,11 @@ abstract class FunctionalRandom {
case items if items.isEmpty => accRS
case head +: tail =>
val newAccRS =
if pf.isDefinedAt(head) then pf(head)(accRS.nextRandom).map(accRS.newValue ++ _)
if pf.isDefinedAt(head) then
pf(head)(accRS.nextRandom).map(accRS.newValue ++ _)
else accRS
go(tail, newAccRS)
case _ => ??? // above cases should cover
case _ => ??? // above cases should cover
}
go(vec, RandomState(Vector(), this))
@@ -278,13 +281,13 @@ abstract class FunctionalRandom {
case class SeededRandom(override val seed: Long) extends FunctionalRandom {
private val magicMultiplier = 0x5deece66dL
private val magicAdder = 0xbL
private val mask = 0xffffffffffffffffL
private val magicAdder = 0xbL
private val mask = 0xffffffffffffffffL
override def nextInt: RandomState[Int] = {
// borrowed from Functional Programming in Scala
val newSeed = (seed * magicMultiplier + magicAdder) & mask
val next = SeededRandom(newSeed)
val newSeed = (seed * magicMultiplier + magicAdder) & mask
val next = SeededRandom(newSeed)
val newValue = (newSeed >>> 16).toInt
RandomState(newValue, next)
}
@@ -1,42 +1,45 @@
package net.eagle0.common
import java.util.concurrent.{Executors, TimeUnit}
import scala.concurrent.{ExecutionContext, Future}
import io.grpc.{Status, StatusRuntimeException}
import io.grpc.stub.AbstractStub
import io.grpc.{Status, StatusRuntimeException}
import java.util.concurrent.{Executors, TimeUnit}
import scala.concurrent.{ExecutionContext, Future}
final case class GrpcRetrier[Stub <: AbstractStub[Stub]](grpcClient: Stub) {
implicit val ec: ExecutionContext =
ExecutionContext.fromExecutor(Executors.newFixedThreadPool(4))
private val firstDelayMillis = TimeUnit.SECONDS.toMillis(1)
private val maxDelayMillis = TimeUnit.SECONDS.toMillis(10)
private val timeoutSeconds = 1200
private val maxDelayMillis = TimeUnit.SECONDS.toMillis(10)
private val timeoutSeconds = 1200
private def go[Response](
clientOp: Stub => Future[Response],
delayMillis: Long
): Future[Response] =
clientOp(grpcClient.withDeadlineAfter(timeoutSeconds, TimeUnit.SECONDS)).recoverWith {
case sre: StatusRuntimeException if sre.getStatus.getCode == Status.Code.UNAVAILABLE =>
println(
s"Unavailable, retrying in ${TimeUnit.MILLISECONDS.toSeconds(delayMillis)}s..."
)
Thread.sleep(delayMillis)
go(
clientOp = clientOp,
delayMillis = Math.min(delayMillis * 2, maxDelayMillis)
)
case sre: StatusRuntimeException if sre.getStatus.getCode == Status.Code.CANCELLED =>
println("Cancelled, reconnecting...")
go(
clientOp = clientOp,
delayMillis = firstDelayMillis
)
case fr: Throwable => throw fr
}
): Future[Response] = {
clientOp(grpcClient.withDeadlineAfter(timeoutSeconds, TimeUnit.SECONDS))
.recoverWith {
case sre: StatusRuntimeException
if sre.getStatus.getCode == Status.Code.UNAVAILABLE =>
println(
s"Unavailable, retrying in ${TimeUnit.MILLISECONDS.toSeconds(delayMillis)}s..."
)
Thread.sleep(delayMillis)
go(
clientOp = clientOp,
delayMillis = Math.min(delayMillis * 2, maxDelayMillis)
)
case sre: StatusRuntimeException
if sre.getStatus.getCode == Status.Code.CANCELLED =>
println("Cancelled, reconnecting...")
go(
clientOp = clientOp,
delayMillis = firstDelayMillis
)
case fr: Throwable => throw fr
}
}
def withRetries[Response](
clientOp: Stub => Future[Response]
@@ -1,17 +1,16 @@
package net.eagle0.common
import java.net.URL
import org.json4s.DefaultFormats
import org.json4s.native.Json
import org.json4s.jvalue2extractable
import java.net.URL
import scala.io.Source
import scala.util.Using
import org.json4s.jvalue2extractable
import org.json4s.native.Json
import org.json4s.DefaultFormats
object JsonUtils {
implicit val jsonFormats: DefaultFormats.type = DefaultFormats
private val json = new Json(jsonFormats)
private val json = new Json(jsonFormats)
// This will just crash if the source url is not available, or if the
// json is not a String:Vector[String] map
@@ -3,9 +3,9 @@ package net.eagle0.common
import scala.collection.mutable
case class Metrics[T](logFrequency: Int) {
private var counters = mutable.Map[T, Int]()
private var counters = mutable.Map[T, Int]()
private var totalCount = 0
private var lastLog = 0
private var lastLog = 0
def increment(counter: T): Unit =
this.synchronized {
@@ -19,15 +19,15 @@ case class Metrics[T](logFrequency: Int) {
}
def logCounters: Unit = this.synchronized {
counters.toVector.sortBy(-_._2).foreach {
case (t, count) =>
println(s"$count $t")
counters.toVector.sortBy(-_._2).foreach { case (t, count) =>
println(s"$count $t")
}
}
def maybeLogCounters: Unit =
def maybeLogCounters: Unit = {
if totalCount - lastLog > logFrequency then {
logCounters
lastLog = totalCount
}
}
}
@@ -1,7 +1,7 @@
package net.eagle0.common
import scala.collection.generic.IsIterable
import scala.collection.Factory
import scala.collection.generic.IsIterable
object MoreSeq {
extension [A, Repr[_]](coll: Repr[A])(using itr: IsIterable[Repr[A]])
@@ -17,13 +17,11 @@ object MoreSeq {
def irregularTranspose[A](coll: Iterable[Seq[A]]): Vector[Vector[A]] = {
val maxLength = coll.map(_.length).max
coll.foldLeft(Vector.fill(maxLength)(Vector[A]())) {
case (acc, nextLine) =>
acc.zipWithIndex.map {
case (vec, idx) =>
if idx < nextLine.length then vec :+ nextLine(idx)
else vec
}
coll.foldLeft(Vector.fill(maxLength)(Vector[A]())) { case (acc, nextLine) =>
acc.zipWithIndex.map { case (vec, idx) =>
if idx < nextLine.length then vec :+ nextLine(idx)
else vec
}
}
}
}
@@ -1,11 +1,10 @@
package net.eagle0.common
import java.io.{BufferedInputStream, InputStream}
import scala.util.Using
import scalapb.{GeneratedMessage, GeneratedMessageCompanion}
import java.io.{BufferedInputStream, InputStream}
import scala.util.Using
object ProtoParser {
def parseZipped[T <: GeneratedMessage](inputStream: InputStream)(using
obj: GeneratedMessageCompanion[T]
@@ -3,9 +3,8 @@ package net.eagle0.common
import java.io.{FileOutputStream, OutputStream, OutputStreamWriter}
import java.nio.charset.StandardCharsets
import java.nio.file.Paths
import java.time.{Instant, ZoneOffset}
import java.time.format.DateTimeFormatter
import java.time.{Instant, ZoneOffset}
import scala.util.{Failure, Success, Using}
object SimpleTimedLogger {
@@ -35,7 +34,7 @@ object SimpleTimedLogger {
private def format(string: String): String =
s"${formatter.format(Instant.now())} $string"
private def formatLine(string: String): String = s"${format(string)}\n"
private def formatLine(string: String): String = s"${format(string)}\n"
private def formatNoLine(string: String): String = s"${format(string)} "
private class PrintLogger extends SimpleTimedLoggerT {
@@ -44,14 +43,15 @@ object SimpleTimedLogger {
override def logLine(str: String): Unit = print(formatLine(str))
}
private class SimpleTimedLogger(osConstructor: () => OutputStream) extends SimpleTimedLoggerT {
private class SimpleTimedLogger(osConstructor: () => OutputStream)
extends SimpleTimedLoggerT {
def log(str: String): Unit = write(formatNoLine(str))
def logLine(str: String): Unit = write(formatLine(str))
private def write(str: String): Unit =
Using(new OutputStreamWriter(osConstructor(), StandardCharsets.UTF_8)) { fw =>
fw.write(str)
Using(new OutputStreamWriter(osConstructor(), StandardCharsets.UTF_8)) {
fw => fw.write(str)
} match {
case Success(_) => ()
case Failure(exception) =>
@@ -68,7 +68,7 @@ trait SimpleTimedLoggerT {
def logLine(str: String): Unit
def withStopwatch[T](str: String)(f: => T): T = {
val start = System.currentTimeMillis()
val start = System.currentTimeMillis()
val result = f
logLine(s"Finished $str in ${System.currentTimeMillis() - start} ms")
result
@@ -2,7 +2,6 @@ package net.eagle0.common
import java.io.{File, PrintWriter}
import java.net.URL
import scala.collection.immutable.ListMap
import scala.io.Source
import scala.util.{Try, Using}
@@ -34,11 +33,11 @@ object TsvUtils {
}
def loadLines(url: URL): Try[Vector[Vector[String]]] =
Using(Source.fromURL(url, "UTF-8"))(loadLines)
Using(Source.fromURL(url, "UTF-8")) { loadLines }
// Loads a tsv and returns a Vector of arrays of strings.
def loadLines(path: String): Try[Vector[Vector[String]]] =
Using(Source.fromFile(path, "UTF-8"))(loadLines)
Using(Source.fromFile(path, "UTF-8")) { loadLines }
def loadMaps(url: URL): Try[Vector[ListMap[String, String]]] =
loadLines(url).map(linesToMaps)
@@ -80,7 +79,7 @@ object TsvUtils {
private def mapsToLines(
maps: Vector[Map[String, String]]
): Vector[Vector[String]] = {
val headers = maps.head.keys.toVector
val headers = maps.head.keys.toVector
val otherLines = maps.map(oneMap => headers.map(oneMap))
headers +: otherLines
@@ -88,11 +87,12 @@ object TsvUtils {
private def saveLines(lines: Vector[Vector[String]], url: URL): Unit = {
val file = new File(url.getPath)
val pw = new PrintWriter(file)
val pw = new PrintWriter(file)
try
try {
lines.foreach(line => pw.write(line.mkString("\t") + "\n"))
finally
} finally {
pw.close()
}
}
}
@@ -9,7 +9,7 @@ object ZipUtils {
val os = new ByteArrayOutputStream()
val buf = new Array[Byte](1024)
var n = gz.read(buf)
var n = gz.read(buf)
while n > 0 do {
os.write(buf, 0, n)
n = gz.read(buf)
@@ -8,10 +8,10 @@ object ApiKeys {
"src/main/scala/net/eagle0/common/llm_integration/api_keys.txt"
// These must have valid values set in api_keys.txt
private val openAIKeyName = "openai_api_key"
private val openAIKeyName = "openai_api_key"
private val anthropicKeyName = "anthropic_api_key"
private def readDictionary(): Map[String, String] =
private def readDictionary(): Map[String, String] = {
Using(scala.io.Source.fromFile(apiKeyFilePath)) { source =>
val lines = source.getLines().filterNot(_.startsWith("#")).toList
lines.map { line =>
@@ -27,19 +27,21 @@ object ApiKeys {
"Failed to read api_keys.txt. Make sure you have copied api_keys_template.txt to api_keys.txt and set the keys."
)
}
}
private val dictionary = readDictionary()
private def getKey(key: String): String =
private def getKey(key: String): String = {
dictionary.get(key) match {
case None =>
case None =>
throw new ApiKeyException(s"$key not found")
case Some(value) if value.startsWith("your_") =>
throw new ApiKeyException(
s"API key for $key is not set. Please set it in $apiKeyFilePath."
)
case Some(value) => value
case Some(value) => value
}
}
lazy val openAI: String = getKey(openAIKeyName)
@@ -1,36 +1,39 @@
package net.eagle0.common.llm_integration
import java.net.{URI, URL}
import net.eagle0.common.llm_integration.ClaudeServiceImpl.{
dictionaryFor,
messageVector
}
import org.json4s.native.{Json, Serialization}
import org.json4s.{DefaultFormats, JString}
import org.json4s.jvalue2extractable
import org.json4s.jvalue2monadic
import java.net.http.HttpRequest
import java.net.http.HttpRequest.BodyPublishers
import java.net.{URI, URL}
import java.time.{Duration, ZonedDateTime}
import java.time.format.DateTimeFormatter
import java.util.function.Consumer
import net.eagle0.common.llm_integration.ClaudeServiceImpl.{dictionaryFor, messageVector}
import org.json4s.{DefaultFormats, JString}
import org.json4s.jvalue2extractable
import org.json4s.jvalue2monadic
import org.json4s.native.{Json, Serialization}
object ClaudeServiceImpl {
val model: String = "claude-sonnet-4-20250514"
val maxTokens = 2048
val maxTokens = 2048
private val apiKey = ApiKeys.anthropic
private val apiKey = ApiKeys.anthropic
private val baseURL = new URL("https://api.anthropic.com/v1/messages")
private def dictionaryFor(
modelName: String
): Map[String, Any] = Map(
"model" -> modelName,
): Map[String, Any] = Map(
"model" -> modelName,
"max_tokens" -> maxTokens,
"stream" -> true
"stream" -> true
)
private def messageVector(inputText: String): Vector[Map[String, String]] =
Vector(
Map(
"role" -> "user",
"role" -> "user",
"content" -> inputText
)
)
@@ -80,14 +83,14 @@ class ClaudeServiceImpl(
private class ClaudeStringConsumer(
streamingConsumer: Consumer[StreamingTextResults]
) extends Consumer[String] {
private val json = new Json(DefaultFormats)
private val json = new Json(DefaultFormats)
private var streamId = ""
override def accept(t: String): Unit = {
val parsedJson = json.parse(t)
parsedJson \ "type" match {
case JString("message_start") =>
case JString("message_start") =>
streamId = (parsedJson \ "message" \ "id").extract[String]
case JString("content_block_delta") =>
(parsedJson \ "delta" \ "text") match {
@@ -99,7 +102,7 @@ class ClaudeServiceImpl(
completed = false
)
)
case _ => ???
case _ => ???
}
case JString("content_block_start") => ()
case JString("content_block_stop") => ()
@@ -113,12 +116,12 @@ class ClaudeServiceImpl(
completed = true
)
)
case JString("error") =>
case JString("error") =>
val errorDetails = parsedJson \ "error"
val errorType = (errorDetails \ "type").extract[String]
val errorType = (errorDetails \ "type").extract[String]
val errorMessage = (errorDetails \ "message").extract[String]
println(s"Got an \"($errorType)\" error from Claude: $errorMessage")
case _ => ???
case _ => ???
}
}
}
@@ -133,24 +136,26 @@ class ClaudeServiceImpl(
headers: Map[String, Vector[String]]
): Option[RateLimits] =
for {
requestLimit <- headers.get("anthropic-ratelimit-requests-limit")
tokenLimit <- headers.get("anthropic-ratelimit-tokens-limit")
requestLimit <- headers.get("anthropic-ratelimit-requests-limit")
tokenLimit <- headers.get("anthropic-ratelimit-tokens-limit")
requestsRemaining <- headers.get("anthropic-ratelimit-requests-remaining")
tokensRemaining <- headers.get("anthropic-ratelimit-tokens-remaining")
requestResetTime <- headers.get("anthropic-ratelimit-requests-reset")
tokenResetTime <- headers.get("anthropic-ratelimit-tokens-reset")
} yield RateLimits(
requestLimit = requestLimit.head.toInt,
tokenLimit = tokenLimit.head.toInt,
requestsRemaining = requestsRemaining.head.toInt,
tokensRemaining = tokensRemaining.head.toInt,
requestResetTime = ZonedDateTime.parse(
requestResetTime.head,
rfc3339formatter
),
tokenResetTime = ZonedDateTime.parse(
tokenResetTime.head,
rfc3339formatter
tokensRemaining <- headers.get("anthropic-ratelimit-tokens-remaining")
requestResetTime <- headers.get("anthropic-ratelimit-requests-reset")
tokenResetTime <- headers.get("anthropic-ratelimit-tokens-reset")
} yield {
RateLimits(
requestLimit = requestLimit.head.toInt,
tokenLimit = tokenLimit.head.toInt,
requestsRemaining = requestsRemaining.head.toInt,
tokensRemaining = tokensRemaining.head.toInt,
requestResetTime = ZonedDateTime.parse(
requestResetTime.head,
rfc3339formatter
),
tokenResetTime = ZonedDateTime.parse(
tokenResetTime.head,
rfc3339formatter
)
)
)
}
}
@@ -1,22 +1,25 @@
package net.eagle0.common.llm_integration
import java.net.http.{HttpClient, HttpRequest, HttpResponse, HttpTimeoutException}
import net.eagle0.common.sse.SseSubscriber
import java.net.HttpURLConnection
import java.net.http.HttpClient.{Redirect, Version}
import java.net.http.HttpResponse.ResponseInfo
import java.net.HttpURLConnection
import java.time.{Duration, ZonedDateTime}
import java.net.http.{
HttpClient,
HttpRequest,
HttpResponse,
HttpTimeoutException
}
import java.time.temporal.ChronoUnit
import java.util.{Timer, TimerTask}
import java.time.{Duration, ZonedDateTime}
import java.util.concurrent.CompletionException
import java.util.function.Consumer
import java.util.{Timer, TimerTask}
import scala.concurrent.{ExecutionContext, Future, Promise}
import scala.jdk.CollectionConverters.{CollectionHasAsScala, MapHasAsScala}
import scala.jdk.FutureConverters.CompletionStageOps
import scala.util.{Failure, Success}
import net.eagle0.common.sse.SseSubscriber
enum ExternalTextGenerationError extends Error:
case RateLimit(code: Int, msg: String)
case Http(code: Int, msg: String)
@@ -44,10 +47,10 @@ final class ExternalTextGenerationCaller(
private var inProgressCount = 0
def maxConcurrentStreams: Int = serviceImpl.maxConcurrentStreams
def getInProgressCount: Int = inProgressCount
def getInProgressCount: Int = inProgressCount
private var currentRateLimits: Option[RateLimits] = None
private var currentRateLimitTime: ZonedDateTime = ZonedDateTime.now()
private var currentRateLimitTime: ZonedDateTime = ZonedDateTime.now()
def millisUntilReset: Long =
ChronoUnit.MILLIS.between(
@@ -71,8 +74,9 @@ final class ExternalTextGenerationCaller(
inputText: String,
partialCompletion: Option[String],
streamingConsumer: Consumer[StreamingTextResults],
backoffSeconds: Double = ExternalTextGenerationCaller.initialBackoffSeconds
): Future[HttpResponse[Unit]] =
backoffSeconds: Double =
ExternalTextGenerationCaller.initialBackoffSeconds
): Future[HttpResponse[Unit]] = {
streamCompletion(
inputText = inputText,
request = serviceImpl.makeRequest(
@@ -82,6 +86,7 @@ final class ExternalTextGenerationCaller(
backoffSeconds = backoffSeconds,
streamingConsumer = streamingConsumer
)
}
private def streamCompletion(
inputText: String,
@@ -95,18 +100,20 @@ final class ExternalTextGenerationCaller(
println(s"Trying again after $backoffSeconds seconds...")
val promise = Promise[HttpResponse[Unit]]()
val t = new Timer()
val t = new Timer()
t.schedule(
new TimerTask {
override def run(): Unit =
override def run(): Unit = {
promise.completeWith(
streamCompletion(
inputText = inputText,
request = request,
backoffSeconds = ExternalTextGenerationCaller.nextBackoff(backoffSeconds),
backoffSeconds =
ExternalTextGenerationCaller.nextBackoff(backoffSeconds),
streamingConsumer = streamingConsumer
)
)
}
},
(backoffSeconds * 1000).toLong
)
@@ -117,19 +124,19 @@ final class ExternalTextGenerationCaller(
httpClient
.sendAsync(
request,
(respInfo: ResponseInfo) =>
(respInfo: ResponseInfo) => {
if respInfo.statusCode() == HttpURLConnection.HTTP_OK then
new SseSubscriber(serviceImpl.stringConsumer(streamingConsumer))
else
throw new RuntimeException(
s"Unexpected response code: ${respInfo.statusCode()}"
)
}
)
.asScala
.andThen {
case x =>
inProgressCount -= 1
x
.andThen { case x =>
inProgressCount -= 1
x
}
.transform {
case Success(httpResponse) =>
@@ -187,7 +194,7 @@ final class ExternalTextGenerationCaller(
case e: CompletionException =>
println(s"CompletionException error $e")
tryAgain()
case e: Throwable =>
case e: Throwable =>
println(s"error $e")
tryAgain()
}
@@ -1,7 +1,7 @@
package net.eagle0.common.llm_integration
import scala.concurrent.duration.{Duration, SECONDS}
import scala.concurrent.Await
import scala.concurrent.duration.{Duration, SECONDS}
//import scala.util.Random
object ExternalTextGenerationCallerApp {
@@ -66,11 +66,13 @@ object ExternalTextGenerationCallerApp {
|Alakanda's faction controls Atfordia.
|Roger the Shrubber's faction controls Chapellia.""".stripMargin
val startTime = System.currentTimeMillis()
val completion = caller.streamCompletion(
val startTime = System.currentTimeMillis()
val completion = caller.streamCompletion(
inputText = inputText,
partialCompletion = None,
streamingConsumer = results => print(results.value)
streamingConsumer = results => {
print(results.value)
}
)
Await.result(completion, Duration(500, SECONDS))
println("")
@@ -1,30 +1,33 @@
package net.eagle0.common.llm_integration
import java.net.{URI, URL}
import java.net.http.HttpRequest
import java.net.http.HttpRequest.BodyPublishers
import java.time.{Duration, ZonedDateTime}
import java.util.function.Consumer
import net.eagle0.common.llm_integration.OpenAIChatCompletionsServiceImpl.{dictionaryFor, messageVector}
import net.eagle0.common.llm_integration.OpenAIChatCompletionsServiceImpl.{
dictionaryFor,
messageVector
}
import org.json4s.native.{Json, Serialization}
import org.json4s.{DefaultFormats, JArray, JObject, JString}
import org.json4s.jvalue2extractable
import org.json4s.jvalue2monadic
import org.json4s.native.{Json, Serialization}
import java.net.http.HttpRequest
import java.net.http.HttpRequest.BodyPublishers
import java.net.{URI, URL}
import java.time.{Duration, ZonedDateTime}
import java.util.function.Consumer
object OpenAIChatCompletionsServiceImpl {
val gpt5: String = "gpt-5"
private val apiKey = ApiKeys.openAI
private val baseURL = new URL("https://api.openai.com/v1/chat/completions")
private val apiKey = ApiKeys.openAI
private val baseURL = new URL("https://api.openai.com/v1/chat/completions")
private val temperature: Double = 1.0
private def dictionaryFor(
modelName: String
): Map[String, Any] = Map(
"model" -> modelName,
"temperature" -> temperature,
"stream" -> true,
): Map[String, Any] = Map(
"model" -> modelName,
"temperature" -> temperature,
"stream" -> true,
"reasoning_effort" -> "minimal"
)
@@ -34,12 +37,12 @@ object OpenAIChatCompletionsServiceImpl {
): Vector[Map[String, String]] =
Vector(
Map(
"role" -> "user",
"role" -> "user",
"content" -> inputText
)
) ++ partialCompletion.map(pc =>
Map(
"role" -> "assistant",
"role" -> "assistant",
"content" -> pc
)
)
@@ -96,18 +99,18 @@ class OpenAIChatCompletionsServiceImpl(
override def stringConsumer(
streamingConsumer: Consumer[StreamingTextResults]
): Consumer[String] = (t: String) => {
val json = new Json(DefaultFormats)
val json = new Json(DefaultFormats)
val parsedJson =
try
try {
json.parse(t)
catch {
} catch {
case pe: org.json4s.ParserUtil.ParseException =>
println(s"Failed to parse JSON: $t")
throw pe
}
val streamId = (parsedJson \ "id").extract[String]
val choice = (parsedJson \ "choices")
val choice = (parsedJson \ "choices")
.asInstanceOf[JArray]
.arr
.head
@@ -123,7 +126,7 @@ class OpenAIChatCompletionsServiceImpl(
case JString(str) =>
println(s"Unexpected finish reason $str")
true
case _ => false
case _ => false
}
(choice \ "delta" \ "content") match {
@@ -135,7 +138,7 @@ class OpenAIChatCompletionsServiceImpl(
completed = completed
)
)
case _ =>
case _ =>
if completed then
streamingConsumer.accept(
StreamingTextResults(
@@ -151,30 +154,32 @@ class OpenAIChatCompletionsServiceImpl(
headers: Map[String, Vector[String]]
): Option[RateLimits] =
for {
requestLimit <- headers.get("x-ratelimit-limit-requests")
tokenLimit <- headers.get("x-ratelimit-limit-tokens")
requestLimit <- headers.get("x-ratelimit-limit-requests")
tokenLimit <- headers.get("x-ratelimit-limit-tokens")
requestsRemaining <- headers.get("x-ratelimit-remaining-requests")
tokensRemaining <- headers.get("x-ratelimit-remaining-tokens")
requestResetTime <- headers.get("x-ratelimit-reset-requests")
tokenResetTime <- headers.get("x-ratelimit-reset-tokens")
} yield RateLimits(
requestLimit = requestLimit.head.toInt,
tokenLimit = tokenLimit.head.toInt,
requestsRemaining = requestsRemaining.head.toInt,
tokensRemaining = tokensRemaining.head.toInt,
requestResetTime = ZonedDateTime
.now()
.plus(
OpenAiDurationParser
.parseDuration(requestResetTime.head)
.getOrElse(Duration.ZERO)
),
tokenResetTime = ZonedDateTime
.now()
.plus(
OpenAiDurationParser
.parseDuration(tokenResetTime.head)
.getOrElse(Duration.ZERO)
)
)
tokensRemaining <- headers.get("x-ratelimit-remaining-tokens")
requestResetTime <- headers.get("x-ratelimit-reset-requests")
tokenResetTime <- headers.get("x-ratelimit-reset-tokens")
} yield {
RateLimits(
requestLimit = requestLimit.head.toInt,
tokenLimit = tokenLimit.head.toInt,
requestsRemaining = requestsRemaining.head.toInt,
tokensRemaining = tokensRemaining.head.toInt,
requestResetTime = ZonedDateTime
.now()
.plus(
OpenAiDurationParser
.parseDuration(requestResetTime.head)
.getOrElse(Duration.ZERO)
),
tokenResetTime = ZonedDateTime
.now()
.plus(
OpenAiDurationParser
.parseDuration(tokenResetTime.head)
.getOrElse(Duration.ZERO)
)
)
}
}
@@ -4,13 +4,13 @@ import java.time.Duration
object OpenAiDurationParser {
private val durationRegex = """(\d+)((ms)|[smhdy])""".r
private val durationMap = Map(
private val durationMap = Map(
"ms" -> Duration.ofMillis(1),
"s" -> Duration.ofSeconds(1),
"m" -> Duration.ofMinutes(1),
"h" -> Duration.ofHours(1),
"d" -> Duration.ofDays(1),
"y" -> Duration.ofDays(365)
"s" -> Duration.ofSeconds(1),
"m" -> Duration.ofMinutes(1),
"h" -> Duration.ofHours(1),
"d" -> Duration.ofDays(1),
"y" -> Duration.ofDays(365)
)
def parseDuration(duration: String): Option[Duration] =
@@ -18,7 +18,7 @@ object OpenAiDurationParser {
.findAllMatchIn(duration)
.map { m =>
val amount = m.group(1).toInt
val unit = m.group(2)
val unit = m.group(2)
durationMap(unit).multipliedBy(amount)
}
.toSeq match {
@@ -1,20 +1,19 @@
package net.eagle0.common.name_generation
import java.io.File
import scala.util.Random
import net.eagle0.common.{SeededRandom, TsvUtils}
import java.io.File
import scala.util.Random
class NameGenerator {
private val random = SeededRandom(Random.nextLong())
private val emptySubstitutions = Map[String, String]()
private val random = SeededRandom(Random.nextLong())
private val emptySubstitutions = Map[String, String]()
private val nameConstructionUrl = new File(
"/opt/nameConstruction.txt"
).toURI.toURL
private val namesFileUrl = new File("/opt/names.tsv").toURI.toURL
private val namesFileUrl = new File("/opt/names.tsv").toURI.toURL
private val namesMap = TsvUtils.loadColumnMaps(namesFileUrl).get
private val namesMap = TsvUtils.loadColumnMaps(namesFileUrl).get
private val formatString =
StringConstructionTokenGenerator.formatStringFromUrl(
nameConstructionUrl
@@ -41,17 +40,17 @@ class NameGenerator {
atSpecializations = Vector("male")
)
def nextFemaleName(): String =
def nextFemaleName(): String =
femaleTok.nextString(random, emptySubstitutions).newValue
def nextMaleName(): String =
def nextMaleName(): String =
maleTok.nextString(random, emptySubstitutions).newValue
def nextNeutralName(): String =
allTok.nextString(random, emptySubstitutions).newValue
def nextName(): String = {
val intR = random.nextInt(0, 20)
val intR = random.nextInt(0, 20)
val newInt = intR.newValue
val tok =
val tok =
if newInt == 0 then allTok
else if newInt <= 10 then femaleTok
else maleTok
@@ -10,9 +10,8 @@ class StringConstructionParser(
import ParseSequence.*
private def charString(seq: Seq[CharacterOrToken]): String =
seq.collect {
case C(char) =>
char
seq.collect { case C(char) =>
char
}.mkString
def parseLiteral(fmt: String): Option[(String, StringConstructionToken)] = {
@@ -26,11 +25,12 @@ class StringConstructionParser(
def parseRemaining(str: String, acc: String): ParseStatus =
str.head match {
case '\\' =>
if str.length < 2 then throw new IllegalArgumentException("Found \\ at end of literal")
if str.length < 2 then
throw new IllegalArgumentException("Found \\ at end of literal")
else parseRemaining(str.drop(2), acc + str.charAt(1))
case '\"' =>
ParseStatus(str.drop(1), acc)
case _ =>
case _ =>
parseRemaining(str.drop(1), acc + str.head)
}
@@ -41,44 +41,44 @@ class StringConstructionParser(
private def parseOptional(
ts: ParseSequence
): Try[(ParseSequence, StringConstructionToken)] =
): Try[(ParseSequence, StringConstructionToken)] = {
insideBalanced(ts, open = '{', closed = '}').flatMap {
case (remainAfter, inWithOdds) =>
val DecimalParseResults(inAfterOdds, odds) = readDecimal(inWithOdds)
parsePossiblyParsed(inAfterOdds).flatMap {
case (rem, parsed) =>
if rem.nonEmpty then
Failure(
new IllegalArgumentException(
"Found extra characters inside optional"
)
parsePossiblyParsed(inAfterOdds).flatMap { case (rem, parsed) =>
if rem.nonEmpty then
Failure(
new IllegalArgumentException(
"Found extra characters inside optional"
)
else Success((remainAfter, OptionalToken(parsed, odds)))
)
else Success((remainAfter, OptionalToken(parsed, odds)))
}
}
}
private def parseOneof(
ts: ParseSequence
): Try[(ParseSequence, StringConstructionToken)] =
): Try[(ParseSequence, StringConstructionToken)] = {
insideBalanced(ts, open = '[', closed = ']').map {
case (remainAfter, inside) =>
var entries = Vector.empty[OneofListEntry]
var pos = inside
var pos = inside
while pos.nonEmpty do {
val t = pos.head
if t == ',' then pos = pos.drop(1)
else {
val DecimalParseResults(remIn, weight) = readDecimal(pos)
parsePossiblyParsed(remIn).map {
case (rem, parsed) =>
entries = entries :+ OneofListEntry(parsed, weight)
pos = rem
parsePossiblyParsed(remIn).map { case (rem, parsed) =>
entries = entries :+ OneofListEntry(parsed, weight)
pos = rem
}
}
}
(remainAfter, OneofListToken(entries))
}
}
def parseListSelector(
ts: ParseSequence
@@ -112,11 +112,12 @@ class StringConstructionParser(
def parseCapitalized(
ts: ParseSequence
): Try[(ParseSequence, StringConstructionToken)] =
): Try[(ParseSequence, StringConstructionToken)] = {
for {
(remainAfter, inside) <- insideBalanced(ts, open = '-', closed = '+')
(_, parsed) <- parsePossiblyParsed(inside)
(_, parsed) <- parsePossiblyParsed(inside)
} yield (remainAfter, TitleCaseToken(parsed))
}
def parseOrdinal(
ts: ParseSequence
@@ -145,7 +146,7 @@ class StringConstructionParser(
def go(
tokens: ParseSequence,
acc: Seq[StringConstructionToken]
): Try[(ParseSequence, Seq[StringConstructionToken])] =
): Try[(ParseSequence, Seq[StringConstructionToken])] = {
if tokens.isEmpty then Success((Vector.empty, acc))
else {
tokens.head match {
@@ -167,10 +168,10 @@ class StringConstructionParser(
case T(t) => go(tokens.drop(1), acc :+ t)
}
}
}
go(tokens, Vector.empty).map {
case (rem, ts) =>
(rem, if ts.size == 1 then ts.head else SequenceToken(ts))
go(tokens, Vector.empty).map { case (rem, ts) =>
(rem, if ts.size == 1 then ts.head else SequenceToken(ts))
}
}
@@ -179,7 +180,7 @@ class StringConstructionParser(
def parseNext(
remainingString: String,
acc: ParseSequence
): ParseSequence =
): ParseSequence = {
if remainingString.isEmpty then acc
else
remainingString.head match {
@@ -191,18 +192,19 @@ class StringConstructionParser(
throw new IllegalArgumentException(
s"Illegal literal in $remainingString"
)
case _ =>
case _ =>
parseNext(
remainingString.drop(1),
acc :+ C(remainingString.head)
)
}
}
val tokens = parseNext(formatString, Vector.empty)
parsePossiblyParsed(tokens).flatMap {
case (rem, tok) =>
if rem.nonEmpty then Failure(new IllegalArgumentException("Failed to parse"))
else Success(tok)
parsePossiblyParsed(tokens).flatMap { case (rem, tok) =>
if rem.nonEmpty then
Failure(new IllegalArgumentException("Failed to parse"))
else Success(tok)
}
}
@@ -211,8 +213,8 @@ class StringConstructionParser(
open: Char,
closed: Char
): Try[(ParseSequence, ParseSequence)] = {
var pos = ts.drop(1)
var level = 1
var pos = ts.drop(1)
var level = 1
var inside = Vector.empty[CharacterOrToken]
while level > 0 && pos.nonEmpty do {
@@ -224,7 +226,8 @@ class StringConstructionParser(
pos = pos.drop(1)
}
if level > 0 then Failure(new IllegalArgumentException("Did not find closing character"))
if level > 0 then
Failure(new IllegalArgumentException("Did not find closing character"))
Success((pos, inside))
}
@@ -1,11 +1,10 @@
package net.eagle0.common.name_generation
import java.nio.file.Path
import scala.util.Random
import net.eagle0.common.JankyRandom
import java.nio.file.Path
import scala.util.Random
object StringConstructionTester {
def main(args: Array[String]): Unit = {
val arraysPath = args(0)
@@ -26,7 +25,7 @@ object StringConstructionTester {
"longbowmen"
)
types.foreach { typeName =>
val adj = s"${typeName}_adj"
val adj = s"${typeName}_adj"
val newSubs =
substitutions + ("BATTALION_NAME" -> typeName) + ("BATTALION_ADJECTIVE" -> adj)
@@ -41,7 +40,7 @@ object StringConstructionTester {
val random = new JankyRandom(new Random())
print(s"---\n$typeName\n---\n")
(1 to 20).toVector.foreach { _ =>
((1 to 20).toVector).foreach { _ =>
println(token.nextString(random, Map("PLACE" -> "Pieska")).newValue)
}
print("\n\n\n")
@@ -1,9 +1,9 @@
package net.eagle0.common.name_generation
import scala.annotation.tailrec
import net.eagle0.common.{FunctionalRandom, RandomState}
import scala.annotation.tailrec
sealed trait StringConstructionToken {
def nextString(
random: FunctionalRandom,
@@ -19,7 +19,8 @@ case class LiteralToken(literal: String) extends StringConstructionToken {
RandomState(literal, random)
}
class MissingLiteralKeyException(key: String) extends Exception(s"Missing literal key $key") {}
class MissingLiteralKeyException(key: String)
extends Exception(s"Missing literal key $key") {}
case class SubstitutionToken(key: String) extends StringConstructionToken {
override def nextString(
@@ -33,25 +34,27 @@ case class SubstitutionToken(key: String) extends StringConstructionToken {
)
}
case class SequenceToken(tokens: Seq[StringConstructionToken]) extends StringConstructionToken {
case class SequenceToken(tokens: Seq[StringConstructionToken])
extends StringConstructionToken {
override def nextString(
random: FunctionalRandom,
literalSubstitutions: Map[String, String]
): RandomState[String] =
tokens.foldLeft(RandomState[String]("", random)) {
case (state, tok) =>
val nextStr = tok.nextString(state.nextRandom, literalSubstitutions)
RandomState(state.newValue + nextStr.newValue, nextStr.nextRandom)
tokens.foldLeft(RandomState[String]("", random)) { case (state, tok) =>
val nextStr = tok.nextString(state.nextRandom, literalSubstitutions)
RandomState(state.newValue + nextStr.newValue, nextStr.nextRandom)
}
}
case class OptionalToken(token: StringConstructionToken, odds: Double) extends StringConstructionToken {
case class OptionalToken(token: StringConstructionToken, odds: Double)
extends StringConstructionToken {
override def nextString(
random: FunctionalRandom,
literalSubstitutions: Map[String, String]
): RandomState[String] = {
val state = random.nextDouble
if state.newValue < odds then token.nextString(state.nextRandom, literalSubstitutions)
if state.newValue < odds then
token.nextString(state.nextRandom, literalSubstitutions)
else RandomState("", state.nextRandom)
}
}
@@ -64,32 +67,33 @@ case class OneofListEntry(token: StringConstructionToken, weight: Double) {
token.nextString(random, literalSubstitutions)
}
case class OneofListToken(entries: Seq[OneofListEntry]) extends StringConstructionToken {
case class OneofListToken(entries: Seq[OneofListEntry])
extends StringConstructionToken {
override def nextString(
random: FunctionalRandom,
literalSubstitutions: Map[String, String]
): RandomState[String] = {
val totalWeight = entries.map(_.weight).sum
random.nextDouble.continue {
case (double, fr) =>
@tailrec
def go(
remainingEntries: Seq[OneofListEntry],
remainingD: Double
): RandomState[String] = remainingEntries match {
case Nil =>
throw new IllegalStateException("Failed to find a valid oneof")
case w +: tail =>
if remainingD < w.weight then w.toString(fr, literalSubstitutions)
else go(tail, remainingD - w.weight)
}
random.nextDouble.continue { case (double, fr) =>
@tailrec
def go(
remainingEntries: Seq[OneofListEntry],
remainingD: Double
): RandomState[String] = remainingEntries match {
case Nil =>
throw new IllegalStateException("Failed to find a valid oneof")
case w +: tail =>
if remainingD < w.weight then w.toString(fr, literalSubstitutions)
else go(tail, remainingD - w.weight)
}
go(entries, double * totalWeight)
go(entries, double * totalWeight)
}
}
}
case class ListSelectionToken(choices: Seq[String]) extends StringConstructionToken {
case class ListSelectionToken(choices: Seq[String])
extends StringConstructionToken {
override def nextString(
random: FunctionalRandom,
literalSubstitutions: Map[String, String]
@@ -100,7 +104,7 @@ case class OrdinalSelectionToken(max: Int) extends StringConstructionToken {
override def nextString(
random: FunctionalRandom,
literalSubstitutions: Map[String, String]
): RandomState[String] =
): RandomState[String] = {
random.nextInt(1, max).map { value =>
if value == 11 then "11th"
else {
@@ -112,9 +116,11 @@ case class OrdinalSelectionToken(max: Int) extends StringConstructionToken {
})
}
}
}
}
case class TitleCaseToken(base: StringConstructionToken) extends StringConstructionToken {
case class TitleCaseToken(base: StringConstructionToken)
extends StringConstructionToken {
val uncapitalizedWords: Set[String] =
Set("and", "but", "for", "or", "nor", "the", "a", "an", "to", "as", "of")
@@ -122,8 +128,8 @@ case class TitleCaseToken(base: StringConstructionToken) extends StringConstruct
words match {
case first +: middle :+ last =>
first.capitalize +: middle :+ last.capitalize
case Vector(only: String) => Vector(only.capitalize)
case Vector() => Vector()
case Vector(only: String) => Vector(only.capitalize)
case Vector() => Vector()
}
private def byWordCapitalized(string: String): Vector[String] =
@@ -135,8 +141,9 @@ case class TitleCaseToken(base: StringConstructionToken) extends StringConstruct
override def nextString(
random: FunctionalRandom,
literalSubstitutions: Map[String, String]
): RandomState[String] =
): RandomState[String] = {
base.nextString(random, literalSubstitutions).map { cap =>
firstAndLastCapitalized(byWordCapitalized(cap)).mkString(" ")
}
}
}
@@ -1,7 +1,6 @@
package net.eagle0.common.name_generation
import java.net.URL
import scala.io.Source
import scala.util.Using
@@ -23,8 +22,8 @@ object StringConstructionTokenGenerator {
}
val (stringsMap, unfilteredStringsMap) = {
val allSpecializations = namesMap.map {
case (key, words) =>
val allSpecializations = namesMap
.map { case (key, words) =>
key.split("@", 2) match {
case Array(realKey) => (realKey, "", words)
case Array(realKey, spec) => (realKey, spec, words)
@@ -33,14 +32,17 @@ object StringConstructionTokenGenerator {
"Too many @s in column name"
)
}
}.toVector.groupBy(_._1)
}
.toVector
.groupBy { _._1 }
val stringsMap = allSpecializations.map {
case (realWord, specializations) =>
val includedSpecializations =
specializations.filter(spec => spec._2.isEmpty || atSpecializations.contains(spec._2))
val stringsMap = allSpecializations
.map { case (realWord, specializations) =>
val includedSpecializations = specializations.filter(spec =>
spec._2.isEmpty || atSpecializations.contains(spec._2)
)
(realWord, includedSpecializations.flatMap(_._3))
}
}
val unfilteredStringsMap = allSpecializations.map {
case (realWord, specializations) =>
@@ -5,10 +5,10 @@ import scala.annotation.tailrec
// Functions for parsing events from an SSE stream. Uses spec as defined in
// https://html.spec.whatwg.org/multipage/server-sent-events.html
object SseEventReader {
private val DataKey = "data"
private val EventKey = "event"
private val IdKey = "id"
private val RetryKey = "retry"
private val DataKey = "data"
private val EventKey = "event"
private val IdKey = "id"
private val RetryKey = "retry"
private val DefaultEventValue = "message"
sealed trait ReadResult
@@ -36,7 +36,8 @@ object SseEventReader {
else
readLines(
IncompleteEvent(
eventType = carryoverEvent.map(_.eventType).getOrElse(DefaultEventValue),
eventType =
carryoverEvent.map(_.eventType).getOrElse(DefaultEventValue),
metadata = carryoverEvent.map(_.metadata).getOrElse(Map.empty),
data = carryoverEvent.map(_.data).getOrElse(""),
carryover = carryoverEvent.map(_.carryover).getOrElse("") + text
@@ -52,14 +53,14 @@ object SseEventReader {
if value.startsWith(" ") then value.drop(1) else value
key match {
case DataKey =>
case DataKey =>
IncompleteEvent(
eventType = acc.eventType,
metadata = acc.metadata,
data = acc.data + trimmedSpaceValue,
carryover = acc.carryover
)
case EventKey =>
case EventKey =>
IncompleteEvent(
eventType = trimmedSpaceValue,
metadata = acc.metadata,
@@ -73,23 +74,23 @@ object SseEventReader {
data = acc.data,
carryover = acc.carryover
)
case _ =>
case _ =>
// The spec actually says to ignore fields except if the key is "event", "data", "id", or "retry"
acc
}
}
@tailrec
private def readLines(acc: IncompleteEvent): ReadResult =
private def readLines(acc: IncompleteEvent): ReadResult = {
acc.carryover.split("\n", 2) match {
case Array("", moreText) => // blank line indicates end of event
case Array("", moreText) => // blank line indicates end of event
CompleteEvent(
eventType = acc.eventType,
metadata = acc.metadata,
data = acc.data,
remainingText = moreText
)
case Array(singleLine) => // did not end in a newline
case Array(singleLine) => // did not end in a newline
IncompleteEvent(
eventType = acc.eventType,
metadata = acc.metadata,
@@ -100,7 +101,7 @@ object SseEventReader {
case Array(kv, newCarryover) =>
val accWithNewCarryover = acc.copy(carryover = newCarryover)
kv.split(":", 2) match {
case Array("", _) =>
case Array("", _) =>
// Line started with a colon; ignore line
readLines(accWithNewCarryover)
case Array(key, value) =>
@@ -108,13 +109,14 @@ object SseEventReader {
readLines(
withSingleKeyValuePair(accWithNewCarryover, key, value)
)
case Array(key) =>
case Array(key) =>
// Line contained a key with no value, use empty string
readLines(
withSingleKeyValuePair(accWithNewCarryover, key, "")
)
case _ => ???
case _ => ???
}
case _ => ???
case _ => ???
}
}
}
@@ -1,24 +1,25 @@
package net.eagle0.common.sse
import java.net.http.HttpResponse.BodySubscriber
import java.nio.charset.StandardCharsets
import java.nio.ByteBuffer
import java.util
import java.util.concurrent.{CompletableFuture, CompletionStage, Flow}
import java.util.function.Consumer
import scala.annotation.tailrec
import scala.jdk.CollectionConverters.CollectionHasAsScala
import org.json4s.ParserUtil
class SseException(message: String, cause: Throwable = null) extends Exception(message, cause)
import java.net.http.HttpResponse.BodySubscriber
import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets
import java.util
import java.util.concurrent.{CompletableFuture, CompletionStage, Flow}
import java.util.function.Consumer
import scala.annotation.tailrec
import scala.jdk.CollectionConverters.CollectionHasAsScala
class SseSubscriber(messageDataConsumer: Consumer[String]) extends BodySubscriber[Unit] {
class SseException(message: String, cause: Throwable = null)
extends Exception(message, cause)
class SseSubscriber(messageDataConsumer: Consumer[String])
extends BodySubscriber[Unit] {
@volatile private var subscription: Flow.Subscription = _
private val future: CompletableFuture[Unit] = new CompletableFuture[Unit]
private val future: CompletableFuture[Unit] = new CompletableFuture[Unit]
private val DoneToken = "[DONE]"
private val DoneToken = "[DONE]"
private val MessageType = "message"
override def getBody: CompletionStage[Unit] = future
@@ -40,7 +41,7 @@ class SseSubscriber(messageDataConsumer: Consumer[String]) extends BodySubscribe
@volatile private var incompleteText: Option[SseEventReader.IncompleteEvent] =
None
override def onNext(buffers: util.List[ByteBuffer]): Unit = try {
override def onNext(buffers: util.List[ByteBuffer]): Unit = try {
val newText = buffers.asScala
.map(StandardCharsets.UTF_8.decode)
.mkString
@@ -49,7 +50,7 @@ class SseSubscriber(messageDataConsumer: Consumer[String]) extends BodySubscribe
def consumeEvents(
incompleteEvent: Option[SseEventReader.IncompleteEvent],
remainingText: String
): Option[SseEventReader.IncompleteEvent] =
): Option[SseEventReader.IncompleteEvent] = {
SseEventReader.readOneEvent(incompleteEvent, remainingText) match {
case SseEventReader.EndOfStream =>
None
@@ -90,6 +91,7 @@ class SseSubscriber(messageDataConsumer: Consumer[String]) extends BodySubscribe
case incomplete: SseEventReader.IncompleteEvent =>
Some(incomplete)
}
}
this.incompleteText = consumeEvents(this.incompleteText, newText)
@@ -104,7 +106,7 @@ class SseSubscriber(messageDataConsumer: Consumer[String]) extends BodySubscribe
println(fullBuffer)
future.completeExceptionally(pe)
subscription.cancel()
case e: Exception =>
case e: Exception =>
future.completeExceptionally(e)
subscription.cancel()
}
+11 -10
View File
@@ -1,21 +1,21 @@
package net.eagle0.eagle
import scala.concurrent.ExecutionContext
import scala.language.existentials
import scala.util.Random
import io.grpc.{Server, ServerBuilder}
import net.eagle0.common.{FunctionalRandom, SeededRandom, SimpleTimedLogger}
import net.eagle0.eagle.api.eagle.EagleGrpc
import net.eagle0.eagle.service.*
import scala.concurrent.ExecutionContext
import scala.language.existentials
import scala.util.Random
object Main {
import ServerSetupHelpers.*
private val eagleGrpcPortKey = Symbol("eagleGrpcPort")
private val eagleGrpcPortKey = Symbol("eagleGrpcPort")
private val defaultEagleGrpcPort = "40032"
private val shardokInterfaceRemoteAddressKey = Symbol(
private val shardokInterfaceRemoteAddressKey = Symbol(
"shardokInterfaceRemoteAddress"
)
private val defaultShardokInterfaceRemoteAddress = "eagle0.net:443"
@@ -26,25 +26,26 @@ object Main {
def nextOption(
map: Map[Symbol, String],
list: Vector[String]
): Map[Symbol, String] =
): Map[Symbol, String] = {
list match {
case "--eagle-grpc-port" +: value +: tail =>
case "--eagle-grpc-port" +: value +: tail =>
nextOption(map ++ Map(eagleGrpcPortKey -> value), tail)
case "--shardok-interface-remote-address" +: value +: tail =>
nextOption(
map ++ Map(shardokInterfaceRemoteAddressKey -> value),
tail
)
case "--gpt-model-name" +: value +: tail =>
case "--gpt-model-name" +: value +: tail =>
nextOption(
map ++ Map(gptModelNameKey -> value),
tail
)
case option +: _ =>
case option +: _ =>
throw new IllegalArgumentException("Unknown option " + option)
case _ => map
}
}
nextOption(Map(), args.toVector)
}
@@ -1,11 +1,12 @@
package net.eagle0.eagle
import scala.annotation.tailrec
//import net.eagle0.eagle.common.action_result_type.ActionResultType.PROVINCE_CONQUERED
import net.eagle0.eagle.common.action_result_type.ActionResultType.INVITATION_ACCEPTED
import net.eagle0.eagle.service.persistence.{eagleExtension, LocalFilePersister}
import net.eagle0.eagle.service.PersistedHistory
import net.eagle0.eagle.service.persistence.{LocalFilePersister, eagleExtension}
import scala.annotation.tailrec
// USAGE
// bazel run //src/main/scala/net/eagle0/eagle:remove_actions -- /Users/dancrosby/eagle0/eagle/save/ f0fef63f600f31f8 11
@@ -23,7 +24,7 @@ object RemoveActions {
val persister = new LocalFilePersister(
path + gameId.toHexString
)
val history =
val history =
PersistedHistory.gameHistoryFromPersister(gameId).get
val found = history.recentHistory.indexWhere(
@@ -53,8 +54,8 @@ object RemoveActions {
}
def main(args: Array[String]): Unit = {
val path = args(0)
val gameId = BigInt(args(1), 16).toLong
val path = args(0)
val gameId = BigInt(args(1), 16).toLong
val countToTruncate = args(2).toInt
truncate(path = path, gameId = gameId, countToTruncate = countToTruncate)
@@ -1,14 +1,14 @@
package net.eagle0.eagle
import scala.annotation.tailrec
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.eagle.service.persistence.LocalFilePersister
import net.eagle0.eagle.service.PersistedHistory
import scalapb.{GeneratedMessage, GeneratedMessageCompanion}
import net.eagle0.eagle.service.persistence.LocalFilePersister
import scalapb.descriptors.*
import scalapb.{GeneratedMessage, GeneratedMessageCompanion}
import scala.annotation.tailrec
object MessageComparators {
@tailrec
@@ -18,13 +18,14 @@ object MessageComparators {
acc: Vector[FieldDescriptor] = Vector()
): Vector[FieldDescriptor] = fieldNames match {
case name +: tail =>
val field =
val field = {
comp.scalaDescriptor.fields
.find(_.name == name)
.getOrElse {
println(s"No field $name in ${comp.scalaDescriptor.name}")
sys.exit(1)
}
}
if tail.isEmpty then acc :+ field
else
@@ -33,7 +34,7 @@ object MessageComparators {
tail,
acc :+ field
)
case _ => acc
case _ => acc
}
def nestedPValue(
@@ -49,7 +50,7 @@ object MessageComparators {
gm.getFieldByNumber(fd.number).asInstanceOf[GeneratedMessage],
tail
)
case _ => gm.toPMessage
case _ => gm.toPMessage
}
def typedComparison[T](
@@ -58,17 +59,19 @@ object MessageComparators {
value: String
): (GeneratedMessage => Boolean) = comparator match {
case "=" =>
gm =>
nestedPValue(gm, fieldDescriptors) match {
case PInt(i) => i == value.toInt
case PDouble(d) => d == value.toDouble
case PString(s) => s == value
case PBoolean(b) => b == value.toBoolean
case PEmpty => value.isEmpty
case x =>
throw new NotImplementedError(s"$x pvalue not implemented")
}
case x => throw new NotImplementedError(s"$x comparator not implemented")
(
gm =>
nestedPValue(gm, fieldDescriptors) match {
case PInt(i) => i == value.toInt
case PDouble(d) => d == value.toDouble
case PString(s) => s == value
case PBoolean(b) => b == value.toBoolean
case PEmpty => value.isEmpty
case x =>
throw new NotImplementedError(s"$x pvalue not implemented")
}
)
case x => throw new NotImplementedError(s"$x comparator not implemented")
}
}
@@ -77,9 +80,9 @@ object GameStateFilters {
def filter(filter: String): ActionWithResultingState => Boolean = {
val (left, rightWithComparator) = filter.span(!comparisonChars.contains(_))
val (comparator, right) = rightWithComparator.splitAt(1)
val (comparator, right) = rightWithComparator.splitAt(1)
val fieldNames = left.split('.').toVector
val fieldNames = left.split('.').toVector
val (prefix, remaining) = fieldNames.splitAt(1)
prefix.head match {
case "gs" =>
@@ -118,11 +121,11 @@ object SavedGameUtils {
def filterOne(
results: Vector[ActionWithResultingState],
filter: String
): Vector[ActionWithResultingState] =
results.filter {
case awrs =>
GameStateFilters.filter(filter)(awrs)
): Vector[ActionWithResultingState] = {
results.filter { case awrs =>
GameStateFilters.filter(filter)(awrs)
}
}
def filteredResults(
results: Vector[ActionWithResultingState],
@@ -138,9 +141,9 @@ object SavedGameUtils {
def fieldValues(
results: Vector[ActionWithResultingState],
fields: Vector[String]
): Vector[FieldWithValue] =
): Vector[FieldWithValue] = {
for {
result <- results
result <- results
qualifiedFieldName <- fields
} yield {
val (ident, subfieldNames) = qualifiedFieldName.split('.').splitAt(1)
@@ -169,6 +172,7 @@ object SavedGameUtils {
)
}
}
}
def main(args: Array[String]): Unit = {
val argList = args.toList
@@ -203,7 +207,8 @@ object SavedGameUtils {
)
def withoutFilterIndices(indices: Vector[Int]): FilteredResultsState = {
val newFilters = filters.zipWithIndex.filterNot { case (_, idx) => indices.contains(idx) }
val newFilters = filters.zipWithIndex
.filterNot { case (_, idx) => indices.contains(idx) }
.map(_._1)
this.copy(
filters = newFilters,
@@ -213,7 +218,8 @@ object SavedGameUtils {
def withoutPrintIndices(indices: Vector[Int]): FilteredResultsState =
this.copy(
prints = prints.zipWithIndex.filterNot { case (_, idx) => indices.contains(idx) }
prints = prints.zipWithIndex
.filterNot { case (_, idx) => indices.contains(idx) }
.map(_._1)
)
}
@@ -222,14 +228,12 @@ object SavedGameUtils {
state: FilteredResultsState
): FilteredResultsState = {
println("Your filters:")
state.filters.zipWithIndex.foreach {
case (filt, idx) =>
println(s" ($idx) $filt")
state.filters.zipWithIndex.foreach { case (filt, idx) =>
println(s" ($idx) $filt")
}
println("Your prints:")
state.prints.zipWithIndex.foreach {
case (pf, idx) =>
println(s" ($idx) $pf")
state.prints.zipWithIndex.foreach { case (pf, idx) =>
println(s" ($idx) $pf")
}
println(s"${state.partialResults.size} filtered results")
@@ -244,8 +248,8 @@ object SavedGameUtils {
state.withoutFilterIndices(idxStrings.map(_.toInt).toVector)
case "dp" :: idxStrings =>
state.withoutPrintIndices(idxStrings.map(_.toInt).toVector)
case "q" :: _ => sys.exit(0)
case x :: _ =>
case "q" :: _ => sys.exit(0)
case x :: _ =>
println(s"Unknown command $x, stopping")
state
}
@@ -1,9 +1,9 @@
package net.eagle0.eagle
import scala.util.Random
import net.eagle0.eagle.service.new_game_creation.GameParametersUtils
import net.eagle0.eagle.service.ServerSetupHelpers.newGamesManager
import net.eagle0.eagle.service.new_game_creation.GameParametersUtils
import scala.util.Random
object TestAIGame {
def main(args: Array[String]): Unit = {
@@ -18,7 +18,8 @@ object TestAIGame {
(1 to gameCount).foreach { _ =>
gamesManager.synchronizedCreateGame(
expandedGameParameters = GameParametersUtils.defaultExpandedGameParameters,
expandedGameParameters =
GameParametersUtils.defaultExpandedGameParameters,
gameId = Random.nextLong(),
humanPlayers = Vector.empty,
aiPlayerCount = 5
@@ -1,21 +1,21 @@
package net.eagle0.eagle.ai
import scala.collection.Map
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.Engine
import net.eagle0.eagle.library.util.*
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
import net.eagle0.eagle.library.util.command_choice_helpers.{
AlmsCommandSelector,
AttackDecisionCommandChooser,
CommandChooser
}
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
import net.eagle0.eagle.library.Engine
import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
import scala.collection.Map
case class AIClientWithSelectedCommand(
client: AIClient,
@@ -118,7 +118,7 @@ case class AIClient(
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[AIClientWithSelectedCommand] = {
val opac = acs.head._2
val opac = acs.head._2
val oneProvinceAcs = acs.head._2.commands
CommandChooser.choose(
@@ -215,7 +215,7 @@ case class AIClient(
) match {
case RandomState(Some(cs), fr) =>
RandomState(AIClientWithSelectedCommand(this, Some(cs)), fr)
case RandomState(None, fr) =>
case RandomState(None, fr) =>
if EarlyGameAIClient.isEarlyGame(gs, factionId) then
EarlyGameAIClient
.chooseEarlyGameCommand(
@@ -1,12 +1,11 @@
package net.eagle0.eagle.ai
import net.eagle0.eagle.{FactionId, GameId}
import java.io.FileWriter
import java.nio.file.Paths
import scala.util.Using
import net.eagle0.eagle.{FactionId, GameId}
class AIClientLogger(gameId: GameId, factionId: FactionId) {
private val logFile = Paths
.get(
@@ -1,23 +1,24 @@
package net.eagle0.eagle.ai
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
object AIClientUtils {
def extraHeroCount(pid: ProvinceId, fid: FactionId, gs: GameState): Int =
(gs.provinces(pid).rulingFactionHeroIds.size - desiredHeroCount(
(gs.provinces(pid.value).rulingFactionHeroIds.size - desiredHeroCount(
pid,
fid,
gs
)).max(0)
def desiredHeroCount(pid: ProvinceId, fid: FactionId, gs: GameState): Int =
def desiredHeroCount(pid: ProvinceId, fid: FactionId, gs: GameState): Int = {
if LegacyFactionUtils.hostileNeighbors(pid, fid, gs).nonEmpty then 3
else if gs.provinces(pid).support < 65 then 2
else if gs.provinces(pid.value).support < 65 then 2
else 1
}
def desiredCountForMarchTowardFocus(
originProvince: Province,
@@ -54,7 +55,11 @@ object AIClientUtils {
originProvince = originProvince,
destinationProvince = destinationProvince,
availableHeroIds = availableHeroIds,
factionLeaders = gs.factions(originProvince.rulingFactionId.get).leaders.toVector,
factionLeaders = gs
.factions(originProvince.rulingFactionId.get)
.leaders
.toVector
.map(HeroId(_)),
favorLeaders = favorLeaders
),
gs,
@@ -69,19 +74,21 @@ object AIClientUtils {
favorLeaders: Boolean
): Vector[HeroId] = {
val availableHeroes = availableHeroIds
.map(gs.heroes)
.map(hid => gs.heroes(hid.value))
if availableHeroes.size <= desiredCount then availableHeroes.map(_.id)
if availableHeroes.size <= desiredCount then
availableHeroes.map(h => HeroId(h.id))
else
availableHeroes
.sortBy(hero =>
(
if favorLeaders then !LegacyFactionUtils.isFactionLeader(hero.id, gs)
else LegacyFactionUtils.isFactionLeader(hero.id, gs),
if favorLeaders then
!LegacyFactionUtils.isFactionLeader(HeroId(hero.id), gs)
else LegacyFactionUtils.isFactionLeader(HeroId(hero.id), gs),
-LegacyHeroUtils.power(hero)
)
)
.map(_.id)
.map(h => HeroId(h.id))
.take(desiredCount)
}
}
@@ -1,6 +1,7 @@
package net.eagle0.eagle.ai
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.FactionId
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers.{
@@ -11,9 +12,8 @@ import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooser
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.DeterministicCommandChooser
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.FactionId
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
object EarlyGameAIClient {
def isEarlyGame(gs: GameState, factionId: FactionId): Boolean =
@@ -26,7 +26,7 @@ object EarlyGameAIClient {
gs: GameState,
acs: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[CommandSelection] =
): RandomState[CommandSelection] = {
CommandChooser
.choose(
actingFactionId = actingFactionId,
@@ -40,4 +40,5 @@ object EarlyGameAIClient {
functionalRandom = functionalRandom
)
.map(_.get)
}
}
@@ -4,8 +4,8 @@ import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.ProvinceDistances
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
object FactionLeaderProvinceRanker {
private def factionLeaderProvinceOrdering(
@@ -25,7 +25,7 @@ object FactionLeaderProvinceRanker {
): Ordering[Province] = Ordering.by((p: Province) =>
ProvinceDistances.distanceThroughFriendliesOption(
fromProvinceId,
p.id,
ProvinceId(p.id),
factionId,
gameState
)
@@ -36,7 +36,9 @@ object FactionLeaderProvinceRanker {
factionId: FactionId,
gameState: GameState
): Ordering[Province] = Ordering
.by((p: Province) => LegacyFactionUtils.hostileNeighbors(p, factionId, gameState).size)
.by((p: Province) =>
LegacyFactionUtils.hostileNeighbors(p, factionId, gameState).size
)
.reverse
// Orders by most-neutral-neighbors-first
@@ -74,7 +76,7 @@ object FactionLeaderProvinceRanker {
LegacyFactionUtils
.provinces(factionId, gameState)
.sorted(ordering)
.map(_.id)
.map(p => ProvinceId(p.id))
}
def bestProvinceId(
@@ -88,7 +90,7 @@ object FactionLeaderProvinceRanker {
LegacyFactionUtils
.provinces(factionId, gameState)
.minOption(ordering)
.map(_.id)
.map(p => ProvinceId(p.id))
}
def bestProvinceId(
@@ -104,7 +106,7 @@ object FactionLeaderProvinceRanker {
LegacyFactionUtils
.provinces(factionId, gameState)
.minOption(ordering)
.map(_.id)
.map(p => ProvinceId(p.id))
}
def rankedProvinceIds(
@@ -120,7 +122,7 @@ object FactionLeaderProvinceRanker {
LegacyFactionUtils
.provinces(factionId, gameState)
.sorted(ordering)
.map(_.id)
.map(p => ProvinceId(p.id))
}
def bestSettlementPidForFactionLeader(
@@ -131,7 +133,7 @@ object FactionLeaderProvinceRanker {
val ordering = factionLeaderProvinceOrdering(
factionId = factionId,
gameState = gameState,
fromProvinceId = currentProvince.id
fromProvinceId = ProvinceId(currentProvince.id)
)
LegacyFactionUtils
@@ -139,7 +141,7 @@ object FactionLeaderProvinceRanker {
.filter(ordering.lt(_, currentProvince))
.filterNot(LegacyProvinceUtils.ruledByFactionLeader(_, gameState))
.minOption(ordering)
.map(_.id)
.map(p => ProvinceId(p.id))
}
def bestSettlementPidForFactionLeader(
@@ -149,7 +151,7 @@ object FactionLeaderProvinceRanker {
): Option[ProvinceId] =
bestSettlementPidForFactionLeader(
factionId = factionId,
currentProvince = gameState.provinces(currentProvinceId),
currentProvince = gameState.provinces(currentProvinceId.value),
gameState = gameState
)
}
@@ -1,14 +1,17 @@
package net.eagle0.eagle.ai
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand}
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.{
AvailableCommand,
MarchAvailableCommand
}
import net.eagle0.eagle.api.selected_command.MarchSelectedCommand
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.randomSelectionForType
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.FactionId
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
object FixLeaderAloneCommandSelector {
def maybeFixLeaderAlone(
@@ -20,53 +23,60 @@ object FixLeaderAloneCommandSelector {
randomSelectionForType[MarchAvailableCommand](
availableCommands,
functionalRandom
) {
case (ac, frOuter) =>
frOuter.nextFlatCollectFirst(
ac.oneProvinceCommands
.filter(opmc =>
gameState
.provinces(opmc.originProvinceId)
.rulingFactionHeroIds
.filterNot(LegacyFactionUtils.isFactionLeader(_, gameState))
.size > 1
)
) { opmc => fr =>
opmc.availableDestinationProvinces
.map(dest => gameState.provinces(dest.provinceId))
.filter(_.rulingFactionId.contains(actingFactionId))
.filter(_.rulingFactionHeroIds.size == 1)
.filter(dest =>
LegacyFactionUtils
.isFactionLeader(dest.rulingFactionHeroIds.head, gameState)
)
.map { destination =>
fr.nextRandomElement(opmc.availableHeroIds)
.map { hid =>
CombatUnit(factionId = actingFactionId, heroId = hid)
}
.map { combatUnit =>
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = ac.actingProvinceId,
available = ac,
selected = MarchSelectedCommand(
gold = 0,
food = 0,
originProvince = opmc.originProvinceId,
destinationProvinceId = destination.id,
marchingUnits = Vector(
combatUnit
)
),
reason = "Moving a hero so that a faction leader won't be alone"
)
) { case (ac, frOuter) =>
frOuter.nextFlatCollectFirst(
ac.oneProvinceCommands
.filter(opmc =>
gameState
.provinces(opmc.originProvinceId)
.rulingFactionHeroIds
.filterNot(hid =>
LegacyFactionUtils.isFactionLeader(HeroId(hid), gameState)
)
.size > 1
)
) { opmc => fr =>
opmc.availableDestinationProvinces
.map(
gameState.provinces
)
.filter(_.rulingFactionId.contains(actingFactionId))
.filter(_.rulingFactionHeroIds.size == 1)
.filter(dest =>
LegacyFactionUtils
.isFactionLeader(
HeroId(dest.rulingFactionHeroIds.head),
gameState
)
)
.map { destination =>
fr.nextRandomElement(opmc.availableHeroIds)
.map { hid =>
CombatUnit(factionId = actingFactionId.value, heroId = hid)
}
.map { combatUnit =>
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = ProvinceId(ac.actingProvinceId),
available = ac,
selected = MarchSelectedCommand(
gold = 0,
food = 0,
originProvince = opmc.originProvinceId,
destinationProvinceId = destination.id,
marchingUnits = Vector(
combatUnit
)
),
reason =
"Moving a hero so that a faction leader won't be alone"
)
}
}
.headOption
.getOrElse(RandomState(None, fr))
}
)
}
}
.headOption
.getOrElse(RandomState(None, fr))
}
}
}
@@ -1,14 +1,20 @@
package net.eagle0.eagle.ai
import net.eagle0.eagle.api.available_command.{AvailableCommand, DiplomacyAvailableCommand}
import net.eagle0.eagle.api.available_command.{
AvailableCommand,
DiplomacyAvailableCommand
}
import net.eagle0.eagle.api.command.util.diplomacy_option.InvitationOption
import net.eagle0.eagle.api.selected_command.DiplomacySelectedCommand
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.settings.MinChanceForAIInvite
import net.eagle0.eagle.library.util.command_choice_helpers.{ProvinceGoldSurplusCalculator, TrustForDiplomacy}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.FactionId
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
import net.eagle0.eagle.library.util.command_choice_helpers.{
ProvinceGoldSurplusCalculator,
TrustForDiplomacy
}
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.library.settings.MinChanceForAIInvite
object InvitationCommandSelector {
private case class InvitationOptionWithChance(
@@ -21,41 +27,49 @@ object InvitationCommandSelector {
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[DiplomacyAvailableCommand](acs = availableCommands) { diplomacyAvailableCommand =>
diplomacyAvailableCommand.options.collect { case io: InvitationOption => io }.filter { invitationOption =>
TrustForDiplomacy.meetsConditionsForInvitation(
actingFactionId = actingFactionId,
targetFactionId = invitationOption.targetFactionId,
gameState = gameState
)
}.filter { invitationOption =>
ProvinceGoldSurplusCalculator.provinceGoldSurplus(
diplomacyAvailableCommand.actingProvinceId,
gameState
) >= invitationOption.goldCost
}.map { invitationOption =>
InvitationOptionWithChance(
invitationOption = invitationOption,
acceptanceChance = ResolveDiplomacyCommandSelector.invitationAcceptanceChance(
actingFactionId,
invitationOption.targetFactionId,
gameState
)
)
}.maxByOption(_.acceptanceChance)
.filter(_.acceptanceChance >= MinChanceForAIInvite.intValue)
.map { iowc =>
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = diplomacyAvailableCommand.actingProvinceId,
available = diplomacyAvailableCommand,
selected = DiplomacySelectedCommand(
selectedOption = iowc.invitationOption,
targetFactionId = iowc.invitationOption.targetFactionId,
sentHeroId = diplomacyAvailableCommand.recommendedHeroId
),
reason = "maybeInviteOtherFaction"
)
}
flatSelectionForType[DiplomacyAvailableCommand](acs = availableCommands) {
diplomacyAvailableCommand =>
diplomacyAvailableCommand.options
.collect { case io: InvitationOption => io }
.filter { invitationOption =>
TrustForDiplomacy.meetsConditionsForInvitation(
actingFactionId = actingFactionId,
targetFactionId = FactionId(invitationOption.targetFactionId),
gameState = gameState
)
}
.filter { invitationOption =>
ProvinceGoldSurplusCalculator.provinceGoldSurplus(
ProvinceId(diplomacyAvailableCommand.actingProvinceId),
gameState
) >= invitationOption.goldCost
}
.map { invitationOption =>
InvitationOptionWithChance(
invitationOption = invitationOption,
acceptanceChance =
ResolveDiplomacyCommandSelector.invitationAcceptanceChance(
actingFactionId,
FactionId(invitationOption.targetFactionId),
gameState
)
)
}
.maxByOption { _.acceptanceChance }
.filter(_.acceptanceChance >= MinChanceForAIInvite.intValue)
.map { iowc =>
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId =
ProvinceId(diplomacyAvailableCommand.actingProvinceId),
available = diplomacyAvailableCommand,
selected = DiplomacySelectedCommand(
selectedOption = iowc.invitationOption,
targetFactionId = iowc.invitationOption.targetFactionId,
sentHeroId = diplomacyAvailableCommand.recommendedHeroId
),
reason = "maybeInviteOtherFaction"
)
}
}
}
@@ -1,13 +1,20 @@
package net.eagle0.eagle.ai
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.{MarchAvailableCommand, MarchCommandFromOneProvince}
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.{
MarchAvailableCommand,
MarchCommandFromOneProvince
}
import net.eagle0.eagle.api.selected_command.MarchSelectedCommand
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.{CommandSelection, LegacyBattalionUtils, ProvinceDistances}
import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusCalculator.provinceGoldSurplus
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusCalculator.provinceGoldSurplus
import net.eagle0.eagle.library.util.{
LegacyBattalionUtils,
CommandSelection,
ProvinceDistances
}
object MarchTowardProvinceCommandChooser {
def marchTowardProvinceCommand(
@@ -20,81 +27,85 @@ object MarchTowardProvinceCommandChooser {
): Option[CommandSelection] = {
val bestCommand =
opmc.availableDestinationProvinces
.map(dest => (opmc, gs.provinces(dest.provinceId)))
.flatMap {
case (opmc, p) =>
ProvinceDistances
.distanceThroughFriendliesOption(
destinationProvinceId,
p.id,
fid,
gs
)
.map(distance => (opmc, p, distance))
.map(dest => (opmc, gs.provinces(dest)))
.flatMap { case (opmc, p) =>
ProvinceDistances
.distanceThroughFriendliesOption(
destinationProvinceId,
ProvinceId(p.id),
fid,
gs
)
.map(distance => (opmc, p, distance))
}
.minByOption(_._3)
val actingProvinceId = ac.actingProvinceId
bestCommand.flatMap {
case (opmc, dest, _) =>
internalRequire(
opmc.originProvinceId != destinationProvinceId,
s"Marching away from $destinationProvinceId in an effort to get to that province"
bestCommand.flatMap { case (opmc, dest, _) =>
internalRequire(
ProvinceId(opmc.originProvinceId) != destinationProvinceId,
s"Marching away from $destinationProvinceId in an effort to get to that province"
)
val originProvince = gs.provinces(opmc.originProvinceId)
val takenHeroIds =
AIClientUtils.takenHeroIdsForMarchTowardFocus(
originProvince = originProvince,
destinationProvince = dest,
availableHeroIds = opmc.availableHeroIds.toVector.map(HeroId(_)),
favorLeaders = favorLeaders,
gs = gs
)
val originProvince = gs.provinces(opmc.originProvinceId)
Option.when(takenHeroIds.nonEmpty) {
val takenBattalions =
if originProvince.battalionIds.size <= takenHeroIds.size then
originProvince.battalionIds.map(gs.battalions)
else
originProvince.battalionIds
.map(gs.battalions)
.sortBy(_.size)
.take(takenHeroIds.size)
val takenHeroIds =
AIClientUtils.takenHeroIdsForMarchTowardFocus(
originProvince = originProvince,
destinationProvince = dest,
availableHeroIds = opmc.availableHeroIds.toVector,
favorLeaders = favorLeaders,
gs = gs
val takenUnits = takenHeroIds
.zipAll(
takenBattalions.map(batt => Some(batt.id)),
HeroId(0),
None
)
Option.when(takenHeroIds.nonEmpty) {
val takenBattalions =
if originProvince.battalionIds.size <= takenHeroIds.size then originProvince.battalionIds.map(gs.battalions)
else
originProvince.battalionIds
.map(gs.battalions)
.sortBy(_.size)
.take(takenHeroIds.size)
val takenUnits = takenHeroIds
.zipAll(
takenBattalions.map(batt => Some(batt.id)),
0,
None
.map { case (hid, bid) =>
CombatUnit(
factionId = fid.value,
heroId = hid.value,
battalionId = bid
)
.map {
case (hid, bid) =>
CombatUnit(factionId = fid, heroId = hid, battalionId = bid)
}
}
val foodToTake = 2 * LegacyBattalionUtils.monthlyConsumedFood(
takenBattalions,
gs.battalionTypes.toVector
)
val goldToTake = provinceGoldSurplus(originProvince.id, gs)
CommandSelection(
actingFactionId = fid,
actingProvinceId = actingProvinceId,
available = ac,
selected = MarchSelectedCommand(
marchingUnits = takenUnits, // Check the food cost here?
destinationProvinceId = dest.id,
originProvince = opmc.originProvinceId,
gold = Math.min(goldToTake, opmc.goldAvailable),
food = Math.min(foodToTake, opmc.foodAvailable)
),
reason =
if favorLeaders then s"marching leaders toward ${gs.provinces(destinationProvinceId).name}"
else s"marching heroes toward ${gs.provinces(destinationProvinceId).name}"
)
}
val foodToTake = 2 * LegacyBattalionUtils.monthlyConsumedFood(
takenBattalions,
gs.battalionTypes.toVector
)
val goldToTake = provinceGoldSurplus(ProvinceId(originProvince.id), gs)
CommandSelection(
actingFactionId = fid,
actingProvinceId = ProvinceId(actingProvinceId),
available = ac,
selected = MarchSelectedCommand(
marchingUnits = takenUnits, // Check the food cost here?
destinationProvinceId = dest.id,
originProvince = opmc.originProvinceId,
gold = Math.min(goldToTake, opmc.goldAvailable),
food = Math.min(foodToTake, opmc.foodAvailable),
unknownFields = scalapb.UnknownFieldSet.empty
),
reason = if favorLeaders then
s"marching leaders toward ${gs.provinces(destinationProvinceId.value).name}"
else
s"marching heroes toward ${gs.provinces(destinationProvinceId.value).name}"
)
}
}
}
}
@@ -1,18 +1,29 @@
package net.eagle0.eagle.ai
import net.eagle0.common.{FunctionalRandom, MoreOption, RandomState}
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.ai.AIClientUtils.extraHeroCount
import net.eagle0.eagle.api.available_command.*
import net.eagle0.eagle.views.battalion_view.BattalionView
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
import net.eagle0.eagle.api.selected_command.{MarchSelectedCommand, ReconSelectedCommand}
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.common.improvement_type.ImprovementType
import net.eagle0.eagle.views.province_view.FullProvinceInfo
import net.eagle0.eagle.api.selected_command.{
MarchSelectedCommand,
ReconSelectedCommand
}
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.internal.faction_relationship.FactionRelationship
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.settings.{MinSupportForTaxes, MonthsReconConsideredRecent}
import net.eagle0.eagle.library.settings.{
MinSupportForTaxes,
MonthsReconConsideredRecent
}
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.util.*
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers.*
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
import net.eagle0.eagle.library.util.command_choice_helpers.{
AlmsCommandSelector,
AttackCommandChooser,
@@ -25,15 +36,10 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
OrganizeCommandSelector,
ProvinceGoldSurplusCalculator
}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers.*
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.views.battalion_view.BattalionView
import net.eagle0.eagle.views.province_view.FullProvinceInfo
import net.eagle0.eagle.{FactionId, ProvinceId}
object MidGameAIClient {
private def validateNoFactionLeaderAlone(
@@ -135,20 +141,20 @@ object MidGameAIClient {
_ /* availableHeroIds */,
_ /* unknownFieldSet */
) =>
val actingProvince = gameState.provinces(actingProvinceId)
val actingProvince = gameState.provinces(actingProvinceId)
val foodMonthsToHoldBack =
FoodConsumptionUtils.foodConsumptionMonthsToHold(
actingProvinceId,
gameState
)
val foodToHoldBack =
val foodToHoldBack =
foodMonthsToHoldBack * LegacyProvinceUtils.monthlyFoodConsumption(
actingProvinceId,
gameState
)
val maxFoodToSend =
val maxFoodToSend =
Math.min(actingProvince.food - foodToHoldBack, foodAvailable)
val maxGoldToSend = Math.min(
val maxGoldToSend = Math.min(
ProvinceGoldSurplusCalculator
.provinceGoldSurplus(actingProvinceId, gameState),
goldAvailable
@@ -166,12 +172,12 @@ object MidGameAIClient {
.map(gameState.provinces)
.filter(_.rulingFactionId.contains(factionId))
.map { province =>
val consumption = LegacyProvinceUtils
val consumption = LegacyProvinceUtils
.monthlyFoodConsumption(province.id, gameState)
val incomingFood =
val incomingFood =
province.incomingShipments.flatMap(_.supplies).map(_.food).sum
val availableFood = province.food + incomingFood
val monthsOfFood =
val monthsOfFood =
if consumption == 0 then 120
else availableFood / consumption
DestinationFoodStatus(
@@ -183,28 +189,29 @@ object MidGameAIClient {
.filter(_.monthsOfFood < 4)
.minByOption(_.monthsOfFood)
bestDestination.map { destinationFoodStatus =>
val destinationProvince = destinationFoodStatus.province
val desiredFood = LegacyProvinceUtils.monthlyFoodConsumption(
destinationProvince.id,
gameState
) * 6 - destinationProvince.food
val foodToSend =
if desiredFood > maxFoodToSend then maxFoodToSend
else (desiredFood + maxFoodToSend) / 2
bestDestination
.map { destinationFoodStatus =>
val destinationProvince = destinationFoodStatus.province
val desiredFood = LegacyProvinceUtils.monthlyFoodConsumption(
destinationProvince.id,
gameState
) * 6 - destinationProvince.food
val foodToSend =
if desiredFood > maxFoodToSend then maxFoodToSend
else (desiredFood + maxFoodToSend) / 2
CommandChoiceHelpers
.chosenSendSuppliesCommandWithSuppliesAndDestination(
actingFactionId = factionId,
gameState = gameState,
ac = available,
supplies = Supplies(
gold = maxGoldToSend / 2,
food = foodToSend
),
destination = destinationFoodStatus.province.id
)
}
CommandChoiceHelpers
.chosenSendSuppliesCommandWithSuppliesAndDestination(
actingFactionId = factionId,
gameState = gameState,
ac = available,
supplies = Supplies(
gold = maxGoldToSend / 2,
food = foodToSend
),
destination = destinationFoodStatus.province.id
)
}
}
}
.map(_.withReason("relay supplies"))
@@ -328,70 +335,71 @@ object MidGameAIClient {
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
provincesWithFactionLeader(actingFactionId, gameState).map { province =>
MoreOption
.flatWhen(province.rulingFactionHeroIds.size < 4)(
maybeMoveHeroesTowardFactionLeaderCommand(
actingFactionId = actingFactionId,
gs = gameState,
acs = availableCommands
)
) match {
case Some(cs) => RandomState(Some(cs), functionalRandom)
case None =>
chosenGenericCommandFromRankedOptions(
actingFactionId = actingFactionId,
gs = gameState,
acs = availableCommands,
choosersAndReasons = Vector(
(
ExpandCommandSelector.maybeChosenExpandCommand,
"no hostile neighbors, expand"
),
(
maybeMoveToRecruitCommand,
"no hostile neighbors, travel to recruit"
),
(
provincesWithFactionLeader(actingFactionId, gameState)
.map { province =>
MoreOption
.flatWhen(province.rulingFactionHeroIds.size < 4)(
maybeMoveHeroesTowardFactionLeaderCommand(
actingFactionId = actingFactionId,
gs = gameState,
acs = availableCommands
)
) match {
case Some(cs) => RandomState(Some(cs), functionalRandom)
case None =>
chosenGenericCommandFromRankedOptions(
actingFactionId = actingFactionId,
gs = gameState,
acs = availableCommands,
choosersAndReasons = Vector(
(
actingFactionId,
gameState,
availableCommands,
functionalRandom
) =>
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
gameState,
availableCommands
),
functionalRandom
),
"chosen neutral neighbors: Improve b/c nothing better"
),
(
ExpandCommandSelector.maybeChosenExpandCommand,
"no hostile neighbors, expand"
),
(
actingFactionId,
gameState,
availableCommands,
functionalRandom
) =>
RandomState(
chosenRestCommand(
maybeMoveToRecruitCommand,
"no hostile neighbors, travel to recruit"
),
(
(
actingFactionId,
gameState,
availableCommands,
"chosen neutral neighbors: rest"
functionalRandom
) =>
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
gameState,
availableCommands
),
functionalRandom
),
functionalRandom
),
"chosen neutral neighbors: rest"
)
),
functionalRandom = functionalRandom
)
"chosen neutral neighbors: Improve b/c nothing better"
),
(
(
actingFactionId,
gameState,
availableCommands,
functionalRandom
) =>
RandomState(
chosenRestCommand(
actingFactionId,
gameState,
availableCommands,
"chosen neutral neighbors: rest"
),
functionalRandom
),
"chosen neutral neighbors: rest"
)
),
functionalRandom = functionalRandom
)
}
}
}
.find(_.newValue.isDefined)
.get
@@ -402,84 +410,85 @@ object MidGameAIClient {
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.DeterministicCommandChooser
provincesWithFactionLeader(actingFactionId, gameState).map { province =>
MoreOption
.flatWhen(province.rulingFactionHeroIds.size < 4)(
maybeMoveHeroesTowardFactionLeaderCommand(
actingFactionId = actingFactionId,
gs = gameState,
acs = availableCommands
)
) match {
case Some(cs) => RandomState(Some(cs), functionalRandom)
case None =>
chosenGenericCommandFromRankedOptions(
actingFactionId = actingFactionId,
gs = gameState,
acs = availableCommands,
choosersAndReasons = Vector(
(
maybeMoveToRecruitCommand,
"chosen no neutral neighbors, travel to recruit"
),
(
provincesWithFactionLeader(actingFactionId, gameState)
.map { province =>
MoreOption
.flatWhen(province.rulingFactionHeroIds.size < 4)(
maybeMoveHeroesTowardFactionLeaderCommand(
actingFactionId = actingFactionId,
gs = gameState,
acs = availableCommands
)
) match {
case Some(cs) => RandomState(Some(cs), functionalRandom)
case None =>
chosenGenericCommandFromRankedOptions(
actingFactionId = actingFactionId,
gs = gameState,
acs = availableCommands,
choosersAndReasons = Vector(
(
actingFactionId,
gameState,
availableCommands,
functionalRandom
) =>
RandomState(
MoveLeaderToBetterProvinceCommandChooser
.maybeMoveLeaderToBetterProvinceCommand(
maybeMoveToRecruitCommand,
"chosen no neutral neighbors, travel to recruit"
),
(
(
actingFactionId,
gameState,
availableCommands,
functionalRandom
) =>
RandomState(
MoveLeaderToBetterProvinceCommandChooser
.maybeMoveLeaderToBetterProvinceCommand(
actingFactionId,
gameState,
availableCommands
),
functionalRandom
),
"chosen no neutral neighbors, travel to front"
),
(
(
actingFactionId,
gameState,
availableCommands,
functionalRandom
) =>
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
gameState,
availableCommands
),
functionalRandom
),
"chosen no neutral neighbors, travel to front"
),
(
(
actingFactionId,
gameState,
availableCommands,
functionalRandom
) =>
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
gameState,
availableCommands
functionalRandom
),
functionalRandom
),
"chosen no neutral neighbors: Improve b/c nothing better"
),
(
"chosen no neutral neighbors: Improve b/c nothing better"
),
(
actingFactionId,
gameState,
availableCommands,
functionalRandom
) =>
RandomState(
chosenRestCommand(
(
actingFactionId,
gameState,
availableCommands,
"chosen no neutral neighbors: rest"
functionalRandom
) =>
RandomState(
chosenRestCommand(
actingFactionId,
gameState,
availableCommands,
"chosen no neutral neighbors: rest"
),
functionalRandom
),
functionalRandom
),
"chosen neutral neighbors: rest"
)
),
functionalRandom = functionalRandom
)
"chosen neutral neighbors: rest"
)
),
functionalRandom = functionalRandom
)
}
}
}
.find(_.newValue.isDefined)
.get
}
@@ -494,11 +503,11 @@ object MidGameAIClient {
.reconnedProvinces
.find(_.id == provinceId)
.flatMap(_.fullInfo)
def reconnedHeroCountFor(provinceId: ProvinceId): Int =
def reconnedHeroCountFor(provinceId: ProvinceId): Int =
reconnedFullInfoFor(provinceId)
.map(_.rulingFactionHeroIds.size)
.getOrElse(0)
def reconnedBattalionsFor(provinceId: ProvinceId): Vector[BattalionView] =
def reconnedBattalionsFor(provinceId: ProvinceId): Vector[BattalionView] =
reconnedFullInfoFor(provinceId)
.map(_.battalions.toVector)
.getOrElse(Vector())
@@ -664,7 +673,8 @@ object MidGameAIClient {
}
}
),
(fid, gs, acs, fr) => chosenNoNeutralNeighborsCommand(fid, gs, acs, fr)
(fid, gs, acs, fr) =>
chosenNoNeutralNeighborsCommand(fid, gs, acs, fr)
),
functionalRandom = functionalRandom
)
@@ -686,7 +696,7 @@ object MidGameAIClient {
factionId: FactionId
): Option[CommandSelection] =
flatSelectionForType[ImproveAvailableCommand](acs) { availableCommand =>
val province = gs.provinces(availableCommand.actingProvinceId)
val province = gs.provinces(availableCommand.actingProvinceId)
val existingBattalions = province.battalionIds
.map(gs.battalions)
@@ -715,7 +725,7 @@ object MidGameAIClient {
.map(_.minimumAgriculture - province.agriculture)
.filter(_ > 0)
.min
val neededEconomy = notAvailable
val neededEconomy = notAvailable
.map(_.minimumEconomy - province.economy)
.filter(_ > 0)
.min
@@ -860,7 +870,7 @@ object MidGameAIClient {
case (None, fr2) => go(RandomState(tail, fr2))
}
}
case (_, fr) => RandomState(None, fr)
case (_, fr) => RandomState(None, fr)
}
go(
@@ -879,34 +889,35 @@ object MidGameAIClient {
val factionLeaderProvincesNeedingHeroes =
provincesWithFactionLeader(actingFactionId, gs)
.filter(p => p.rulingFactionHeroIds.size < p.heroCap)
val cs = for {
val cs = for {
(marchCommand, idx) <- acs.zipWithIndex.collectFirst {
case (ac: MarchAvailableCommand, idx) => (ac, idx)
}
case (ac: MarchAvailableCommand, idx) => (ac, idx)
}
} yield {
// Find the march command origin provinces that contain extra heroes
val candidateCommands = marchCommand.oneProvinceCommands.filter { opmc =>
extraHeroCount(opmc.originProvinceId, actingFactionId, gs) > 0
}
val furthestOrigin = candidateCommands.flatMap { opmc =>
// distance to the faction leader province we're closest to
factionLeaderProvincesNeedingHeroes.flatMap { flp =>
ProvinceDistances
.distanceThroughFriendliesOption(
p1 = flp.id,
p2 = opmc.originProvinceId,
fid = actingFactionId,
gs = gs
)
.map {
(_: Int, flp.id: ProvinceId)
val furthestOrigin = candidateCommands
.flatMap { opmc =>
// distance to the faction leader province we're closest to
factionLeaderProvincesNeedingHeroes
.flatMap { flp =>
ProvinceDistances
.distanceThroughFriendliesOption(
p1 = flp.id,
p2 = opmc.originProvinceId,
fid = actingFactionId,
gs = gs
)
.map {
(_: Int, flp.id: ProvinceId)
}
}
}
.minByOption(_._1)
.filterNot(_._1 == 0) // If distance is zero, we'd be moving away from the province we're trying to get to
.map {
case (distance: Int, destinationProvinceId: ProvinceId) =>
.minByOption(_._1)
.filterNot(_._1 == 0) // If distance is zero, we'd be moving away from the province we're trying to get to
.map { case (distance: Int, destinationProvinceId: ProvinceId) =>
(
// distance to the faction leader province we're closest to
distance,
@@ -914,22 +925,21 @@ object MidGameAIClient {
extraHeroCount(opmc.originProvinceId, actingFactionId, gs),
opmc
)
}
}
}
}
.maxByOption(tup => (tup._1, tup._3))
.map(tup => (tup._2, tup._4))
.map { tup => (tup._2, tup._4) }
furthestOrigin.flatMap {
case (destinationProvinceId, opmc) =>
MarchTowardProvinceCommandChooser
.marchTowardProvinceCommand(
ac = marchCommand,
fid = actingFactionId,
opmc = opmc,
destinationProvinceId = destinationProvinceId,
gs = gs,
favorLeaders = false
)
furthestOrigin.flatMap { case (destinationProvinceId, opmc) =>
MarchTowardProvinceCommandChooser
.marchTowardProvinceCommand(
ac = marchCommand,
fid = actingFactionId,
opmc = opmc,
destinationProvinceId = destinationProvinceId,
gs = gs,
favorLeaders = false
)
}
}
@@ -947,7 +957,8 @@ object MidGameAIClient {
actingProvinceId = ac.actingProvinceId,
available = ac,
selected = ReconSelectedCommand(
actingHeroId = ac.availableHeroIds.maxBy(hid => gameState.heroes(hid).vigor),
actingHeroId =
ac.availableHeroIds.maxBy(hid => gameState.heroes(hid).vigor),
targetProvinceId = targetProvinceId
),
reason = "selectedReconCommand"
@@ -1035,11 +1046,11 @@ object MidGameAIClient {
gameState: GameState,
acs: Vector[AvailableCommand]
): Option[CommandSelection] = {
val faction = gameState.factions(actingFactionId)
val faction = gameState.factions(actingFactionId)
val factionLeaders = faction.leaders
flatSelectionForType[MarchAvailableCommand](acs) { marchAvailableCommand =>
marchAvailableCommand.oneProvinceCommands.flatMap { opmc =>
val originProvince = gameState.provinces(opmc.originProvinceId)
val originProvince = gameState.provinces(opmc.originProvinceId)
val leadersInOriginProvince =
originProvince.rulingFactionHeroIds.intersect(factionLeaders)
MoreOption.flatWhen(
@@ -1047,12 +1058,12 @@ object MidGameAIClient {
.intersect(factionLeaders)
.nonEmpty
) {
val leaderIdToMove =
val leaderIdToMove =
opmc.availableHeroIds
.intersect(factionLeaders)
.maxBy(hid => LegacyHeroUtils.seniorityOrder(faction, hid))
val validDestinations = opmc.availableDestinationProvinces
.map(dest => gameState.provinces(dest.provinceId))
.map(gameState.provinces)
.filter(
_.rulingFactionId.contains(actingFactionId)
) // only send to a province we own
@@ -1063,26 +1074,27 @@ object MidGameAIClient {
IncomingArmyUtils.isUnderAttack(province, gameState)
) // don't send a leader into a province under attack
validDestinations.minByOption(_.rulingFactionHeroIds.size).map { destinationProvince =>
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = marchAvailableCommand.actingProvinceId,
available = marchAvailableCommand,
selected = MarchSelectedCommand(
gold = 0,
food = 0,
originProvince = originProvince.id,
destinationProvinceId = destinationProvince.id,
marchingUnits = Vector(
CombatUnit(
factionId = actingFactionId,
heroId = leaderIdToMove,
battalionId = None
validDestinations.minByOption(_.rulingFactionHeroIds.size).map {
destinationProvince =>
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = marchAvailableCommand.actingProvinceId,
available = marchAvailableCommand,
selected = MarchSelectedCommand(
gold = 0,
food = 0,
originProvince = originProvince.id,
destinationProvinceId = destinationProvince.id,
marchingUnits = Vector(
CombatUnit(
factionId = actingFactionId,
heroId = leaderIdToMove,
battalionId = None
)
)
)
),
reason = "Spreading out the faction leaders"
)
),
reason = "Spreading out the faction leaders"
)
}
}
}.headOption
@@ -1,12 +1,16 @@
package net.eagle0.eagle.ai
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand, MarchCommandFromOneProvince}
import net.eagle0.eagle.api.available_command.{
AvailableCommand,
MarchAvailableCommand,
MarchCommandFromOneProvince
}
import net.eagle0.eagle.internal.faction.Faction
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.library.util.ProvinceDistances
import net.eagle0.eagle.library.util.ProvinceDistances.ProvinceAndDistance
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.{FactionId, ProvinceId}
object MoveLeaderToBetterProvinceCommandChooser {
private case class MCFOPWithDestinationAndDistance(
@@ -26,7 +30,8 @@ object MoveLeaderToBetterProvinceCommandChooser {
actingFactionId = actingFactionId,
gs = gs,
acs = acs,
betterDestinationChooser = FactionLeaderProvinceRanker.bestSettlementPidForFactionLeader
betterDestinationChooser =
FactionLeaderProvinceRanker.bestSettlementPidForFactionLeader
)
private def candidatesForMarchCommand(
@@ -52,21 +57,22 @@ object MoveLeaderToBetterProvinceCommandChooser {
actingFactionId,
mcfop.originProvinceId,
gs
).flatMap { desiredPid =>
ProvinceDistances
.closestProvinceThroughFriendliesOption(
desiredPid,
mcfop.availableDestinationProvinces.map(_.provinceId).toVector,
actingFactionId,
gs
)
.map { provinceAndDistance =>
MCFOPWithDestinationAndDistance(
mcfop = mcfop,
provinceAndDistance = provinceAndDistance
)
.flatMap { desiredPid =>
ProvinceDistances
.closestProvinceThroughFriendliesOption(
desiredPid,
mcfop.availableDestinationProvinces.toVector,
actingFactionId,
gs
)
}
}
.map { provinceAndDistance =>
MCFOPWithDestinationAndDistance(
mcfop = mcfop,
provinceAndDistance = provinceAndDistance
)
}
}
}
def maybeMoveLeaderToBetterProvinceCommandWithChooser(
@@ -76,14 +82,15 @@ object MoveLeaderToBetterProvinceCommandChooser {
betterDestinationChooser: DestinationChooser
): Option[CommandSelection] = {
val cs = for {
faction <- gs.factions.get(actingFactionId)
faction <- gs.factions.get(actingFactionId)
marchCommand <- acs.collectFirst { case ac: MarchAvailableCommand => ac }
} yield {
// Find the march command origin provinces that contain a faction leader
val candidatesWithDistances = candidateCommandsWithDistances(
actingFactionId = actingFactionId,
gs = gs,
candidateCommands = candidatesForMarchCommand(marchCommand, gs, faction),
candidateCommands =
candidatesForMarchCommand(marchCommand, gs, faction),
betterDestinationChooser = betterDestinationChooser
)
@@ -1,6 +1,7 @@
package net.eagle0.eagle.ai
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.{
AvailableCommand,
ResolveAllianceOfferAvailableCommand,
@@ -25,25 +26,24 @@ import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus.{
}
import net.eagle0.eagle.internal.faction_relationship.FactionRelationship.RelationshipLevel.ALLY
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.library.settings.{
AiAllianceAcceptanceBaseChance,
AiTruceAcceptanceBaseChance,
MinimumPrestigeDifferenceToInvite
}
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.{
randomSelectionForType,
selectionForType
}
import net.eagle0.eagle.library.util.command_choice_helpers.{
CommandChooser,
CommandChooserImplicits,
RansomOfferHelpers
}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.{
randomSelectionForType,
selectionForType
}
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.FactionId
object ResolveDiplomacyCommandSelector {
import CommandChooserImplicits.*
@@ -59,10 +59,10 @@ object ResolveDiplomacyCommandSelector {
gameState = gameState,
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
resolveInvitationSelectedCommand _,
resolveAllianceOfferSelectedCommand _,
resolveBreakAllianceSelectedCommand _,
resolveTruceOfferSelectedCommand _,
resolveInvitationSelectedCommand,
resolveAllianceOfferSelectedCommand,
resolveBreakAllianceSelectedCommand,
resolveTruceOfferSelectedCommand,
resolveRansomOfferSelectedCommand _
),
functionalRandom = functionalRandom
@@ -77,24 +77,23 @@ object ResolveDiplomacyCommandSelector {
randomSelectionForType[ResolveInvitationAvailableCommand](
availableCommands,
functionalRandom
) {
case (resolveInvitationAc, fr) =>
selectionForResolveInvitationCommand(
actingFactionId = actingFactionId,
ac = resolveInvitationAc,
gs = gameState,
functionalRandom = fr
).map { resolveInviteSelection =>
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = 0,
available = resolveInvitationAc,
selected = resolveInviteSelection,
reason = "resolving invitation"
)
) { case (resolveInvitationAc, fr) =>
selectionForResolveInvitationCommand(
actingFactionId = actingFactionId,
ac = resolveInvitationAc,
gs = gameState,
functionalRandom = fr
).map { resolveInviteSelection =>
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = ProvinceId(0),
available = resolveInvitationAc,
selected = resolveInviteSelection,
reason = "resolving invitation"
)
}
)
}
}
private def selectionForResolveInvitationCommand(
@@ -103,9 +102,15 @@ object ResolveDiplomacyCommandSelector {
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
val invitedFid = actingFactionId
val invitedFid = actingFactionId
val bestInvitation = ac.invitations
.maxBy(inv => invitationAcceptanceChance(inv.originatingFactionId, invitedFid, gs))
.maxBy(inv =>
invitationAcceptanceChance(
FactionId(inv.originatingFactionId),
invitedFid,
gs
)
)
internalRequire(
bestInvitation.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
@@ -122,7 +127,7 @@ object ResolveDiplomacyCommandSelector {
originatingFactionId = bestOriginatingFid,
resolution =
if 100.0 * roll < invitationAcceptanceChance(
bestOriginatingFid,
FactionId(bestOriginatingFid),
invitedFid,
gs
)
@@ -149,24 +154,23 @@ object ResolveDiplomacyCommandSelector {
randomSelectionForType[ResolveTruceOfferAvailableCommand](
availableCommands,
functionalRandom
) {
case (resolveTruceAc, fr) =>
selectionForResolveTruceOfferSelectedCommand(
actingFactionId = actingFactionId,
ac = resolveTruceAc,
gs = gameState,
functionalRandom = fr
).map { resolveTruceOfferSelection =>
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = 0,
available = resolveTruceAc,
selected = resolveTruceOfferSelection,
reason = "resolving truce offer"
)
) { case (resolveTruceAc, fr) =>
selectionForResolveTruceOfferSelectedCommand(
actingFactionId = actingFactionId,
ac = resolveTruceAc,
gs = gameState,
functionalRandom = fr
).map { resolveTruceOfferSelection =>
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = ProvinceId(0),
available = resolveTruceAc,
selected = resolveTruceOfferSelection,
reason = "resolving truce offer"
)
}
)
}
}
private def selectionForResolveTruceOfferSelectedCommand(
@@ -177,7 +181,13 @@ object ResolveDiplomacyCommandSelector {
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
val actingFid = actingFactionId
val bestOffer = ac.offers
.maxBy(inv => truceOfferAcceptanceChance(inv.originatingFactionId, actingFid, gs))
.maxBy(inv =>
truceOfferAcceptanceChance(
FactionId(inv.originatingFactionId),
actingFid,
gs
)
)
internalRequire(
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
@@ -194,7 +204,7 @@ object ResolveDiplomacyCommandSelector {
originatingFactionId = bestOriginatingFid,
resolution =
if 100.0 * roll < truceOfferAcceptanceChance(
originatingFid = bestOriginatingFid,
originatingFid = FactionId(bestOriginatingFid),
targetFid = actingFid,
gs = gs
)
@@ -223,20 +233,21 @@ object ResolveDiplomacyCommandSelector {
originatingFid: FactionId,
targetFid: FactionId,
gs: GameState
): Int =
): Int = {
// Always reject if the faction already has an alliance
if gs
.factions(targetFid)
.factions(targetFid.value)
.factionRelationships
.exists(_.relationshipLevel == ALLY)
then 0
// reject if this is our only neighbor and we have no expansion possibilities
else if LegacyFactionUtils.provinces(targetFid, gs).forall { province =>
province.neighbors.map(n => gs.provinces(n.provinceId)).forall { neighborProvince =>
neighborProvince.rulingFactionId.contains(
originatingFid
) || neighborProvince.rulingFactionId.contains(targetFid)
province.neighbors.map { n => gs.provinces(n.provinceId) }.forall {
neighborProvince =>
neighborProvince.rulingFactionId.contains(
originatingFid
) || neighborProvince.rulingFactionId.contains(targetFid)
}
}
then 0
@@ -251,24 +262,26 @@ object ResolveDiplomacyCommandSelector {
gameState = gs
)
).floor.toInt.max(0)
}
private def resolveRansomOfferSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
selectionForType[ResolveRansomOfferAvailableCommand](availableCommands) { resolveRansomAc =>
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = 0,
available = resolveRansomAc,
selected = selectionForResolveRansomOfferSelectedCommand(
selectionForType[ResolveRansomOfferAvailableCommand](availableCommands) {
resolveRansomAc =>
CommandSelection(
actingFactionId = actingFactionId,
ac = resolveRansomAc,
gs = gameState
),
reason = "resolving ransom offer"
)
actingProvinceId = ProvinceId(0),
available = resolveRansomAc,
selected = selectionForResolveRansomOfferSelectedCommand(
actingFactionId = actingFactionId,
ac = resolveRansomAc,
gs = gameState
),
reason = "resolving ransom offer"
)
}
private def selectionForResolveRansomOfferSelectedCommand(
@@ -283,7 +296,7 @@ object ResolveDiplomacyCommandSelector {
ransomOffer = Some(ac.offers.head),
resolution = RansomOfferHelpers.chosenResolution(rod)
)
case _ => throw new EagleInternalException("Not a ransom offer")
case _ => throw new EagleInternalException("Not a ransom offer")
}
private def resolveAllianceOfferSelectedCommand(
@@ -295,24 +308,23 @@ object ResolveDiplomacyCommandSelector {
randomSelectionForType[ResolveAllianceOfferAvailableCommand](
availableCommands,
functionalRandom
) {
case (resolveAllianceAc, fr) =>
selectionForResolveAllianceOfferSelectedCommand(
actingFactionId = actingFactionId,
ac = resolveAllianceAc,
gs = gameState,
functionalRandom = fr
).map { resolveAllianceOfferSelection =>
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = 0,
available = resolveAllianceAc,
selected = resolveAllianceOfferSelection,
reason = "resolving alliance offer"
)
) { case (resolveAllianceAc, fr) =>
selectionForResolveAllianceOfferSelectedCommand(
actingFactionId = actingFactionId,
ac = resolveAllianceAc,
gs = gameState,
functionalRandom = fr
).map { resolveAllianceOfferSelection =>
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = ProvinceId(0),
available = resolveAllianceAc,
selected = resolveAllianceOfferSelection,
reason = "resolving alliance offer"
)
}
)
}
}
private def resolveBreakAllianceSelectedCommand(
@@ -324,24 +336,23 @@ object ResolveDiplomacyCommandSelector {
randomSelectionForType[ResolveBreakAllianceAvailableCommand](
availableCommands,
functionalRandom
) {
case (resolveBreakAllianceAc, fr) =>
selectionForResolveBreakAllianceSelectedCommand(
actingFactionId = actingFactionId,
ac = resolveBreakAllianceAc,
gs = gameState,
functionalRandom = fr
).map { resolveBreakAllianceSelection =>
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = 0,
available = resolveBreakAllianceAc,
selected = resolveBreakAllianceSelection,
reason = "resolving break alliance"
)
) { case (resolveBreakAllianceAc, fr) =>
selectionForResolveBreakAllianceSelectedCommand(
actingFactionId = actingFactionId,
ac = resolveBreakAllianceAc,
gs = gameState,
functionalRandom = fr
).map { resolveBreakAllianceSelection =>
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = ProvinceId(0),
available = resolveBreakAllianceAc,
selected = resolveBreakAllianceSelection,
reason = "resolving break alliance"
)
}
)
}
}
private def selectionForResolveAllianceOfferSelectedCommand(
@@ -352,7 +363,13 @@ object ResolveDiplomacyCommandSelector {
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
val actingFid = actingFactionId
val bestOffer = ac.offers
.maxBy(inv => allianceOfferAcceptanceChance(inv.originatingFactionId, actingFid, gs))
.maxBy(inv =>
allianceOfferAcceptanceChance(
FactionId(inv.originatingFactionId),
actingFid,
gs
)
)
internalRequire(
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
@@ -369,7 +386,7 @@ object ResolveDiplomacyCommandSelector {
originatingFactionId = bestOriginatingFid,
resolution =
if 100.0 * roll < allianceOfferAcceptanceChance(
originatingFid = bestOriginatingFid,
originatingFid = FactionId(bestOriginatingFid),
targetFid = actingFid,
gs = gs
)
@@ -1,13 +1,15 @@
package net.eagle0.eagle.ai
import net.eagle0.common.MoreOption
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand}
import net.eagle0.eagle.api.available_command.{
AvailableCommand,
MarchAvailableCommand
}
import net.eagle0.eagle.api.selected_command.MarchSelectedCommand
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.settings.{LoyaltyGainFromFeast, MinimumLoyaltyForSwearBrotherhood}
import net.eagle0.eagle.library.util.{CommandSelection, ProvinceDistances}
import net.eagle0.eagle.library.util.command_choice_helpers.{
CommandChoiceHelpers,
@@ -15,9 +17,13 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
SwearBrotherhoodCommandSelector,
SwornBrotherChooser
}
import net.eagle0.eagle.FactionId
import net.eagle0.eagle.library.settings.{
LoyaltyGainFromFeast,
MinimumLoyaltyForSwearBrotherhood
}
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.FactionId
object SeekMoreLeadersCommandChooser {
private def needsMoreLeaders(
@@ -37,22 +43,21 @@ object SeekMoreLeadersCommandChooser {
factionHeadProvince: Province,
gameState: GameState
): Option[CommandSelection] = {
val opmc = command.oneProvinceCommands
val opmc = command.oneProvinceCommands
.find(_.originProvinceId == startingProvince.id)
.get
val bestDestination =
opmc.availableDestinationProvinces
.map(dest => (opmc, gameState.provinces(dest.provinceId)))
.flatMap {
case (opmc, p) =>
ProvinceDistances
.distanceThroughFriendliesOption(
factionHeadProvince.id,
p.id,
factionHeadProvince.rulingFactionId.get,
gameState
)
.map(distance => (opmc, p, distance))
.map(dest => (opmc, gameState.provinces(dest)))
.flatMap { case (opmc, p) =>
ProvinceDistances
.distanceThroughFriendliesOption(
factionHeadProvince.id,
p.id,
factionHeadProvince.rulingFactionId.get,
gameState
)
.map(distance => (opmc, p, distance))
}
.minByOption(_._3)
@@ -83,7 +88,7 @@ object SeekMoreLeadersCommandChooser {
actingFactionId: FactionId,
gameState: GameState,
acs: Vector[AvailableCommand]
): Option[CommandSelection] =
): Option[CommandSelection] = {
LegacyFactionUtils
.provinces(actingFactionId, gameState)
.find(
@@ -101,34 +106,36 @@ object SeekMoreLeadersCommandChooser {
province.rulingFactionHeroIds.map(gameState.heroes),
gameState.factions(actingFactionId).leaders.toVector
)
.map(hero => Candidate(hero, province))
.map { hero => Candidate(hero, province) }
}
val marchACs = acs.collect { case ac: MarchAvailableCommand => ac }
val marchACs = acs
.collect { case ac: MarchAvailableCommand => ac }
val marchableCandidates = candidates.flatMap { c =>
marchACs.find { mac =>
mac.oneProvinceCommands.exists(opmc =>
opmc.originProvinceId == c.province.id && opmc.availableHeroIds.toVector
.contains(c.hero.id)
)
}
marchACs
.find { mac =>
mac.oneProvinceCommands.exists(opmc =>
opmc.originProvinceId == c.province.id && opmc.availableHeroIds.toVector
.contains(c.hero.id)
)
}
.map(mac => (mac, c))
}
marchableCandidates
.maxByOption(x => LegacyHeroUtils.power(x._2.hero))
.flatMap {
case (command, candidate) =>
maybeMoveTowardDestination(
command = command,
hero = candidate.hero,
startingProvince = candidate.province,
factionHeadProvince = provinceWithFactionHead,
gameState = gameState
)
.flatMap { case (command, candidate) =>
maybeMoveTowardDestination(
command = command,
hero = candidate.hero,
startingProvince = candidate.province,
factionHeadProvince = provinceWithFactionHead,
gameState = gameState
)
}
}
}
def maybeSeekMoreLeadersCommand(
actingFactionId: FactionId,
@@ -147,36 +154,37 @@ object SeekMoreLeadersCommandChooser {
faction.leaders.toVector
)
desiredHero.flatMap { hero =>
SwearBrotherhoodCommandSelector
.chosenCommandForSpecificHero(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = acs,
desiredHeroId = hero.id
)
.orElse {
MoreOption.flatWhen(
MinimumLoyaltyForSwearBrotherhood.doubleValue - hero.loyalty < LoyaltyGainFromFeast.doubleValue
) {
CommandChoiceHelpers.chosenFeastCommandIfAvailable(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = acs,
reason = s"want to make hero ${hero.id} a leader"
)
}
}
.orElse {
HeroGiftCommandSelector.chosenCommandForSpecificHero(
desiredHero
.flatMap { hero =>
SwearBrotherhoodCommandSelector
.chosenCommandForSpecificHero(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = acs,
heroId = hero.id,
reason = "want to make this hero a leader"
desiredHeroId = hero.id
)
}
}
.orElse {
MoreOption.flatWhen(
MinimumLoyaltyForSwearBrotherhood.doubleValue - hero.loyalty < LoyaltyGainFromFeast.doubleValue
) {
CommandChoiceHelpers.chosenFeastCommandIfAvailable(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = acs,
reason = s"want to make hero ${hero.id} a leader"
)
}
}
.orElse {
HeroGiftCommandSelector.chosenCommandForSpecificHero(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = acs,
heroId = hero.id,
reason = "want to make this hero a leader"
)
}
}
.orElse(
// Then try moving a hero this way
maybeMoveBestCandidateCommand(
@@ -1,7 +1,7 @@
package net.eagle0.eagle.client_text
import net.eagle0.eagle.internal.generated_text_request.GeneratedTextRequest
import net.eagle0.eagle.ClientTextId
import net.eagle0.eagle.internal.generated_text_request.GeneratedTextRequest
sealed trait ClientText {
def id: ClientTextId
@@ -37,5 +37,5 @@ case class UnrequestedClientText(
llmRequest: GeneratedTextRequest
) extends ClientText {
def complete: Boolean = false
def text: String = ""
def text: String = ""
}
@@ -1,20 +1,23 @@
package net.eagle0.eagle.client_text
import net.eagle0.common.ProtoParser
import net.eagle0.eagle.{ClientTextId, FactionId}
import net.eagle0.eagle.internal.client_text.{
IncompleteText,
IncompleteTextStore,
UnrequestedText
}
import net.eagle0.eagle.internal.generated_text_request.GeneratedTextRequest
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.service.persistence.Persister
import java.io.FileNotFoundException
import java.nio.charset.StandardCharsets
import java.util.regex.Pattern
import scala.io.Source
import scala.util.Try
import net.eagle0.common.ProtoParser
import net.eagle0.eagle.{ClientTextId, FactionId}
import net.eagle0.eagle.internal.client_text.{IncompleteText, IncompleteTextStore, UnrequestedText}
import net.eagle0.eagle.internal.generated_text_request.GeneratedTextRequest
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.service.persistence.Persister
case class ClientTextStoreImpl(
pregenerated: PregeneratedClientTextStore,
persister: Persister,
@@ -35,24 +38,25 @@ case class ClientTextStoreImpl(
requestedAfterHistoryCount: Int
): ClientTextStore = {
internalRequire(
pregenerated.getText(id).isEmpty,
pregenerated.getText(id.value).isEmpty,
s"Text with id $id is pregenerated"
)
if completeTexts.contains(id) then {
if completeTexts.contains(id.value) then {
println(s"Text with id $id already exists")
this
} else if incompleteTexts.contains(id) then {
} else if incompleteTexts.contains(id.value) then {
println(s"Incomplete text with id $id already exists")
this
} else {
copy(
unrequestedTexts = unrequestedTexts + (id -> UnrequestedClientText(
id = id,
requestedAfterHistoryCount = requestedAfterHistoryCount,
llmRequest = llmRequest
)),
accessibleTo = this.accessibleTo + (id -> accessibleTo),
unrequestedTexts =
unrequestedTexts + (id.value -> UnrequestedClientText(
id = id,
requestedAfterHistoryCount = requestedAfterHistoryCount,
llmRequest = llmRequest
)),
accessibleTo = this.accessibleTo + (id.value -> accessibleTo),
accessibleToIsSaved = false,
incompleteTextsAreSaved = false
)
@@ -61,17 +65,18 @@ case class ClientTextStoreImpl(
def withMarkedRequested(id: ClientTextId): ClientTextStore =
unrequestedTexts
.get(id)
.get(id.value)
.map { unrequested =>
copy(
incompleteTexts = incompleteTexts + (id ->
incompleteTexts = incompleteTexts + (id.value ->
IncompleteClientText(
id = id,
partialText = "",
llmRequest = unrequested.llmRequest,
requestedAfterHistoryCount = unrequested.requestedAfterHistoryCount
requestedAfterHistoryCount =
unrequested.requestedAfterHistoryCount
)),
unrequestedTexts = unrequestedTexts - id,
unrequestedTexts = unrequestedTexts - id.value,
incompleteTextsAreSaved = false
)
}
@@ -83,7 +88,7 @@ case class ClientTextStoreImpl(
def withBypassed(id: ClientTextId): ClientTextStore =
copy(
unrequestedTexts = unrequestedTexts - id,
unrequestedTexts = unrequestedTexts - id.value,
incompleteTextsAreSaved = false
)
@@ -91,18 +96,18 @@ case class ClientTextStoreImpl(
pregenerated
.getText(id)
.orElse {
completeTexts.get(id).map(_.text)
completeTexts.get(id.value).map(_.text)
}
.map(text => TextGenerationSuccess(text))
.map { text => TextGenerationSuccess(text) }
.getOrElse {
incompleteTexts
.get(id)
.get(id.value)
.map { ict =>
TextGenerationDependencyInProgress(ict.id, ict.text)
}
.getOrElse {
unrequestedTexts
.get(id)
.get(id.value)
.map { ut =>
TextGenerationDependencyWaiting(ut.id)
}
@@ -118,19 +123,19 @@ case class ClientTextStoreImpl(
complete: Boolean
): ClientTextStoreWithUpdate = {
internalRequire(
!unrequestedTexts.contains(id),
!unrequestedTexts.contains(id.value),
s"Text with id $id is unrequested"
)
internalRequire(
!completeTexts.contains(id),
!completeTexts.contains(id.value),
s"Text with id $id is already complete"
)
internalRequire(
pregenerated.getText(id).isEmpty,
pregenerated.getText(id.value).isEmpty,
s"Text with id $id is pregenerated"
)
val update = incompleteTexts
.get(id)
.get(id.value)
.map(t => t.append(newText))
.getOrElse {
throw new EagleInternalException(s"Text with id $id not found")
@@ -145,8 +150,8 @@ case class ClientTextStoreImpl(
ClientTextStoreWithUpdate(
copy(
completeTexts = completeTexts + (id -> updatedText),
incompleteTexts = incompleteTexts - id,
completeTexts = completeTexts + (id.value -> updatedText),
incompleteTexts = incompleteTexts - id.value,
incompleteTextsAreSaved = false
),
updatedText
@@ -154,7 +159,7 @@ case class ClientTextStoreImpl(
} else {
ClientTextStoreWithUpdate(
copy(
incompleteTexts = incompleteTexts + (id -> update),
incompleteTexts = incompleteTexts + (id.value -> update),
incompleteTextsAreSaved = false
),
update
@@ -167,28 +172,30 @@ case class ClientTextStoreImpl(
addedFactionIds: Vector[FactionId]
): ClientTextStore = {
val originalFactionIds = accessibleTo.getOrElse(id, Vector()).sorted
val updatedFactionIds =
val updatedFactionIds =
(originalFactionIds ++ addedFactionIds).distinct.sorted
if updatedFactionIds == originalFactionIds then this
else
copy(
accessibleTo = accessibleTo + (id -> updatedFactionIds),
accessibleTo = accessibleTo + (id.value -> updatedFactionIds),
accessibleToIsSaved = false
)
}
def withMovedBackToUnrequested(id: ClientTextId): ClientTextStore =
incompleteTexts
.get(id)
.get(id.value)
.map { incomplete =>
copy(
unrequestedTexts = unrequestedTexts + (id -> UnrequestedClientText(
id = id,
requestedAfterHistoryCount = incomplete.requestedAfterHistoryCount,
llmRequest = incomplete.llmRequest
)),
incompleteTexts = incompleteTexts - id,
unrequestedTexts =
unrequestedTexts + (id.value -> UnrequestedClientText(
id = id,
requestedAfterHistoryCount =
incomplete.requestedAfterHistoryCount,
llmRequest = incomplete.llmRequest
)),
incompleteTexts = incompleteTexts - id.value,
incompleteTextsAreSaved = false
)
}
@@ -201,22 +208,22 @@ case class ClientTextStoreImpl(
}
object ClientTextStoreImpl {
private val separator = "\n******\n"
private val separator = "\n******\n"
private val separatorPattern = Pattern.quote(separator)
private def completeTextsKey = "completeText.txt"
private def completeTextsKey = "completeText.txt"
private def incompleteTextsKey = "incompleteText.dat"
private def visibilityKey = "visibility.txt"
private def visibilityKey = "visibility.txt"
def saved(clientTextStore: ClientTextStoreImpl): ClientTextStore = {
val completeSaved =
if clientTextStore.completeTexts.size > clientTextStore.savedCompleteCount
then {
// FIXME: make this append, will require Persister changes
val saveData = clientTextStore.completeTexts.map {
case (_: String, ct: CompleteClientText) =>
val saveData = clientTextStore.completeTexts
.map { case (_: String, ct: CompleteClientText) =>
s"${ct.id}\n${ct.requestedAfterHistoryCount}\n${ct.text}$separator"
}
}
.mkString("")
.getBytes(StandardCharsets.UTF_8)
@@ -226,7 +233,9 @@ object ClientTextStoreImpl {
)
}
clientTextStore.copy(savedCompleteCount = clientTextStore.completeTexts.size)
clientTextStore.copy(savedCompleteCount =
clientTextStore.completeTexts.size
)
} else clientTextStore
val incompleteSaved =
@@ -263,10 +272,10 @@ object ClientTextStoreImpl {
val accessibleToSaved =
if !incompleteSaved.accessibleToIsSaved then {
val accessibleToData = incompleteSaved.accessibleTo.map {
case (id, factions) =>
val accessibleToData = incompleteSaved.accessibleTo
.map { case (id, factions) =>
s"$id\n${factions.mkString(",")}$separator"
}
}
.mkString("")
.getBytes(StandardCharsets.UTF_8)
@@ -309,10 +318,9 @@ object ClientTextStoreImpl {
}
.toVector
}
.recover {
case _: FileNotFoundException =>
println("No complete text store found")
Vector()
.recover { case _: FileNotFoundException =>
println("No complete text store found")
Vector()
}
private def loadedIncompleteTexts(
@@ -344,10 +352,9 @@ object ClientTextStoreImpl {
}
.getOrElse((Vector(), Vector()))
}
.recover {
case _: FileNotFoundException =>
println("No incomplete text store found")
(Vector(), Vector())
.recover { case _: FileNotFoundException =>
println("No incomplete text store found")
(Vector(), Vector())
}
private def loadedAccessibleTo(
@@ -371,10 +378,9 @@ object ClientTextStoreImpl {
}
.toMap
}
.recover {
case _: FileNotFoundException =>
println("No accessibleTo store found")
Map()
.recover { case _: FileNotFoundException =>
println("No accessibleTo store found")
Map()
}
def loaded(
@@ -382,18 +388,22 @@ object ClientTextStoreImpl {
persister: Persister
): Try[ClientTextStore] =
for {
loadedCompletedTexts <- loadedCompleteTexts(persister)
loadedCompletedTexts <- loadedCompleteTexts(persister)
loadedIncompleteTexts <- loadedIncompleteTexts(persister)
loadedAccessibleTo <- loadedAccessibleTo(persister)
} yield ClientTextStoreImpl(
pregenerated = pregenerated,
persister = persister,
completeTexts = loadedCompletedTexts.map(ct => ct.id -> ct).toMap,
incompleteTexts = loadedIncompleteTexts._1.map(ict => ict.id -> ict).toMap,
unrequestedTexts = loadedIncompleteTexts._2.map(ut => ut.id -> ut).toMap,
accessibleTo = loadedAccessibleTo,
savedCompleteCount = loadedCompletedTexts.size,
incompleteTextsAreSaved = true,
accessibleToIsSaved = true
)
loadedAccessibleTo <- loadedAccessibleTo(persister)
} yield {
ClientTextStoreImpl(
pregenerated = pregenerated,
persister = persister,
completeTexts = loadedCompletedTexts.map(ct => ct.id -> ct).toMap,
incompleteTexts =
loadedIncompleteTexts._1.map(ict => ict.id -> ict).toMap,
unrequestedTexts =
loadedIncompleteTexts._2.map(ut => ut.id -> ut).toMap,
accessibleTo = loadedAccessibleTo,
savedCompleteCount = loadedCompletedTexts.size,
incompleteTextsAreSaved = true,
accessibleToIsSaved = true
)
}
}
@@ -1,9 +1,9 @@
package net.eagle0.eagle.client_text
import scala.annotation.tailrec
import net.eagle0.eagle.library.EagleInternalException
import scala.annotation.tailrec
object TextGenerationResult {
def mkString(
results: Seq[TextGenerationResult],
@@ -22,7 +22,7 @@ object TextGenerationResult {
acc :+ result,
tail
)
case x => x
case x => x
}
}
@@ -47,28 +47,30 @@ trait TextGenerationResult {
}
case class TextGenerationSuccess(result: String) extends TextGenerationResult {
override def resolved: Boolean = true
override def get: String = result
override def get: String = result
}
case class TextGenerationDependencyInProgress(
inProgressTextId: String,
partialText: String
) extends TextGenerationResult {
override def resolved: Boolean = false
override def get: String =
override def get: String =
throw new EagleInternalException(
s"Text generation dependency in progress: $inProgressTextId"
)
}
case class TextGenerationDependencyWaiting(notSatisfiedTextId: String) extends TextGenerationResult {
case class TextGenerationDependencyWaiting(notSatisfiedTextId: String)
extends TextGenerationResult {
override def resolved: Boolean = false
override def get: String =
override def get: String =
throw new EagleInternalException(
s"Text generation dependency not satisfied: $notSatisfiedTextId"
)
}
case class TextGenerationDependencyUnknown(unknownTextId: String) extends TextGenerationResult {
case class TextGenerationDependencyUnknown(unknownTextId: String)
extends TextGenerationResult {
override def resolved: Boolean = false
override def get: String =
override def get: String =
throw new EagleInternalException(
s"Text generation dependency unknown: $unknownTextId"
)
@@ -1,17 +1,17 @@
package net.eagle0.eagle.library
import net.eagle0.eagle.FactionId
import net.eagle0.eagle.views.action_result_view.ActionResultView
import net.eagle0.eagle.views.game_state_view.GameStateViewDiff
import net.eagle0.eagle.common.action_result_notification_details.Notification
import net.eagle0.eagle.common.action_result_type.ActionResultType
import net.eagle0.eagle.common.action_result_type.ActionResultType.*
import net.eagle0.eagle.common.round_phase.RoundPhase
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.eagle.library.util.GameStateViewDiffer
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.view_filters.GameStateViewFilter
import net.eagle0.eagle.library.util.GameStateViewDiffer
import net.eagle0.eagle.views.action_result_view.ActionResultView
import net.eagle0.eagle.views.game_state_view.GameStateViewDiff
import net.eagle0.eagle.FactionId
object ActionResultFilter {
private val UNIVERSALLY_VISIBLE_TYPES: Vector[ActionResultType] = Vector(
@@ -84,8 +84,8 @@ object ActionResultFilter {
RoundPhase.HERO_DEPARTURES,
RoundPhase.UNCONTESTED_CONQUEST,
RoundPhase.BATTLE_RESOLUTION,
RoundPhase.BATTLE_AFTERMATH, // MIGHT BE WRONG
RoundPhase.ATTACK_DECISION, // MIGHT BE WRONG
RoundPhase.BATTLE_AFTERMATH, // MIGHT BE WRONG
RoundPhase.ATTACK_DECISION, // MIGHT BE WRONG
RoundPhase.DIPLOMACY_RESOLUTION // IS WRONG
)
@@ -97,7 +97,8 @@ object ActionResultFilter {
GameStateViewDiffer.diff(
before = GameStateViewFilter
.filteredGameState(gs = before, factionId = factionId),
after = GameStateViewFilter.filteredGameState(gs = after, factionId = factionId)
after =
GameStateViewFilter.filteredGameState(gs = after, factionId = factionId)
)
private def transformOne(
@@ -114,7 +115,9 @@ object ActionResultFilter {
)
def shouldInclude(note: Notification): Boolean =
note.targetFactions.isEmpty || factionId.exists(fid => note.targetFactions.exists(_.factionId == fid))
note.targetFactions.isEmpty || factionId.exists(fid =>
note.targetFactions.exists(_.factionId == fid)
)
if gsDiff.isDefined &&
result.actionResult.player.isDefined &&
@@ -140,7 +143,8 @@ object ActionResultFilter {
province = actionResult.province,
leader = actionResult.leader,
gameStateDiff = gsDiff,
notifications = actionResult.notificationsToDeliver.filter(shouldInclude)
notifications =
actionResult.notificationsToDeliver.filter(shouldInclude)
)
}
}
@@ -150,9 +154,10 @@ object ActionResultFilter {
results: Vector[ActionWithResultingState],
factionId: Option[FactionId]
): Vector[ActionResultView] =
factionId.map { pid =>
filterForPlayer(startingState, results, pid)
}
factionId
.map { pid =>
filterForPlayer(startingState, results, pid)
}
.getOrElse(filterForObserver(startingState, results))
def filterForPlayer(
@@ -177,5 +182,7 @@ object ActionResultFilter {
startingState: GameState,
results: Vector[ActionWithResultingState]
): Vector[ActionResultView] =
results.flatMap(res => observeOne(result = res, startingState = startingState))
results.flatMap(res =>
observeOne(result = res, startingState = startingState)
)
}
@@ -71,7 +71,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/faction",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
@@ -215,18 +214,8 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types/base:action_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:action_result_proto_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/faction",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -1,3 +1,4 @@
package net.eagle0.eagle.library
class EagleValidationException(message: String) extends EagleInternalException(message) {}
class EagleValidationException(message: String)
extends EagleInternalException(message) {}
@@ -1,12 +1,12 @@
package net.eagle0.eagle.library
import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
import net.eagle0.eagle.views.action_result_view.ActionResultView
import net.eagle0.eagle.api.command.AvailableCommands
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.shardok_interface.{BattleResolution, BattleUpdate}
import net.eagle0.eagle.views.action_result_view.ActionResultView
import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
trait EngineAndResults {
def engine: Engine
@@ -1,35 +1,43 @@
package net.eagle0.eagle.library
import scala.annotation.tailrec
import net.eagle0.common.SeededRandom
import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
import net.eagle0.eagle.api.command.AvailableCommands
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.actions.applier.{ActionResultProtoApplier, ActionResultProtoApplierImpl}
import net.eagle0.eagle.library.EngineImpl.{appliedResults, withUpdateChecks}
import net.eagle0.eagle.library.actions.applier.{
ActionResultProtoApplier,
ActionResultProtoApplierImpl
}
import net.eagle0.eagle.library.actions.availability.AvailableCommandsFactory
import net.eagle0.eagle.library.actions.impl.action.{
CheckForFulfilledQuestsAction,
HeroBackstoryUpdateActionGenerator,
ResolveBattleAction
}
import net.eagle0.eagle.library.actions.impl.command.{AvailableCommandTypeMap, CommandFactory}
import net.eagle0.eagle.library.actions.impl.common.{ActionWithResultingState, RandomStateProtoSequencer}
import net.eagle0.eagle.library.actions.impl.command.{
AvailableCommandTypeMap,
CommandFactory
}
import net.eagle0.eagle.library.actions.impl.common.{
ActionWithResultingState,
RandomStateProtoSequencer
}
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.util.hero_generator.HeroGenerator
import net.eagle0.eagle.library.util.validations.RuntimeValidator
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.EngineImpl.{appliedResults, withUpdateChecks}
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.BattalionConverter
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.proto_converters.faction.FactionConverter
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
import net.eagle0.eagle.model.proto_converters.BattalionConverter
import net.eagle0.eagle.shardok_interface.{BattleResolution, BattleUpdate}
import net.eagle0.eagle.views.action_result_view.ActionResultView
import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
import scala.annotation.tailrec
object EngineImpl {
def apply(
@@ -74,9 +82,11 @@ object EngineImpl {
.toVector,
battalions = eng.currentState.battalions.values.toVector
.map(BattalionConverter.fromProto),
getHero = hid => eng.currentState.heroes.get(hid).map(HeroConverter.fromProto),
getHero =
hid => eng.currentState.heroes.get(hid).map(HeroConverter.fromProto),
battalionTypes = eng.currentState.battalionTypes.toVector,
heroBackstoryTextIdLookup = hid => eng.currentState.heroes(hid).backstoryVersions.last.textId
heroBackstoryTextIdLookup =
hid => eng.currentState.heroes(hid).backstoryVersions.last.textId
).results
)
@@ -88,7 +98,8 @@ object EngineImpl {
withPhaseAdvancement(engineAndResults)
)
if newEar.results.length > engineAndResults.results.length then withUpdateChecks(newEar)
if newEar.results.length > engineAndResults.results.length then
withUpdateChecks(newEar)
else newEar
}
@@ -98,7 +109,8 @@ object EngineImpl {
): EngineAndResults = withUpdateChecks(
EngineAndResultsImpl(
engine = engine.copy(
currentState = results.lastOption.map(_.gameState).getOrElse(engine.currentState),
currentState =
results.lastOption.map(_.gameState).getOrElse(engine.currentState),
history = engine.history.withNewResults(results)
),
results = results.map(_.actionResult)
@@ -133,14 +145,14 @@ final case class EngineAndResultsImpl(
def recursiveTransformT(
f: EngineImpl => Vector[ActionResultT]
): EngineAndResultsImpl = recursiveTransform { eng =>
): EngineAndResultsImpl = recursiveTransform(eng => {
val results = f(eng)
RandomStateProtoSequencer(
initialStateProto = eng.currentState,
initialState = eng.currentState,
actionResultProtoApplier = eng.actionResultProtoApplier,
functionalRandom = SeededRandom(eng.currentState.randomSeed)
).withActionResultTs(_ => results).results.newValue
}
})
def saveNow: EngineAndResultsImpl =
this.copy(engine = this.engine.saveNow)
@@ -156,7 +168,8 @@ case class EngineImpl(
override val currentState: GameState,
heroGenerator: HeroGenerator,
override val history: GameHistory,
actionResultProtoApplier: ActionResultProtoApplier = new ActionResultProtoApplierImpl(validator = RuntimeValidator)
actionResultProtoApplier: ActionResultProtoApplier =
new ActionResultProtoApplierImpl(validator = RuntimeValidator)
) extends Engine {
import net.eagle0.eagle.library.util.EagleRequire.commandRequire
@@ -284,17 +297,17 @@ case class EngineImpl(
availableCommandOpt.isDefined,
s"No matching available command for selected command $selectedCommand"
)
val availableCommand = availableCommandOpt.get
val availableCommand = availableCommandOpt.get
val sequencer = RandomStateProtoSequencer(
initialStateProto = this.currentState,
initialState = this.currentState,
actionResultProtoApplier = actionResultProtoApplier,
functionalRandom = SeededRandom(this.currentState.randomSeed)
).withActionResults { gs =>
val results = commandFactory
.makeCommand(
actingFactionId = factionId,
gameState = GameStateConverter.fromProto(gs),
gameState = gs,
availableCommand = availableCommand,
selectedCommand = selectedCommand
)
@@ -312,7 +325,9 @@ case class EngineImpl(
)
results
}.withProtolessSequentialResultsAction(gs => HeroBackstoryUpdateActionGenerator.fromGameState(gs))
}.withProtolessSequentialResultsAction(gs =>
HeroBackstoryUpdateActionGenerator.fromGameState(gs)
)
appliedResults(
engine = this,
@@ -1,12 +1,12 @@
package net.eagle0.eagle.library
import net.eagle0.eagle.{FactionId, RoundId, ShardokGameId}
import net.eagle0.eagle.common.date.Date
import net.eagle0.eagle.{FactionId, RoundId, ShardokGameId}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.shardok.storage.action_result.ActionResult as ShardokActionResult
import net.eagle0.shardok.api.action_result_view.ActionResultView as ShardokActionResultView
import net.eagle0.shardok.api.command_descriptor.AvailableCommands as ShardokAvailableCommands
import net.eagle0.shardok.storage.action_result.ActionResult as ShardokActionResult
case class CappedResults(
results: Vector[ActionWithResultingState],
@@ -17,7 +17,8 @@ trait GameHistory {
def all: Vector[ActionWithResultingState]
def since(count: Int): Vector[ActionWithResultingState]
def sinceCapped(count: Int, resultSizeCap: Int): CappedResults =
if this.count - count <= resultSizeCap then CappedResults(since(count), None)
if this.count - count <= resultSizeCap then
CappedResults(since(count), None)
else
CappedResults(
since(this.count - resultSizeCap),
@@ -1,33 +1,31 @@
package net.eagle0.eagle.library
import scala.collection.mutable
import net.eagle0.common.SeededRandom
import net.eagle0.eagle.RoundId
import net.eagle0.eagle.common.round_phase.RoundPhase
import net.eagle0.eagle.common.round_phase.RoundPhase.*
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.actions.applier.{ActionResultProtoApplier, ActionResultTApplierImpl}
import net.eagle0.eagle.library.actions.applier.{
ActionResultProtoApplier,
ActionResultTApplierImpl
}
import net.eagle0.eagle.library.actions.availability.AvailableCommandsFactory
import net.eagle0.eagle.library.actions.impl.action.*
import net.eagle0.eagle.library.actions.impl.command.CommandFactory
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.eagle.library.util.hero_generator.HeroGenerator
import net.eagle0.eagle.library.util.EagleRequire.internalValidated
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.proto_converters.faction.FactionConverter
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
import net.eagle0.eagle.library.util.hero_generator.HeroGenerator
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
import net.eagle0.eagle.model.proto_converters.ActionResultProtoConverter
import net.eagle0.eagle.model.proto_converters.BattalionConverter
import net.eagle0.eagle.model.proto_converters.BattalionTypeConverter
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.RoundId
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import scala.collection.mutable
object RoundPhaseAdvancer {
private val times: mutable.Map[RoundPhase, Long] =
mutable.Map(RoundPhase.values.map(_ -> 0L)*)
private val print = false
private val roundsBetweenPrint = 100
private val print = false
private val roundsBetweenPrint = 100
private def printTimings(roundId: RoundId): Unit = {
val totalTime = times.values.sum.toDouble / 1000.0
@@ -36,7 +34,7 @@ object RoundPhaseAdvancer {
case (phase, time) =>
val timeInSecs = time.toDouble / 1000.0
val msPerRound = time.toDouble / roundId.toDouble
val timePct = timeInSecs / totalTime * 100.0
val timePct = timeInSecs / totalTime * 100.0
println(
f"$phase%-25s: $timeInSecs%6.2fs ($msPerRound%4.2fms per round), $timePct%4.1f%%"
)
@@ -53,7 +51,7 @@ object RoundPhaseAdvancer {
commandFactory: CommandFactory
): Vector[ActionWithResultingState] = {
val currentPhase = currentState.currentPhase
val startTime = System.currentTimeMillis
val startTime = System.currentTimeMillis
if print && currentPhase == NEW_ROUND && currentState.currentRoundId % roundsBetweenPrint == 0
then {
@@ -194,27 +192,7 @@ object RoundPhaseAdvancer {
case UNCONTESTED_CONQUEST =>
actionResultProtoApplier.applyActionResults(
currentState,
PerformUncontestedConquestAction(
gameId = currentState.gameId,
currentRoundId = currentState.currentRoundId,
currentDate = DateConverter.fromProto(currentState.currentDate),
provinces = currentState.provinces.values
.map(ProvinceConverter.fromProto)
.map(p => p.id -> p)
.toMap,
factions = currentState.factions.values
.map(FactionConverter.fromProto)
.map(f => f.id -> f)
.toMap,
heroes = currentState.heroes.values
.map(HeroConverter.fromProto)
.map(h => h.id -> h)
.toMap,
battalions = currentState.battalions.values
.map(BattalionConverter.fromProto)
.map(b => (b.id, b))
.toMap
).results
PerformUncontestedConquestAction(currentState).results
.map(ActionResultProtoConverter.toProto)
)
@@ -262,32 +240,8 @@ object RoundPhaseAdvancer {
TruceTurnBackPhaseAction(currentState).execute(actionResultProtoApplier)
case BATTLE_REQUEST =>
val requestBattlesAction = RequestBattlesAction(
gameId = currentState.gameId,
currentRoundId = currentState.currentRoundId,
currentDate = DateConverter.fromProto(currentState.currentDate),
battleCounter = currentState.battleCounter,
heroes = currentState.heroes.view.mapValues(HeroConverter.fromProto).toMap,
battalions = currentState.battalions.view
.mapValues(BattalionConverter.fromProto)
.toMap,
provinces = currentState.provinces.view
.mapValues(ProvinceConverter.fromProto)
.toMap,
factions = currentState.factions.view
.mapValues(FactionConverter.fromProto)
.toMap,
battalionTypes = currentState.battalionTypes.map { bt =>
val converted = BattalionTypeConverter.fromProto(bt)
converted.typeId -> converted
}.toMap
)
val requestResults = actionResultProtoApplier.applyActionResults(
currentState,
requestBattlesAction.results.map(
ActionResultProtoConverter.toProto(_)
)
)
val requestResults =
RequestBattlesAction(currentState).execute(actionResultProtoApplier)
requestResults ++ EndBattleRequestPhaseAction(
requestResults.lastOption.map(_.gameState).getOrElse(currentState)
).execute(actionResultProtoApplier)
@@ -333,7 +287,8 @@ object RoundPhaseAdvancer {
currentState,
EndDiplomacyResolutionPhaseAction(
currentState,
actionResultTApplier = ActionResultTApplierImpl(actionResultProtoApplier)
actionResultTApplier =
ActionResultTApplierImpl(actionResultProtoApplier)
).randomResults(SeededRandom(currentState.randomSeed))
.newValue
.map(ActionResultProtoConverter.toProto)
@@ -347,7 +302,7 @@ object RoundPhaseAdvancer {
case Unrecognized(x) =>
throw new IllegalStateException(s"Unknown round phase $x")
}
val timeSpent = System.currentTimeMillis - startTime
val timeSpent = System.currentTimeMillis - startTime
times(currentPhase) = times(currentPhase) + timeSpent
validateResults(results, currentState, availableCommandsFactory)
@@ -7,7 +7,6 @@ import net.eagle0.eagle.common.action_result_type.ActionResultType
import net.eagle0.eagle.common.battalion_type.BattalionType
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.common.date.Date
import net.eagle0.eagle.common.hero_backstory_version.BackstoryVersion
import net.eagle0.eagle.common.province_order_type.ProvinceOrderType
import net.eagle0.eagle.common.recruitment_info.RecruitmentInfo
import net.eagle0.eagle.common.recruitment_info.RecruitmentStatus.{
@@ -31,7 +30,10 @@ import net.eagle0.eagle.common.unaffiliated_hero_type.UnaffiliatedHeroType.{
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.internal.army.{HostileArmyGroup, MovingArmy}
import net.eagle0.eagle.internal.battalion.Battalion
import net.eagle0.eagle.internal.changed_faction.{ChangedFaction, TrustLevelUpdate}
import net.eagle0.eagle.internal.changed_faction.{
ChangedFaction,
TrustLevelUpdate
}
import net.eagle0.eagle.internal.changed_hero.ChangedHero
import net.eagle0.eagle.internal.changed_hero.ChangedHero.{Loyalty, Vigor}
import net.eagle0.eagle.internal.changed_province.ChangedProvince
@@ -42,23 +44,29 @@ import net.eagle0.eagle.internal.faction.Faction.OutgoingOfferRound
import net.eagle0.eagle.internal.faction_relationship.FactionRelationship
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.common.hero_backstory_version.BackstoryVersion
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.internal.run_status.RunStatus
import net.eagle0.eagle.internal.shardok_battle.ShardokBattle
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.eagle.library.settings.{ExtraXpForStatBumpOver100, XpForStatBump}
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.validations.Validator
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.util.ProvinceEventUtils
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.eagle.library.settings.{
ExtraXpForStatBumpOver100,
XpForStatBump
}
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.util.validations.Validator
import net.eagle0.eagle.library.util.ProvinceEventUtils
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultProtoApplier {
class ActionResultProtoApplierImpl(validator: Validator)
extends ActionResultProtoApplier {
import validator.validate
override def xpForStatBump(stat: Int): Int =
if stat <= 99 then XpForStatBump.intValue
else XpForStatBump.intValue + ExtraXpForStatBumpOver100.intValue * (stat - 99)
else
XpForStatBump.intValue + ExtraXpForStatBumpOver100.intValue * (stat - 99)
private val trustMax: Int = 100
@@ -85,11 +93,10 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
if changedProvinces.isEmpty then gameState
else
changedProvinces
.foldLeft(gameState) {
case (gs, cp) =>
val after = gs.applyChangedProvince(cp)
validate(after.provinces(cp.id), gs.currentPhase)
after
.foldLeft(gameState) { case (gs, cp) =>
val after = gs.applyChangedProvince(cp)
validate(after.provinces(cp.id), gs.currentPhase)
after
}
def applyChangedProvince(cp: ChangedProvince): GameState = {
@@ -127,7 +134,7 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
_.incomingShipments.modify { is =>
val holdovers =
is.filterNot(sh => cp.removedIncomingShipmentIds.contains(sh.id))
val nextId = holdovers
val nextId = holdovers
.map(_.id)
.reduceOption(_ max _)
.getOrElse(0) + 1
@@ -142,7 +149,9 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
da.map { nda =>
nda.withUnits(
nda.units
.filterNot(u => cp.removedRulingPlayerHeroIds.contains(u.heroId))
.filterNot(u =>
cp.removedRulingPlayerHeroIds.contains(u.heroId)
)
.map {
case CombatUnit(
pid,
@@ -176,11 +185,17 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
newF
},
_.priceIndex.setIfDefined(cp.newPriceIndex),
_.economy.modify(e => (e + cp.economyDelta.getOrElse(0.0)).max(0.0).min(100.0)),
_.economy.modify(e =>
(e + cp.economyDelta.getOrElse(0.0)).max(0.0).min(100.0)
),
_.agriculture
.modify(a => (a + cp.agricultureDelta.getOrElse(0.0)).max(0.0).min(100.0)),
.modify(a =>
(a + cp.agricultureDelta.getOrElse(0.0)).max(0.0).min(100.0)
),
_.infrastructure
.modify(i => (i + cp.infrastructureDelta.getOrElse(0.0)).max(0.0).min(100.0)),
.modify(i =>
(i + cp.infrastructureDelta.getOrElse(0.0)).max(0.0).min(100.0)
),
_.economyDevastation.modify(d =>
(d + cp.economyDevastationDelta.getOrElse(0.0))
.max(0.0)
@@ -196,12 +211,16 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
.max(0.0)
.min(provinceBefore.infrastructure)
),
_.support.modify(s => (s + cp.supportDelta.getOrElse(0.0)).max(0.0).min(100.0)),
_.support.modify(s =>
(s + cp.supportDelta.getOrElse(0.0)).max(0.0).min(100.0)
),
_.hasActed.setIfDefined(cp.setHasActed),
_.rulerIsTraveling.setIfDefined(cp.setRulerIsTraveling),
_.unaffiliatedHeroes.modify(uhs =>
uhs
.filterNot(uh => cp.removedUnaffiliatedHeroIds.contains(uh.heroId))
.filterNot(uh =>
cp.removedUnaffiliatedHeroIds.contains(uh.heroId)
)
.filterNot(uh =>
cp.changedUnaffiliatedHeroes
.map(_.heroId)
@@ -228,14 +247,14 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
cp.newProvinceEvents.exists(
_.newEvents.exists(ProvinceEventUtils.isBeastsEvent)
)
)(gameState.currentDate.get)
) { gameState.currentDate.get }
),
_.lastRiotDate.setIfDefined(
Option.when(
cp.newProvinceEvents.exists(
_.newEvents.exists(ProvinceEventUtils.isImminentRiotEvent)
)
)(gameState.currentDate.get)
) { gameState.currentDate.get }
),
_.incomingEndTurnActions :++= cp.addedIncomingEndTurnActions,
_.incomingEndTurnActions.modify(
@@ -246,7 +265,9 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
.map(idx => dcs.patch(idx, Nil, 1))
.getOrElse(dcs) ++ cp.addedDeferredChange.asNonEmpty
),
_.battleRevelations.modify(brs => brs.diff(cp.removedBattleRevelations) ++ cp.addedBattleRevelations)
_.battleRevelations.modify(brs =>
brs.diff(cp.removedBattleRevelations) ++ cp.addedBattleRevelations
)
)
val provinceFixedForRuler =
@@ -254,27 +275,27 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
then
provinceApplied.update(
_.optionalRulingFactionId := None,
_.optionalRulingHeroId := None,
_.rulingFactionHeroIds := Vector.empty,
_.support := 0,
_.provinceOrders := ProvinceOrderType.UNKNOWN_ORDERS,
_.optionalRulingHeroId := None,
_.rulingFactionHeroIds := Vector.empty,
_.support := 0,
_.provinceOrders := ProvinceOrderType.UNKNOWN_ORDERS,
_.unaffiliatedHeroes.modify(uhs =>
uhs.map(uh =>
uh.update(
_.recruitmentInfo := RecruitmentInfo(
status = uh.`type` match {
case UNAFFILIATED_HERO_PRISONER =>
case UNAFFILIATED_HERO_PRISONER =>
RECRUITMENT_STATUS_PRISONER
case UNAFFILIATED_HERO_MOVING_PRISONER =>
case UNAFFILIATED_HERO_MOVING_PRISONER =>
RECRUITMENT_STATUS_MOVING_PRISONER
case UNAFFILIATED_HERO_RETURNING_PRISONER =>
case UNAFFILIATED_HERO_RETURNING_PRISONER =>
RECRUITMENT_STATUS_NO_RULER_IN_PROVINCE
case UNAFFILIATED_HERO_OUTLAW => RECRUITMENT_STATUS_OUTLAW
case UNAFFILIATED_HERO_TRAVELER =>
case UNAFFILIATED_HERO_OUTLAW => RECRUITMENT_STATUS_OUTLAW
case UNAFFILIATED_HERO_TRAVELER =>
RECRUITMENT_STATUS_TRAVELER
case UNAFFILIATED_HERO_RESIDENT =>
case UNAFFILIATED_HERO_RESIDENT =>
RECRUITMENT_STATUS_NO_RULER_IN_PROVINCE
case UNAFFILIATED_HERO_UNKNOWN =>
case UNAFFILIATED_HERO_UNKNOWN =>
throw new EagleInternalException(
"Unknown unaffiliated hero type"
)
@@ -304,13 +325,12 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
): Vector[MovingArmy] = {
val holdovers =
existingArmies.filterNot(ma => removedIds.contains(ma.id))
val nextId = holdovers
val nextId = holdovers
.map(_.id)
.reduceOption(_ max _)
.getOrElse(0) + 1
holdovers.toVector ++ addedArmies.zipWithIndex.map {
case (army, index) =>
army.withId(index + nextId)
holdovers.toVector ++ addedArmies.zipWithIndex.map { case (army, index) =>
army.withId(index + nextId)
}
}
@@ -319,7 +339,7 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
removedFactionIds: Seq[FactionId],
addedArmies: Seq[HostileArmyGroup],
statusChanges: Seq[HostileArmyGroupStatusChange]
): Vector[HostileArmyGroup] =
): Vector[HostileArmyGroup] = {
statusChanges
.foldLeft(
existingGroups
@@ -340,8 +360,9 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
}
}
.toVector ++ addedArmies
}
def applyProvinceActed(actedProvince: Option[Int]): GameState = {
def applyProvinceActed(actedProvince: Option[Int]): GameState = {
internalRequire(
!actedProvince.contains(0),
"Province acted has id 0"
@@ -375,19 +396,20 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
): GameState =
if newBattalions.isEmpty then gameState
else
provinceId.map { pid =>
val maxCurrentId =
if gameState.battalions.isEmpty then 0
else gameState.battalions.keys.max
val newIds =
(maxCurrentId + 1) to (maxCurrentId + newBattalions.size)
gameState.update(
_.battalions :++= newIds.zip(newBattalions).map {
case (id, batt) => id -> batt.withId(id)
},
_.provinces(pid).battalionIds :++= newIds
)
}
provinceId
.map { pid =>
val maxCurrentId =
if gameState.battalions.isEmpty then 0
else gameState.battalions.keys.max
val newIds =
(maxCurrentId + 1) to (maxCurrentId + newBattalions.size)
gameState.update(
_.battalions :++= newIds.zip(newBattalions).map {
case (id, batt) => id -> batt.withId(id)
},
_.provinces(pid).battalionIds :++= newIds
)
}
.getOrElse(
gameState
.update(_.battalions :++= newBattalions.map(b => b.id -> b))
@@ -422,15 +444,14 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
_.battalions := gameState.battalions -- destroyedBattalionIds,
_.destroyedBattalions :++= destroyedBattalionIds
.filterNot(_ == -1)
.map(bid => bid -> gameState.battalions(bid)),
_.provinces := gameState.provinces.map {
case (pid, p) =>
pid -> p.update(
_.battalionIds := p.battalionIds
.filterNot(destroyedBattalionIds.contains),
_.incomingArmies := p.incomingArmies
.map(a => movingArmyWithoutBattalions(a, destroyedBattalionIds))
)
.map { bid => bid -> gameState.battalions(bid) },
_.provinces := gameState.provinces.map { case (pid, p) =>
pid -> p.update(
_.battalionIds := p.battalionIds
.filterNot(destroyedBattalionIds.contains),
_.incomingArmies := p.incomingArmies
.map(a => movingArmyWithoutBattalions(a, destroyedBattalionIds))
)
}
)
@@ -463,14 +484,14 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
def applyChangedHeroes(
changedHeroes: Seq[ChangedHero],
date: Date
): GameState =
): GameState = {
if changedHeroes.isEmpty then gameState
else
changedHeroes
.foldLeft(gameState) {
case (gs, ch) =>
gs.applyChangedHero(ch, date)
.foldLeft(gameState) { case (gs, ch) =>
gs.applyChangedHero(ch, date)
}
}
private def statBump(
stat: Int,
@@ -612,10 +633,9 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
)
}
def applyRemovedFactions(removedFactionIds: Set[FactionId]): GameState =
gameState.factions.partition {
case (fid, _) =>
removedFactionIds(fid)
def applyRemovedFactions(removedFactionIds: Set[FactionId]): GameState = {
gameState.factions.partition { case (fid, _) =>
removedFactionIds(fid)
} match {
case (removed, remaining) =>
gameState.update(
@@ -623,17 +643,16 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
_.factions := remaining
)
}
}
def applyRemovedHeroes(removedHeroIds: Set[HeroId]): GameState =
gameState.heroes.partition {
case (k, _) =>
removedHeroIds(k)
gameState.heroes.partition { case (k, _) =>
removedHeroIds(k)
} match {
case (removed, remaining) =>
gameState.update(
_.killedHeroes :++= removed.map {
case (hid, hero) =>
(hid, hero.clearFactionId)
_.killedHeroes :++= removed.map { case (hid, hero) =>
(hid, hero.clearFactionId)
},
_.heroes := remaining
)
@@ -651,11 +670,12 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
actionResultType: ActionResultType,
notification: Option[Notification]
): GameState =
notification.map { note =>
gameState.update(
_.deferredNotifications :+= note
)
}
notification
.map { note =>
gameState.update(
_.deferredNotifications :+= note
)
}
.getOrElse(gameState)
def applyRemovedNotifications(
@@ -707,34 +727,36 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
)
) ++ cf.updatedReconnedProvinces
},
_.factionRelationships.modify { (relationships: Seq[FactionRelationship]) =>
cf.trustLevelUpdates
.foldLeft(relationships) {
(
relationships: Seq[FactionRelationship],
update: TrustLevelUpdate
) =>
val existingRelationship =
relationships
.find(
_.targetFactionId == update.targetFactionId
)
.getOrElse(
FactionRelationship(
targetFactionId = update.targetFactionId,
relationshipLevel = FactionRelationship.RelationshipLevel.HOSTILE,
trustValue = 0
_.factionRelationships
.modify { (relationships: Seq[FactionRelationship]) =>
cf.trustLevelUpdates
.foldLeft(relationships) {
(
relationships: Seq[FactionRelationship],
update: TrustLevelUpdate
) =>
val existingRelationship =
relationships
.find(
_.targetFactionId == update.targetFactionId
)
)
val newValue = Math.min(
trustMax,
update.delta + existingRelationship.trustValue
)
relationships.filterNot(
_.targetFactionId == update.targetFactionId
) :+ existingRelationship.withTrustValue(newValue)
}
},
.getOrElse(
FactionRelationship(
targetFactionId = update.targetFactionId,
relationshipLevel =
FactionRelationship.RelationshipLevel.HOSTILE,
trustValue = 0
)
)
val newValue = Math.min(
trustMax,
update.delta + existingRelationship.trustValue
)
relationships.filterNot(
_.targetFactionId == update.targetFactionId
) :+ existingRelationship.withTrustValue(newValue)
}
},
_.lastActedProvinceIdThisRound.modify { lpid =>
if cf.clearLastActedProvinceId then 0
else cf.newLastActedProvinceId.getOrElse(lpid)
@@ -813,53 +835,58 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
)
def applyNewBattle(battle: Option[ShardokBattle]): GameState =
battle.map { b =>
gameState.update(
_.outstandingBattles.modify(_ :+ b),
_.battleCounter.modify(_.max(b.battleIndex))
)
}
battle
.map { b =>
gameState.update(
_.outstandingBattles.modify(_ :+ b),
_.battleCounter.modify(_.max(b.battleIndex))
)
}
.getOrElse(gameState)
def applyResolvedBattle(shardokGameId: Option[ShardokGameId]): GameState =
gameState
.withOutstandingBattles(
gameState.outstandingBattles.filterNot(batt => shardokGameId.contains(batt.shardokGameId))
gameState.outstandingBattles.filterNot(batt =>
shardokGameId.contains(batt.shardokGameId)
)
)
def applyNewSeed(newSeed: Option[Long]): GameState =
newSeed.map(s => gameState.withRandomSeed(s)).getOrElse(gameState)
newSeed.map { s => gameState.withRandomSeed(s) }.getOrElse(gameState)
def applyChronicleEntry(
chronicleEntry: Option[ChronicleEntry]
): GameState =
chronicleEntry.map { ce =>
gameState.update(
_.chronicleEntries.modify { ces =>
ces.indexWhere(_.date == ce.date) match {
case -1 => ces :+ ce
case idx => ces.updated(idx, ce)
chronicleEntry
.map { ce =>
gameState.update(
_.chronicleEntries.modify { ces =>
ces.indexWhere(_.date == ce.date) match {
case -1 => ces :+ ce
case idx => ces.updated(idx, ce)
}
}
}
)
}
)
}
.getOrElse(gameState)
def applyCommandCountUpdate(actingFactionId: Option[FactionId]): GameState =
actingFactionId.map { factionId =>
val existingCount =
gameState.factionCommandCounts.getOrElse(factionId, 0)
gameState.update(
_.factionCommandCounts(factionId) := existingCount + 1
)
}
actingFactionId
.map { factionId =>
val existingCount =
gameState.factionCommandCounts.getOrElse(factionId, 0)
gameState.update(
_.factionCommandCounts(factionId) := existingCount + 1
)
}
.getOrElse(gameState)
}
override def applyActionResults(
startingState: GameState,
results: Iterable[ActionResult]
): Vector[ActionWithResultingState] =
): Vector[ActionWithResultingState] = {
results
.foldLeft((startingState, Vector.empty[ActionWithResultingState])) {
case ((gameState, acc), result) =>
@@ -869,7 +896,7 @@ class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultPro
}
}
._2
.toVector
}.toVector
override def applyActionResult(
startingState: GameState,
@@ -13,7 +13,8 @@ object ActionResultTApplierImpl {
new ActionResultTApplierImpl(protoApplier)
}
class ActionResultTApplierImpl(protoApplier: ActionResultProtoApplier) extends ActionResultTApplier {
class ActionResultTApplierImpl(protoApplier: ActionResultProtoApplier)
extends ActionResultTApplier {
override def xpForStatBump(stat: Int): Int = protoApplier.xpForStatBump(stat)
override def applyActionResults(
@@ -25,12 +26,11 @@ class ActionResultTApplierImpl(protoApplier: ActionResultProtoApplier) extends A
results.map(ActionResultProtoConverter.toProto)
)
.zip(results)
.map {
case (actionWithResultingState, actionResult) =>
ActionResultTWithResultingState(
actionResult = actionResult,
resultingState = actionWithResultingState.gameState
)
.map { case (actionWithResultingState, actionResult) =>
ActionResultTWithResultingState(
actionResult = actionResult,
resultingState = actionWithResultingState.gameState
)
}
override def applyActionResult(
@@ -1,32 +1,41 @@
package net.eagle0.eagle.library.actions.availability
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.AlmsAvailableCommand
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.settings.{MaxAlmsFood, MinVigorForAlms}
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
object AvailableAlmsCommandFactory extends AvailableCommandsFactoryForType {
def availableHeroIds(gs: GameState, pid: ProvinceId): Vector[HeroId] =
gs.provinces(pid)
gs.provinces(pid.value)
.rulingFactionHeroIds
.toVector
.filter(hid => gs.heroes(hid).vigor >= MinVigorForAlms.doubleValue)
.sortBy(hid => (!gs.heroes(hid).profession.isPaladin, -gs.heroes(hid).vigor))
.map(HeroId(_))
.filter(hid => gs.heroes(hid.value).vigor >= MinVigorForAlms.doubleValue)
.sortBy(hid =>
(
!gs.heroes(hid.value).profession.isPaladin,
-gs.heroes(hid.value).vigor
)
)
override def availableCommand(
gameState: GameState,
factionId: FactionId,
provinceId: ProvinceId
): Option[AlmsAvailableCommand] = {
val hids = availableHeroIds(gameState, provinceId)
val province = gameState.provinces(provinceId)
val hids = availableHeroIds(gameState, provinceId)
val province = gameState.provinces(provinceId.value)
Option.when(hids.nonEmpty && province.food > 0) {
AlmsAvailableCommand(
availableHeroIds = hids,
actingProvinceId = provinceId,
foodAvailable = Math.min(MaxAlmsFood.intValue, gameState.provinces(provinceId).food)
availableHeroIds = hids.map(_.value),
actingProvinceId = provinceId.value,
foodAvailable = Math.min(
MaxAlmsFood.intValue,
gameState.provinces(provinceId.value).food
)
)
}
}
@@ -1,16 +1,20 @@
package net.eagle0.eagle.library.actions.availability
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.{ApprehendOutlawAvailableCommand, ResidentOutlaw}
import net.eagle0.eagle.api.available_command.{
ApprehendOutlawAvailableCommand,
ResidentOutlaw
}
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero
import net.eagle0.eagle.common.unaffiliated_hero_type.UnaffiliatedHeroType.UNAFFILIATED_HERO_OUTLAW
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero
import net.eagle0.eagle.library.settings.MinVigorForApprehendOutlaw
import net.eagle0.eagle.library.util.view_filters.HeroViewFilter
import net.eagle0.eagle.{FactionId, ProvinceId}
object AvailableApprehendOutlawCommandFactory extends AvailableCommandsFactoryForType {
object AvailableApprehendOutlawCommandFactory
extends AvailableCommandsFactoryForType {
private def availableToCapture(province: Province): Vector[UnaffiliatedHero] =
province.unaffiliatedHeroes
@@ -31,13 +35,13 @@ object AvailableApprehendOutlawCommandFactory extends AvailableCommandsFactoryFo
factionId: FactionId,
provinceId: ProvinceId
): Option[ApprehendOutlawAvailableCommand] = {
val province = gameState.provinces(provinceId)
val outlaws = availableToCapture(province)
val actors = availableActors(gameState, province)
val province = gameState.provinces(provinceId.value)
val outlaws = availableToCapture(province)
val actors = availableActors(gameState, province)
Option.when(outlaws.nonEmpty && actors.nonEmpty) {
ApprehendOutlawAvailableCommand(
actingProvinceId = provinceId,
actingProvinceId = provinceId.value,
availableHeroIds = actors.map(_.id),
outlaws = outlaws.map(outlaw =>
ResidentOutlaw(
@@ -1,18 +1,22 @@
package net.eagle0.eagle.library.actions.availability
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.{ArmTroopsAvailableCommand, ArmamentCost}
import net.eagle0.eagle.api.available_command.{
ArmTroopsAvailableCommand,
ArmamentCost
}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.LegacyBattalionUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.{FactionId, ProvinceId}
object AvailableArmTroopsCommandFactory extends AvailableCommandsFactoryForType {
object AvailableArmTroopsCommandFactory
extends AvailableCommandsFactoryForType {
override def availableCommand(
gameState: GameState,
factionId: FactionId,
provinceId: ProvinceId
): Option[ArmTroopsAvailableCommand] = {
val province = gameState.provinces(provinceId)
val province = gameState.provinces(provinceId.value)
if !province.rulerIsTraveling then return None
@@ -52,10 +56,11 @@ object AvailableArmTroopsCommandFactory extends AvailableCommandsFactoryForType
Some(
ArmTroopsAvailableCommand(
actingProvinceId = provinceId,
actingProvinceId = provinceId.value,
availableBattalions = affordableBattalions.map(_.id),
armamentCosts = armamentCosts,
maxArmament = LegacyProvinceUtils.effectiveInfrastructure(province).floor.toInt,
maxArmament =
LegacyProvinceUtils.effectiveInfrastructure(province).floor.toInt,
availableBattalionTypeIds = gameState.battalionTypes.map(_.typeId)
)
)
@@ -1,7 +1,6 @@
package net.eagle0.eagle.library.actions.availability
import net.eagle0.common.MoreOption
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.AttackDecisionAvailableCommand
import net.eagle0.eagle.api.command.util.army_stats.ArmyStats
import net.eagle0.eagle.api.command.util.attack_decision_type.{
@@ -15,11 +14,13 @@ import net.eagle0.eagle.common.tribute_amount.TributeAmount
import net.eagle0.eagle.internal.army.{HostileArmyGroup, MovingArmy}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.util.{IncomingArmyUtils, ProvinceDistances}
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.{IncomingArmyUtils, ProvinceDistances}
import net.eagle0.eagle.{FactionId, ProvinceId}
object AvailableAttackDecisionCommandFactory extends AvailableCommandsFactoryForType {
object AvailableAttackDecisionCommandFactory
extends AvailableCommandsFactoryForType {
// We need to include the armies that have already withdrawn or advanced, in a consistent sort order,
// so that the player doesn't learn what others have done by the shifts.
@@ -27,7 +28,7 @@ object AvailableAttackDecisionCommandFactory extends AvailableCommandsFactoryFor
pid: ProvinceId,
gs: GameState
): Vector[MovingArmy] =
gs.provinces(pid)
gs.provinces(pid.value)
.hostileArmies
.flatMap(_.armies)
.sortBy(ma => (ma.getArmy.factionId, ma.getArmy.units.head.heroId))
@@ -38,9 +39,9 @@ object AvailableAttackDecisionCommandFactory extends AvailableCommandsFactoryFor
fid: FactionId,
gs: GameState
): Option[HostileArmyGroup] =
gs.provinces(pid)
gs.provinces(pid.value)
.hostileArmies
.find(_.factionId == fid)
.find(ha => FactionId(ha.factionId) == fid)
.filter(_.status.asMessage.sealedValue.isAwaitingDecision)
private def defendingArmyStats(
@@ -48,7 +49,7 @@ object AvailableAttackDecisionCommandFactory extends AvailableCommandsFactoryFor
pid: ProvinceId,
gs: GameState
): Option[ArmyStats] = {
val province = gs.provinces(pid)
val province = gs.provinces(pid.value)
val heroCount = province.rulingFactionHeroIds.size + province.defendingArmy
.map(_.units.size)
@@ -71,8 +72,9 @@ object AvailableAttackDecisionCommandFactory extends AvailableCommandsFactoryFor
factionId = defenderFid,
heroCount = heroCount,
troopCount = troopCount,
hostility = LegacyFactionUtils.hostilityStatus(defenderFid, attackerFid, gs),
originProvinceId = pid
hostility = LegacyFactionUtils
.hostilityStatus(FactionId(defenderFid), attackerFid, gs),
originProvinceId = pid.value
)
}
}
@@ -83,7 +85,8 @@ object AvailableAttackDecisionCommandFactory extends AvailableCommandsFactoryFor
gs: GameState
): Vector[ArmyStats] =
relevantArmies(pid, gs).map(ia =>
IncomingArmyUtils.stats(toFid, ia.getArmy, gs, ia.originProvince)
IncomingArmyUtils
.stats(toFid, ia.getArmy, gs, ProvinceId(ia.originProvince))
) ++ defendingArmyStats(
toFid,
pid,
@@ -110,9 +113,9 @@ object AvailableAttackDecisionCommandFactory extends AvailableCommandsFactoryFor
fid: FactionId
): Option[AttackDecisionType] =
Option.when(
gs.provinces(pid).rulingFactionId.isEmpty ||
gs.provinces(pid.value).rulingFactionId.isEmpty ||
IncomingArmyUtils.isHostileProvince(
gs.provinces(pid),
gs.provinces(pid.value),
fid,
gs
)
@@ -124,9 +127,9 @@ object AvailableAttackDecisionCommandFactory extends AvailableCommandsFactoryFor
fid: FactionId
): Option[AttackDecisionType] =
Option.when(
hasResourcesToDemand(gs.provinces(pid))
hasResourcesToDemand(gs.provinces(pid.value))
&& IncomingArmyUtils.isHostileProvince(
gs.provinces(pid),
gs.provinces(pid.value),
fid,
gs
) && allArmiesCanFlee(gs, pid, fid)
@@ -134,8 +137,8 @@ object AvailableAttackDecisionCommandFactory extends AvailableCommandsFactoryFor
DemandTributeDecision(
Some(
TributeAmount(
food = gs.provinces(pid).food,
gold = gs.provinces(pid).gold
food = gs.provinces(pid.value).food,
gold = gs.provinces(pid.value).gold
)
)
)
@@ -158,7 +161,7 @@ object AvailableAttackDecisionCommandFactory extends AvailableCommandsFactoryFor
MoreOption.flatWhen(
!allArmiesCanFlee(gs, pid, fid) &&
!IncomingArmyUtils.isHostileProvince(
gs.provinces(pid),
gs.provinces(pid.value),
fid,
gs
)
@@ -172,14 +175,19 @@ object AvailableAttackDecisionCommandFactory extends AvailableCommandsFactoryFor
.map(_.id)
.toVector match {
case items if items.isEmpty => None
case emptyProvinces =>
case emptyProvinceIds =>
ProvinceDistances
.closestProvinceOption(
pid,
emptyProvinces,
p => gs.provinces(p).neighbors.map(_.provinceId).toVector
emptyProvinceIds.map(ProvinceId(_)),
p =>
gs.provinces(p.value)
.neighbors
.map(_.provinceId)
.map(ProvinceId(_))
.toVector
)
.map(pwd => gs.provinces(pwd.provinceId))
.map(pwd => gs.provinces(pwd.provinceId.value))
}
}
.map { returnProvince =>
@@ -211,27 +219,30 @@ object AvailableAttackDecisionCommandFactory extends AvailableCommandsFactoryFor
): Option[AttackDecisionAvailableCommand] = {
internalRequire(
IncomingArmyUtils.incomingArmiesAreMutuallyAllied(
gameState.provinces(provinceId),
gameState.provinces(provinceId.value),
gameState
),
s"Incoming armies in attack decision phase are not mutually allied in province $provinceId"
)
myDecidingHostileArmies(provinceId, factionId, gameState).flatMap { hostileArmyGroup =>
decisionOptions(gameState, provinceId, factionId) match {
case items if items.isEmpty => None
case nonemptyDecisionOptions =>
Some(
AttackDecisionAvailableCommand(
actingProvinceId = provinceId,
armies = armyStats(factionId, provinceId, gameState),
actingUnits = hostileArmyGroup.armies
.flatMap(_.getArmy.units)
.map(cu => ExpandedCombatUnitUtils.expandedCombatUnit(cu, gameState)),
availableDecisions = nonemptyDecisionOptions
myDecidingHostileArmies(provinceId, factionId, gameState).flatMap {
hostileArmyGroup =>
decisionOptions(gameState, provinceId, factionId) match {
case items if items.isEmpty => None
case nonemptyDecisionOptions =>
Some(
AttackDecisionAvailableCommand(
actingProvinceId = provinceId.value,
armies = armyStats(factionId, provinceId, gameState),
actingUnits = hostileArmyGroup.armies
.flatMap(_.getArmy.units)
.map(cu =>
ExpandedCombatUnitUtils.expandedCombatUnit(cu, gameState)
),
availableDecisions = nonemptyDecisionOptions
)
)
)
}
}
}
}
}
@@ -1,9 +1,6 @@
package net.eagle0.eagle.library.actions.availability
import scala.collection.immutable.SortedMap
import net.eagle0.common.MoreOption
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.*
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
import net.eagle0.eagle.api.selected_command.{
@@ -22,8 +19,11 @@ import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.actions.impl.command.AvailableCommandTypeMap
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.IncomingArmyUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.{FactionId, ProvinceId}
import scala.collection.immutable.SortedMap
object AvailableCommandsFactory {
def shouldFollow(
@@ -38,7 +38,7 @@ object AvailableCommandsFactory {
case (_: OrganizeTroopsSelectedCommand, _) => false
case (_: ExileVassalSelectedCommand, _) => false
case (_: SwearBrotherhoodSelectedCommand, _) => false
case (last, next) => AvailableCommandTypeMap.matches(next, last)
case (last, next) => AvailableCommandTypeMap.matches(next, last)
}
def suggestedProvinceId(
@@ -74,20 +74,22 @@ object AvailableCommandsFactory {
}
class AvailableCommandsFactory(
private val travelingFactories: Vector[AvailableCommandsFactoryForType] = Vector(
AvailableRecruitHeroesCommandFactory,
AvailableDeclineQuestCommandsFactory,
AvailableDivineCommandsFactory,
AvailableArmTroopsCommandFactory,
AvailableTradeCommandFactory,
AvailableManagePrisonersCommandFactory,
AvailableReturnCommandsFactory
),
private val handleRiotFactories: Vector[AvailableCommandsFactoryForType] = Vector(
AvailableHandleRiotCrackDownCommandFactory,
AvailableHandleRiotDoNothingCommandFactory,
AvailableHandleRiotGiveCommandFactory
),
private val travelingFactories: Vector[AvailableCommandsFactoryForType] =
Vector(
AvailableRecruitHeroesCommandFactory,
AvailableDeclineQuestCommandsFactory,
AvailableDivineCommandsFactory,
AvailableArmTroopsCommandFactory,
AvailableTradeCommandFactory,
AvailableManagePrisonersCommandFactory,
AvailableReturnCommandsFactory
),
private val handleRiotFactories: Vector[AvailableCommandsFactoryForType] =
Vector(
AvailableHandleRiotCrackDownCommandFactory,
AvailableHandleRiotDoNothingCommandFactory,
AvailableHandleRiotGiveCommandFactory
),
private val freeForAllDecisionPhaseFactories: Vector[
AvailableCommandsFactoryForType
] = Vector(
@@ -98,31 +100,33 @@ class AvailableCommandsFactory(
] = Vector(
AvailableAttackDecisionCommandFactory
),
private val defensePhaseFactories: Vector[AvailableCommandsFactoryForType] = Vector(
AvailableResolveTributeCommandsFactory,
AvailableDefendCommandsFactory
),
private val commandPhaseFactories: Vector[AvailableCommandsFactoryForType] = Vector(
AvailableApprehendOutlawCommandFactory,
AvailableControlWeatherCommandsFactory,
AvailableDiplomacyCommandsFactory,
AvailableExileVassalCommandFactory,
AvailableFeastCommandFactory,
AvailableHeroGiftCommandFactory,
AvailableImproveCommandsFactory,
AvailableMarchCommandFactory,
AvailableIssueOrdersCommandFactory,
AvailableOrganizeTroopsCommandsFactory,
AvailableAlmsCommandFactory,
AvailableReconCommandFactory,
AvailableRestCommandsFactory,
AvailableSendSuppliesCommandFactory,
AvailableStartEpidemicCommandFactory,
AvailableSuppressBeastsCommandFactory,
AvailableSwearBrotherhoodCommandFactory,
AvailableTrainCommandsFactory,
AvailableTravelCommandsFactory
)
private val defensePhaseFactories: Vector[AvailableCommandsFactoryForType] =
Vector(
AvailableResolveTributeCommandsFactory,
AvailableDefendCommandsFactory
),
private val commandPhaseFactories: Vector[AvailableCommandsFactoryForType] =
Vector(
AvailableApprehendOutlawCommandFactory,
AvailableControlWeatherCommandsFactory,
AvailableDiplomacyCommandsFactory,
AvailableExileVassalCommandFactory,
AvailableFeastCommandFactory,
AvailableHeroGiftCommandFactory,
AvailableImproveCommandsFactory,
AvailableMarchCommandFactory,
AvailableIssueOrdersCommandFactory,
AvailableOrganizeTroopsCommandsFactory,
AvailableAlmsCommandFactory,
AvailableReconCommandFactory,
AvailableRestCommandsFactory,
AvailableSendSuppliesCommandFactory,
AvailableStartEpidemicCommandFactory,
AvailableSuppressBeastsCommandFactory,
AvailableSwearBrotherhoodCommandFactory,
AvailableTrainCommandsFactory,
AvailableTravelCommandsFactory
)
) {
private def allCommands(
factories: Vector[AvailableCommandsFactoryForType],
@@ -177,13 +181,13 @@ class AvailableCommandsFactory(
pid: ProvinceId,
commands: Vector[AvailableCommand]
): Int =
commands.zipWithIndex.find {
case (ac, _) =>
commands.zipWithIndex
.find { case (ac, _) =>
gs.provinces
.get(pid)
.map(_.lastCommand)
.forall(last => AvailableCommandsFactory.shouldFollow(last, ac))
}
}
.map(_._2)
.getOrElse(0)
@@ -216,7 +220,8 @@ class AvailableCommandsFactory(
gs = gs,
pid = pid,
commands =
if gs.provinces(pid).rulerIsTraveling then allCommands(travelingFactories, gs, fid(gs, pid).get, pid)
if gs.provinces(pid).rulerIsTraveling then
allCommands(travelingFactories, gs, fid(gs, pid).get, pid)
else
allCommands(
commandPhaseFactories,
@@ -230,7 +235,8 @@ class AvailableCommandsFactory(
gs: GameState,
pid: ProvinceId
): Boolean =
if gs.provinces(pid).rulerIsTraveling then hasCommand(travelingFactories, gs, fid(gs, pid).get, pid)
if gs.provinces(pid).rulerIsTraveling then
hasCommand(travelingFactories, gs, fid(gs, pid).get, pid)
else
hasCommand(
commandPhaseFactories,
@@ -434,19 +440,27 @@ class AvailableCommandsFactory(
gs: GameState,
fid: FactionId
): Boolean =
factionLeaderProvinces(gs, fid).exists(p => defensePhaseCommandsForProvince(gs, p.id).isDefined)
factionLeaderProvinces(gs, fid).exists(p =>
defensePhaseCommandsForProvince(gs, p.id).isDefined
)
def hasAvailableHandleRiotPhaseCommands(gs: GameState): Boolean =
gs.factions.keys.exists(fid => handleRiotPhaseCommandsForFactionLeader(gs, fid).nonEmpty)
gs.factions.keys.exists(fid =>
handleRiotPhaseCommandsForFactionLeader(gs, fid).nonEmpty
)
def hasAvailablePlayerCommandsPhaseCommands(gs: GameState): Boolean =
gs.factions.keys.exists(fid => hasCommandsPhaseCommandsForPlayer(gs, fid))
def hasAvailableFreeForAllDecisionPhaseCommands(gs: GameState): Boolean =
gs.factions.keys.exists(fid => freeForAllDecisionPhasePlayerCommands(gs, fid).nonEmpty)
gs.factions.keys.exists(fid =>
freeForAllDecisionPhasePlayerCommands(gs, fid).nonEmpty
)
def hasAvailableAttackDecisionPhaseCommands(gs: GameState): Boolean =
gs.factions.keys.exists(fid => attackDecisionPhasePlayerCommands(gs, fid).nonEmpty)
gs.factions.keys.exists(fid =>
attackDecisionPhasePlayerCommands(gs, fid).nonEmpty
)
def hasAvailablePlayerDefenseCommands(gs: GameState): Boolean =
gs.factions.keys.exists(fid => hasPlayerDefenseCommandsForPlayer(gs, fid))
@@ -457,30 +471,30 @@ class AvailableCommandsFactory(
): SortedMap[ProvinceId, OneProvinceAvailableCommands] =
(
for {
faction <- gs.factions.get(fid)
diplomacyCommand <- AvailableResolveInvitationCommandFactory
.availableCommand(gs, faction.id)
.orElse(
AvailableResolveTruceOfferCommandFactory
.availableCommand(gs, faction.id)
)
.orElse(
AvailableResolveRansomOfferCommandFactory
.availableCommand(gs, faction.id)
)
.orElse(
AvailableResolveAllianceOfferCommandFactory
.availableCommand(gs, faction.id)
)
.orElse(
AvailableResolveBreakAllianceCommandFactory
.availableCommand(gs, faction.id)
)
faction <- gs.factions.get(fid)
diplomacyCommand <- AvailableResolveInvitationCommandFactory
.availableCommand(gs, faction.id)
.orElse(
AvailableResolveTruceOfferCommandFactory
.availableCommand(gs, faction.id)
)
.orElse(
AvailableResolveRansomOfferCommandFactory
.availableCommand(gs, faction.id)
)
.orElse(
AvailableResolveAllianceOfferCommandFactory
.availableCommand(gs, faction.id)
)
.orElse(
AvailableResolveBreakAllianceCommandFactory
.availableCommand(gs, faction.id)
)
oneProvinceAvailableCommand <- optionalOneProvinceAvailableCommands(
gs = gs,
0,
Vector(diplomacyCommand)
)
gs = gs,
0,
Vector(diplomacyCommand)
)
} yield SortedMap(0 -> oneProvinceAvailableCommand)
).getOrElse(SortedMap.empty)
@@ -490,14 +504,14 @@ class AvailableCommandsFactory(
): SortedMap[ProvinceId, OneProvinceAvailableCommands] =
(
for {
faction <- gs.factions.get(fid)
pleaseRecruitMeCommand <- AvailablePleaseRecruitMeCommandFactory
.availableCommand(gs, faction.id)
faction <- gs.factions.get(fid)
pleaseRecruitMeCommand <- AvailablePleaseRecruitMeCommandFactory
.availableCommand(gs, faction.id)
oneProvinceAvailableCommand <- optionalOneProvinceAvailableCommands(
gs = gs,
0,
Vector(pleaseRecruitMeCommand)
)
gs = gs,
0,
Vector(pleaseRecruitMeCommand)
)
} yield SortedMap(0 -> oneProvinceAvailableCommand)
).getOrElse(SortedMap.empty)
@@ -513,7 +527,9 @@ class AvailableCommandsFactory(
LegacyFactionUtils
.provinces(fid, gs)
.map(_.id)
.flatMap(pid => availableAftermathCommandsForProvince(gs, pid).map(pid -> _))*
.flatMap(pid =>
availableAftermathCommandsForProvince(gs, pid).map(pid -> _)
)*
)
def availablePlayerCommands(
@@ -521,41 +537,42 @@ class AvailableCommandsFactory(
fid: FactionId
): SortedMap[ProvinceId, OneProvinceAvailableCommands] =
gs.currentPhase match {
case HANDLE_RIOT => handleRiotPhaseCommandsForFactionLeader(gs, fid)
case PLEASE_RECRUIT_ME => pleaseRecruitMePhasePlayerCommands(gs, fid)
case PLAYER_COMMANDS => commandsPhasePlayerCommands(gs, fid)
case HANDLE_RIOT => handleRiotPhaseCommandsForFactionLeader(gs, fid)
case PLEASE_RECRUIT_ME => pleaseRecruitMePhasePlayerCommands(gs, fid)
case PLAYER_COMMANDS => commandsPhasePlayerCommands(gs, fid)
case FREE_FOR_ALL_DECISION =>
freeForAllDecisionPhasePlayerCommands(gs, fid)
case ATTACK_DECISION => attackDecisionPhasePlayerCommands(gs, fid)
case DEFENSE_DECISION => defensePhasePlayerCommands(gs, fid)
case BATTLE_AFTERMATH => availableAftermathPhasePlayerCommands(gs, fid)
case DIPLOMACY_RESOLUTION =>
case ATTACK_DECISION => attackDecisionPhasePlayerCommands(gs, fid)
case DEFENSE_DECISION => defensePhasePlayerCommands(gs, fid)
case BATTLE_AFTERMATH => availableAftermathPhasePlayerCommands(gs, fid)
case DIPLOMACY_RESOLUTION =>
diplomacyResolutionPhasePlayerCommands(gs, fid)
case _ => SortedMap.empty[ProvinceId, OneProvinceAvailableCommands]
case _ => SortedMap.empty[ProvinceId, OneProvinceAvailableCommands]
}
def hasAvailablePlayerCommands(gs: GameState): Boolean =
def hasAvailablePlayerCommands(gs: GameState): Boolean = {
gs.currentPhase match {
case DIPLOMACY_RESOLUTION =>
case DIPLOMACY_RESOLUTION =>
hasAvailableDiplomacyResolutionPhaseCommands(gs)
case HANDLE_RIOT =>
case HANDLE_RIOT =>
hasAvailableHandleRiotPhaseCommands(gs)
case PLEASE_RECRUIT_ME =>
case PLEASE_RECRUIT_ME =>
hasAvailablePleaseRecruitMePhaseCommands(gs)
case DEFENSE_DECISION =>
case DEFENSE_DECISION =>
hasAvailablePlayerDefenseCommands(gs)
case ATTACK_DECISION =>
case ATTACK_DECISION =>
hasAvailableAttackDecisionPhaseCommands(gs)
case FREE_FOR_ALL_DECISION =>
hasAvailableFreeForAllDecisionPhaseCommands(gs)
case PLAYER_COMMANDS =>
case PLAYER_COMMANDS =>
gs.factions.keys.exists { fid =>
hasCommandsPhaseCommandsForPlayer(gs, fid)
}
case BATTLE_AFTERMATH =>
case BATTLE_AFTERMATH =>
gs.factions.keys.exists { fid =>
availableAftermathPhasePlayerCommands(gs, fid).nonEmpty
}
case _ => false
case _ => false
}
}
}
@@ -1,8 +1,8 @@
package net.eagle0.eagle.library.actions.availability
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.{FactionId, ProvinceId}
trait AvailableCommandsFactoryForType {
def availableCommand(
@@ -1,6 +1,5 @@
package net.eagle0.eagle.library.actions.availability
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.ControlWeatherAvailableCommand
import net.eagle0.eagle.api.available_command.ControlWeatherAvailableCommand.TargetProvinceOptions
import net.eagle0.eagle.api.command.util.control_weather_type.ControlWeatherType.{
@@ -13,8 +12,10 @@ import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.settings.MinVigorForControlWeather
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.{FactionId, ProvinceId}
object AvailableControlWeatherCommandsFactory extends AvailableCommandsFactoryForType {
object AvailableControlWeatherCommandsFactory
extends AvailableCommandsFactoryForType {
private def makeControlWeatherCommand(
gameState: GameState,
@@ -33,10 +34,12 @@ object AvailableControlWeatherCommandsFactory extends AvailableCommandsFactoryFo
TargetProvinceOptions(
provinceId = pid,
controlWeatherTypes = (
if LegacyProvinceUtils.hasBlizzard(gameState.provinces(pid)) then Vector(CONTROL_WEATHER_END_BLIZZARD)
if LegacyProvinceUtils.hasBlizzard(gameState.provinces(pid)) then
Vector(CONTROL_WEATHER_END_BLIZZARD)
else Vector(CONTROL_WEATHER_START_BLIZZARD)
) ++ (
if LegacyProvinceUtils.hasDrought(gameState.provinces(pid)) then Vector(CONTROL_WEATHER_END_DROUGHT)
if LegacyProvinceUtils.hasDrought(gameState.provinces(pid)) then
Vector(CONTROL_WEATHER_END_DROUGHT)
else Vector(CONTROL_WEATHER_START_DROUGHT)
)
)
@@ -57,9 +60,8 @@ object AvailableControlWeatherCommandsFactory extends AvailableCommandsFactoryFo
gameState: GameState,
factionId: FactionId,
provinceId: ProvinceId
): Option[ControlWeatherAvailableCommand] =
makeControlWeatherCommand(
gameState,
gameState.provinces(provinceId)
)
): Option[ControlWeatherAvailableCommand] = makeControlWeatherCommand(
gameState,
gameState.provinces(provinceId.value)
)
}
@@ -8,13 +8,14 @@ import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.actions.availability.ExpandedUnaffiliatedHeroUtils.expandedUnaffiliatedHero
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
object AvailableDeclineQuestCommandsFactory extends AvailableCommandsFactoryForType {
object AvailableDeclineQuestCommandsFactory
extends AvailableCommandsFactoryForType {
override def availableCommand(
gameState: GameState,
factionId: FactionId,
provinceId: ProvinceId
): Option[DeclineQuestAvailableCommand] = {
val province = gameState.provinces(provinceId)
val province = gameState.provinces(provinceId.value)
MoreOption.flatWhen(
province.rulerIsTraveling
@@ -25,8 +26,10 @@ object AvailableDeclineQuestCommandsFactory extends AvailableCommandsFactoryForT
Option.when(withQuests.nonEmpty) {
DeclineQuestAvailableCommand(
actingProvinceId = provinceId,
declinableHeroes = withQuests.map(uh => expandedUnaffiliatedHero(gs = gameState, uh = uh))
actingProvinceId = provinceId.value,
declinableHeroes = withQuests.map(uh =>
expandedUnaffiliatedHero(gs = gameState, uh = uh)
)
)
}
}
@@ -1,15 +1,21 @@
package net.eagle0.eagle.library.actions.availability
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.DefendAvailableCommand
import net.eagle0.eagle.api.command.util.battalion_with_food_cost.BattalionWithFoodCost
import net.eagle0.eagle.internal.army.Attacking
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.settings.{MaxCombatUnitCountPerSide, MinVigorForDefend}
import net.eagle0.eagle.library.util.{BattalionSuitability, LegacyBattalionUtils}
import net.eagle0.eagle.library.settings.{
MaxCombatUnitCountPerSide,
MinVigorForDefend
}
import net.eagle0.eagle.library.util.command_choice_helpers.CombatUnitSelector
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.{
BattalionSuitability,
LegacyBattalionUtils
}
import net.eagle0.eagle.{FactionId, ProvinceId}
object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
private def sortedFleeProvinces(
@@ -18,7 +24,7 @@ object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
): Vector[ProvinceId] =
if LegacyFactionUtils
.provinces(
factionId = from.rulingFactionId.get,
factionId = FactionId(from.rulingFactionId.get),
gameState = gs
)
.map(_.id)
@@ -37,6 +43,7 @@ object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
)
.toVector
.map(_.id)
.map(ProvinceId(_))
private def makeDefendCommand(
gameState: GameState,
@@ -46,7 +53,7 @@ object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
army.status match {
case Attacking(_) =>
true
case _ => false
case _ => false
}
}
@@ -58,7 +65,7 @@ object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
.filter(_.vigor >= MinVigorForDefend.doubleValue)
if availableHeroes.isEmpty then return None
val availableBattalions = province.battalionIds.map(gameState.battalions)
val availableBattalions = province.battalionIds.map(gameState.battalions)
val availableBattalionsWithCosts = availableBattalions.map(batt =>
BattalionWithFoodCost(
battalionId = batt.id,
@@ -70,12 +77,13 @@ object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
)
val recommendedUnits = CombatUnitSelector.selectedCombatBattalions(
desiredCount = availableHeroes.size.min(MaxCombatUnitCountPerSide.intValue),
desiredCount =
availableHeroes.size.min(MaxCombatUnitCountPerSide.intValue),
heroes = availableHeroes.toVector,
battalions = availableBattalions.toVector,
battalionTypes = gameState.battalionTypes.toVector
)
val hostileUnits = hostileArmies
val hostileUnits = hostileArmies
.flatMap(_.armies)
.flatMap(_.army)
.flatMap(_.units)
@@ -86,13 +94,17 @@ object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
.toVector
.map(_.id),
actingProvinceId = province.id,
suitableBattalionsForHeroes = BattalionSuitability.suitableBattalionsForHeroes(
hs = availableHeroes.toVector,
bs = availableBattalions.toVector,
bts = gameState.battalionTypes.toVector
),
suitableBattalionsForHeroes = BattalionSuitability
.suitableBattalionsForHeroes(
hs = availableHeroes.toVector,
bs = availableBattalions.toVector,
bts = gameState.battalionTypes.toVector
)
.map { case (hid, suitability) => (hid.value, suitability) },
availableBattalions = availableBattalionsWithCosts,
availableFleeProvinceIds = sortedFleeProvinces(from = province, gs = gameState),
availableFleeProvinceIds =
sortedFleeProvinces(from = province, gs = gameState)
.map(_.value),
recommendedUnits = recommendedUnits,
hostileFactionIds = hostileArmies.toVector.map(_.factionId).distinct,
hostileHeroCount = hostileUnits.size,
@@ -114,6 +126,6 @@ object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
): Option[DefendAvailableCommand] =
makeDefendCommand(
gameState,
gameState.provinces(provinceId)
gameState.provinces(provinceId.value)
)
}
@@ -1,5 +1,4 @@
package net.eagle0.eagle.library.actions.availability
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.DiplomacyAvailableCommand
import net.eagle0.eagle.api.command.util.diplomacy_option.{
AllianceOption,
@@ -28,20 +27,28 @@ import net.eagle0.eagle.library.settings.{
}
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.ProvinceDistances
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
object AvailableDiplomacyCommandsFactory extends AvailableCommandsFactoryForType {
object AvailableDiplomacyCommandsFactory
extends AvailableCommandsFactoryForType {
def availableHeroIds(gs: GameState, pid: ProvinceId): Vector[HeroId] =
gs.provinces(pid)
gs.provinces(pid.value)
.rulingFactionHeroIds
.filter(hid => gs.heroes(hid).vigor >= MinVigorForDiplomacy.doubleValue)
.map(HeroId(_))
.toVector
def recommendedHeroId(gs: GameState, availableHids: Seq[HeroId]): HeroId =
availableHids
.sortBy(hid => (LegacyHeroUtils.fatigue(gs.heroes(hid)), -gs.heroes(hid).vigor)) match {
.sortBy(hid =>
(
LegacyHeroUtils.fatigue(gs.heroes(hid.value)),
-gs.heroes(hid.value).vigor
)
) match {
case sortedHeroes =>
sortedHeroes
.find(hid => !LegacyFactionUtils.isFactionHead(hid, gs))
@@ -58,19 +65,19 @@ object AvailableDiplomacyCommandsFactory extends AvailableCommandsFactoryForType
targetFid: FactionId
): Option[DiplomacyOption] =
Option.when(
gs.provinces(pid).gold >= TruceGoldCost.intValue
gs.provinces(pid.value).gold >= TruceGoldCost.intValue
&& LegacyProvinceUtils.ruledByFactionLeader(
province = gs.provinces(pid),
province = gs.provinces(pid.value),
gameState = gs
)
&& !LegacyFactionUtils.hasAlliance(
fid1 = gs.provinces(pid).rulingFactionId.get,
fid1 = FactionId(gs.provinces(pid.value).rulingFactionId.get),
fid2 = targetFid,
gs = gs
)
)(
TruceOption(
targetFactionId = targetFid,
targetFactionId = targetFid.value,
goldCost = TruceGoldCost.intValue
)
)
@@ -82,15 +89,15 @@ object AvailableDiplomacyCommandsFactory extends AvailableCommandsFactoryForType
targetFid: FactionId
): Option[DiplomacyOption] =
Option.when(
gs.provinces(pid).gold >= AllianceGoldCost.intValue
gs.provinces(pid.value).gold >= AllianceGoldCost.intValue
&& LegacyProvinceUtils.ruledByFactionLeader(
province = gs.provinces(pid),
province = gs.provinces(pid.value),
gameState = gs
) && !LegacyFactionUtils
.hasAlliance(fid1 = actingFid, fid2 = targetFid, gs = gs)
)(
AllianceOption(
targetFactionId = targetFid,
targetFactionId = targetFid.value,
goldCost = AllianceGoldCost.intValue
)
)
@@ -102,15 +109,15 @@ object AvailableDiplomacyCommandsFactory extends AvailableCommandsFactoryForType
targetFid: FactionId
): Option[DiplomacyOption] =
Option.when(
gs.provinces(pid).gold >= BreakAllianceGoldCost.intValue
gs.provinces(pid.value).gold >= BreakAllianceGoldCost.intValue
&& LegacyProvinceUtils.ruledByFactionLeader(
province = gs.provinces(pid),
province = gs.provinces(pid.value),
gameState = gs
) && LegacyFactionUtils
.hasAlliance(fid1 = actingFid, fid2 = targetFid, gs = gs)
)(
BreakAllianceOption(
targetFactionId = targetFid,
targetFactionId = targetFid.value,
goldCost = BreakAllianceGoldCost.intValue
)
)
@@ -120,36 +127,39 @@ object AvailableDiplomacyCommandsFactory extends AvailableCommandsFactoryForType
pid: ProvinceId,
actingFid: FactionId,
targetFid: FactionId
): Boolean =
LegacyFactionUtils.provinces(targetFid, gs).headOption.exists { targetProvince =>
val eligibleNeighbors: ProvinceId => Vector[ProvinceId] =
p =>
gs.provinces(p)
.neighbors
.map(_.provinceId)
.map(gs.provinces)
.filter(neighborProvince =>
// inviting across empty province
neighborProvince.rulingFactionId.isEmpty ||
// inviting across your own province
neighborProvince.rulingFactionId.contains(actingFid) ||
// inviting across the target's province
neighborProvince.rulingFactionId.contains(targetFid) ||
// inviting across your ally's province
LegacyFactionUtils
.hasAlliance(
neighborProvince.rulingFactionId.get,
actingFid,
gs
)
)
.map(_.id)
.toVector
): Boolean = {
LegacyFactionUtils.provinces(targetFid, gs).headOption.exists {
targetProvince =>
val eligibleNeighbors: ProvinceId => Vector[ProvinceId] =
p =>
gs.provinces(p.value)
.neighbors
.map(_.provinceId)
.map(gs.provinces)
.filter(neighborProvince =>
// inviting across empty province
neighborProvince.rulingFactionId.isEmpty ||
// inviting across your own province
neighborProvince.rulingFactionId.contains(actingFid) ||
// inviting across the target's province
neighborProvince.rulingFactionId.contains(targetFid) ||
// inviting across your ally's province
LegacyFactionUtils
.hasAlliance(
FactionId(neighborProvince.rulingFactionId.get),
actingFid,
gs
)
)
.map(_.id)
.map(ProvinceId(_))
.toVector
ProvinceDistances
.distance(pid, targetProvince.id, eligibleNeighbors)
.isDefined
ProvinceDistances
.distance(pid, ProvinceId(targetProvince.id), eligibleNeighbors)
.isDefined
}
}
private def inviteOptionForTargetFaction(
gs: GameState,
@@ -158,8 +168,10 @@ object AvailableDiplomacyCommandsFactory extends AvailableCommandsFactoryForType
targetFid: FactionId
): Option[DiplomacyOption] =
Option.when(
gs.currentRoundId >= gs.factions(actingFid).earliestRoundForInvitation &&
gs.provinces(pid).gold >= InviteGoldCost.intValue
gs.currentRoundId >= gs
.factions(actingFid.value)
.earliestRoundForInvitation &&
gs.provinces(pid.value).gold >= InviteGoldCost.intValue
&& LegacyFactionUtils
.provinceCount(
targetFid,
@@ -168,13 +180,13 @@ object AvailableDiplomacyCommandsFactory extends AvailableCommandsFactoryForType
&& LegacyFactionUtils.provinceCount(actingFid, gs) > LegacyFactionUtils
.provinceCount(targetFid, gs)
&& LegacyProvinceUtils.ruledByFactionLeader(
province = gs.provinces(pid),
province = gs.provinces(pid.value),
gameState = gs
)
&& hasClearPath(gs, pid, actingFid, targetFid)
)(
InvitationOption(
targetFactionId = targetFid,
targetFactionId = targetFid.value,
goldCost = InviteGoldCost.intValue
)
)
@@ -185,9 +197,9 @@ object AvailableDiplomacyCommandsFactory extends AvailableCommandsFactoryForType
actingFid: FactionId,
targetFid: FactionId
): Vector[DiplomacyOption] = {
val prisonersAvailableToOffer = availablePrisoners(gs.provinces(pid))
val hostagesAvailableToOffer = availableHostages(gs, pid)
val goldAvailableToOffer = gs.provinces(pid).gold
val prisonersAvailableToOffer = availablePrisoners(gs.provinces(pid.value))
val hostagesAvailableToOffer = availableHostages(gs, pid)
val goldAvailableToOffer = gs.provinces(pid.value).gold
if prisonersAvailableToOffer.nonEmpty || hostagesAvailableToOffer.nonEmpty || goldAvailableToOffer > 0
then
@@ -195,19 +207,20 @@ object AvailableDiplomacyCommandsFactory extends AvailableCommandsFactoryForType
gs,
factionId = actingFid,
targetFactionId = targetFid
).map { prisonerToBeRansomed =>
RansomOfferOption(
targetFactionId = targetFid,
ransomOffer = Some(
RansomOfferDetails(
prisonerToBeRansomed = Some(prisonerToBeRansomed),
prisonersOffered = prisonersAvailableToOffer,
hostagesOffered = hostagesAvailableToOffer,
goldOffered = goldAvailableToOffer
)
.map { prisonerToBeRansomed =>
RansomOfferOption(
targetFactionId = targetFid.value,
ransomOffer = Some(
RansomOfferDetails(
prisonerToBeRansomed = Some(prisonerToBeRansomed),
prisonersOffered = prisonersAvailableToOffer,
hostagesOffered = hostagesAvailableToOffer,
goldOffered = goldAvailableToOffer
)
)
)
)
}
}
else Vector()
end if
}
@@ -250,23 +263,24 @@ object AvailableDiplomacyCommandsFactory extends AvailableCommandsFactoryForType
gameState: GameState
): Boolean =
gameState
.factions(toFid)
.factions(toFid.value)
.incomingDiplomacyOffers
.exists(_.originatingFactionId == fromFid)
.exists(dof => FactionId(dof.originatingFactionId) == fromFid)
override def availableCommand(
gameState: GameState,
factionId: FactionId,
provinceId: ProvinceId
): Option[DiplomacyAvailableCommand] =
): Option[DiplomacyAvailableCommand] = {
if availableHeroIds(gameState, provinceId).isEmpty then None
else if gameState.provinces(provinceId).rulingFactionHeroIds.size < 2 then None
else if gameState.provinces(provinceId.value).rulingFactionHeroIds.size < 2
then None
else {
gameState.factions.keys
.filterNot(_ == factionId)
.filterNot(fid => FactionId(fid) == factionId)
.filterNot(fid =>
hasIncomingDiplomacyOffer(
toFid = fid,
toFid = FactionId(fid),
fromFid = factionId,
gameState = gameState
)
@@ -276,13 +290,13 @@ object AvailableDiplomacyCommandsFactory extends AvailableCommandsFactoryForType
gs = gameState,
pid = provinceId,
actingFid = factionId,
targetFid = targetFid
targetFid = FactionId(targetFid)
)
)
.toVector
.sortBy(opt =>
LegacyFactionUtils.sortKey(gameState.factions(opt match {
case TruceOption(targetFactionId, _, _ /* unknownFieldSet */ ) =>
case TruceOption(targetFactionId, _, _ /* unknownFieldSet */ ) =>
targetFactionId
case InvitationOption(
targetFactionId,
@@ -304,7 +318,7 @@ object AvailableDiplomacyCommandsFactory extends AvailableCommandsFactoryForType
_ /* unknownFieldSet */
) =>
targetFactionId
case DiplomacyOption.Empty => ???
case DiplomacyOption.Empty => ???
}))
) match {
case items if items.isEmpty => None
@@ -313,14 +327,15 @@ object AvailableDiplomacyCommandsFactory extends AvailableCommandsFactoryForType
Some(
DiplomacyAvailableCommand(
availableHeroIds = heroIds,
availableHeroIds = heroIds.map(_.value),
options = options,
actingProvinceId = provinceId,
recommendedHeroId = recommendedHeroId(gameState, heroIds)
actingProvinceId = provinceId.value,
recommendedHeroId = recommendedHeroId(gameState, heroIds).value
)
)
}
}
}
private def availablePrisoners(
province: Province
@@ -341,14 +356,14 @@ object AvailableDiplomacyCommandsFactory extends AvailableCommandsFactoryForType
provinceId: ProvinceId
): Vector[HostageOfferedInExchange] =
gameState
.provinces(provinceId)
.provinces(provinceId.value)
.rulingFactionHeroIds
.map(heroId => gameState.heroes(heroId))
.filter(_.vigor >= MinVigorForDiplomacy.doubleValue)
.map(hero =>
HostageOfferedInExchange(
heroId = hero.id,
provinceIdWithHero = provinceId
provinceIdWithHero = provinceId.value
)
)
.toVector
@@ -357,18 +372,19 @@ object AvailableDiplomacyCommandsFactory extends AvailableCommandsFactoryForType
gameState: GameState,
factionId: FactionId,
targetFactionId: FactionId
): Vector[PrisonerToBeRansomed] =
): Vector[PrisonerToBeRansomed] = {
for {
province <- gameState.provinces.values
.filter(_.rulingFactionId.contains(targetFactionId))
.toVector
.filter(_.rulingFactionId.contains(targetFactionId))
.toVector
prisoner <- province.unaffiliatedHeroes.filter(
_.`type` == UNAFFILIATED_HERO_PRISONER
)
_.`type` == UNAFFILIATED_HERO_PRISONER
)
if prisoner.lastFaction.contains(factionId)
if LegacyFactionUtils.isFactionLeader(prisoner.heroId, gameState)
if LegacyFactionUtils.isFactionLeader(HeroId(prisoner.heroId), gameState)
} yield PrisonerToBeRansomed(
prisonerHeroId = prisoner.heroId,
provinceIdForPrisoner = province.id
)
}
}
@@ -1,6 +1,5 @@
package net.eagle0.eagle.library.actions.availability
import net.eagle0.common.MoreOption
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.DivineAvailableCommand
import net.eagle0.eagle.common.recruitment_info.RecruitmentStatus.RECRUITMENT_STATUS_NOT_DIVINED
import net.eagle0.eagle.internal.game_state.GameState
@@ -8,6 +7,7 @@ import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.actions.availability.ExpandedUnaffiliatedHeroUtils.expandedUnaffiliatedHero
import net.eagle0.eagle.library.settings.GoldCostForDivine
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.{FactionId, ProvinceId}
object AvailableDivineCommandsFactory extends AvailableCommandsFactoryForType {
private def effectiveCost(province: Province): Int =
@@ -1,13 +1,14 @@
package net.eagle0.eagle.library.actions.availability
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.ExileVassalAvailableCommand
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
object AvailableExileVassalCommandFactory extends AvailableCommandsFactoryForType {
object AvailableExileVassalCommandFactory
extends AvailableCommandsFactoryForType {
def exilableHeroIds(
gameState: GameState,
@@ -1,11 +1,11 @@
package net.eagle0.eagle.library.actions.availability
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.FeastAvailableCommand
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.settings.FeastGoldCostPerHero
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.{FactionId, ProvinceId}
object AvailableFeastCommandFactory extends AvailableCommandsFactoryForType {
@@ -17,7 +17,7 @@ object AvailableFeastCommandFactory extends AvailableCommandsFactoryForType {
.map(gameState.heroes)
.exists(h =>
h.vigor < h.constitution || LegacyHeroUtils
.effectiveLoyalty(h.id, gameState) < 100.0
.effectiveLoyalty(HeroId(h.id), gameState) < 100.0
)
def feastGoldCost(province: Province): Int =
@@ -28,7 +28,7 @@ object AvailableFeastCommandFactory extends AvailableCommandsFactoryForType {
factionId: FactionId,
provinceId: ProvinceId
): Option[FeastAvailableCommand] = {
val province = gameState.provinces(provinceId)
val province = gameState.provinces(provinceId.value)
val goldCost = feastGoldCost(province)
Option.when(
@@ -36,7 +36,7 @@ object AvailableFeastCommandFactory extends AvailableCommandsFactoryForType {
) {
FeastAvailableCommand(
goldCost = goldCost,
actingProvinceId = provinceId
actingProvinceId = provinceId.value
)
}
}

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