Compare commits

..
1 Commits
Author SHA1 Message Date
admin c8d7e03afe lock streaming text 2025-06-05 22:22:34 -07:00
285 changed files with 2373 additions and 22677 deletions
-1
View File
@@ -6,4 +6,3 @@
*.bytes filter=lfs diff=lfs merge=lfs -text
*.psd filter=lfs diff=lfs merge=lfs -text
*.ttf filter=lfs diff=lfs merge=lfs -text
*.herodata filter=lfs diff=lfs merge=lfs -text
+6 -16
View File
@@ -3,23 +3,13 @@ name: Bazel Test
on:
push:
branches: [ "main" ]
paths:
- 'src/**'
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.github/workflows/bazel_test.yml'
- '!src/main/csharp/**'
- '!src/test/csharp/**'
paths-ignore:
- "src/main/csharp/**"
- "src/test/csharp/**"
pull_request:
paths:
- 'src/**'
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.github/workflows/bazel_test.yml'
- '!src/main/csharp/**'
- '!src/test/csharp/**'
paths-ignore:
- "src/main/csharp/**"
- "src/test/csharp/**"
permissions:
contents: read
+6 -8
View File
@@ -4,21 +4,19 @@ on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/client_presigner.yml"
- "src/main/go/net/eagle0/client_download/**"
- "src/main/go/net/eagle0/util/**"
- "src/main/go/**"
- "!src/main/go/net/eagle0/web_functions/name-generator/**"
pull_request:
paths:
- ".github/workflows/client_presigner.yml"
- "src/main/go/net/eagle0/client_download/**"
- "src/main/go/net/eagle0/util/**"
- "src/main/go/**"
- "!src/main/go/net/eagle0/web_functions/name-generator/**"
permissions:
contents: read
jobs:
client-presigner:
runs-on: self-hosted
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
@@ -26,7 +24,7 @@ jobs:
lfs: false
clean: false
- name: Build Client Presigner
run: bazel build --platforms=@io_bazel_rules_go//go/toolchain:linux_amd64 //src/main/go/net/eagle0/client_download
run: bazel build //src/main/go/net/eagle0/client_download
- name: Archive presigner binary
if: success() || failure()
uses: actions/upload-artifact@v4
-82
View File
@@ -1,82 +0,0 @@
name: Installer Build
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/installer_build.yml"
- "src/main/csharp/net/eagle0/clients/win/installer/**"
pull_request:
paths:
- ".github/workflows/installer_build.yml"
- "src/main/csharp/net/eagle0/clients/win/installer/**"
permissions:
contents: read
jobs:
build-installer:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
lfs: false
clean: false
- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/EagleInstaller.csproj
- name: Build installer
run: dotnet publish src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/EagleInstaller.csproj -c Release -r win-x64 --self-contained true --output ./installer-output
- name: Archive installer binary
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: eagle-installer
path: ./installer-output/EagleInstaller.exe
- name: Verify installer exists
if: success()
run: |
if [ ! -f "./installer-output/EagleInstaller.exe" ]; then
echo "ERROR: EagleInstaller.exe not found at expected location"
echo "Directory contents:"
ls -la ./installer-output/
exit 1
fi
echo "Installer found at correct location"
- name: Deploy installer
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: |
INSTALLER_PATH="$(pwd)/installer-output/EagleInstaller.exe"
echo "Using absolute path: $INSTALLER_PATH"
bazel run //src/main/go/net/eagle0/build/installer_build_handler:installer_build_handler -- "$INSTALLER_PATH"
- name: Update unified manifest
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: |
# Create installer manifest content
INSTALLER_SHA=$(sha256sum ./installer-output/EagleInstaller.exe | cut -d' ' -f1)
echo "installer_version=$INSTALLER_SHA" > /tmp/installer_manifest.txt
echo "installer_url=installer/EagleInstaller.exe" >> /tmp/installer_manifest.txt
echo "=== Installer manifest content ==="
cat /tmp/installer_manifest.txt
echo "=================================="
# Update the unified manifest
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer /tmp/installer_manifest.txt
+14 -8
View File
@@ -3,15 +3,21 @@ name: Mac History Editor Build
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/mac_history_build.yml"
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
- "src/main/protobuf/net/eagle0/eagle/**"
paths-ignore:
- "src/main/cpp/**"
- "src/main/scala/**"
- "src/main/csharp/**"
- "src/test/cpp/**"
- "src/test/scala/**"
- "src/test/csharp/**"
pull_request:
paths:
- ".github/workflows/mac_history_build.yml"
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
- "src/main/protobuf/net/eagle0/eagle/**"
paths-ignore:
- "src/main/cpp/**"
- "src/main/scala/**"
- "src/main/csharp/**"
- "src/test/cpp/**"
- "src/test/scala/**"
- "src/test/csharp/**"
permissions:
contents: read
+6 -18
View File
@@ -3,25 +3,13 @@ name: Shardok Build
on:
push:
branches: [ "main" ]
paths:
- 'src/main/cpp/**'
- 'src/main/proto/net/eagle0/shardok/**'
- 'src/main/proto/net/eagle0/common/**'
- 'src/main/go/net/eagle0/build/**'
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.github/workflows/shardok_build.yml'
paths-ignore:
- "src/main/csharp/**"
- "src/test/csharp/**"
pull_request:
paths:
- 'src/main/cpp/**'
- 'src/main/proto/net/eagle0/shardok/**'
- 'src/main/proto/net/eagle0/common/**'
- 'src/main/go/net/eagle0/build/**'
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.github/workflows/shardok_build.yml'
paths-ignore:
- "src/main/csharp/**"
- "src/test/csharp/**"
permissions:
contents: read
+12 -36
View File
@@ -3,33 +3,17 @@ name: Unity Build
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/unity_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/proto/**"
- "scripts/build_protos.sh"
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
- "ci/github_actions/build_unity.sh"
- "ci/github_actions/restore_library.sh"
- "ci/github_actions/persist_library.sh"
- "MODULE.bazel"
- "WORKSPACE"
- "src/main/proto/net/eagle0/eagle/**/BUILD.bazel"
pull_request:
paths:
- ".github/workflows/unity_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/proto/**"
- "scripts/build_protos.sh"
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
- "ci/github_actions/build_unity.sh"
- "ci/github_actions/restore_library.sh"
- "ci/github_actions/persist_library.sh"
- "MODULE.bazel"
- "WORKSPACE"
- "src/main/proto/**/BUILD.bazel"
paths-ignore:
- "src/main/cpp/**"
- "src/main/scala/**"
- "src/test/cpp/**"
- "src/test/scala/**"
# pull_request:
# paths-ignore:
# - "src/main/cpp/**"
# - "src/main/scala/**"
# - "src/test/cpp/**"
# - "src/test/scala/**"
permissions:
contents: read
@@ -52,18 +36,10 @@ jobs:
- name: Persist Library/
run: ./ci/github_actions/persist_library.sh
- name: Deploy Windows unity
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "/tmp/eagle0/eagle0WIN" "/tmp/unity_manifest.txt"
- name: Update unified manifest
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d /tmp/unity_manifest.txt
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "/tmp/eagle0/eagle0WIN"
- name: Archive build log
if: success() || failure()
uses: actions/upload-artifact@v4
-123
View File
@@ -1,123 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Eagle0 is a multi-language gaming system combining strategic turn-based gameplay (Eagle) with tactical hex-based combat (Shardok). The system integrates LLM-based narrative generation and supports both human and AI players.
## Architecture
**Three-Tier Game System:**
- **Unity Client (C#)**: Real-time strategy game client with integrated tactical combat UI
- **Eagle (Scala)**: Strategic layer managing turn-based gameplay, diplomacy, hero progression, and province control
- **Shardok (C++)**: Tactical layer handling real-time hex-based combat simulation with performance-critical battle resolution
**Communication Flow:**
```
Unity Client ↔ Eagle (gRPC streaming) ↔ Shardok (internal gRPC)
```
**Key Entry Points:**
- `/src/main/csharp/net/eagle0/clients/unity/eagle0/` - Unity C# game client
- `/src/main/scala/net/eagle0/eagle/Main.scala` - Eagle strategic game server
- `/src/main/cpp/net/eagle0/shardok/shardok_server_main.cpp` - Shardok tactical server
**Protocol Buffer Architecture:**
- Extensive use of protobuf for type-safe communication
- Separate packages: `api/` (client-facing), `internal/` (server state), `views/` (client projections)
- Event sourcing pattern with immutable action history
## Essential Commands
### Building
```bash
# Build Eagle server (Scala strategic layer)
bazel build //src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar
# Build Shardok server (C++ tactical layer)
bazel build -c opt //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
./scripts/build_windows_plugin.sh # Windows-specific plugin build
# Unity builds via CI: ci/github_actions/build_unity.sh
```
### Running Services
```bash
# Eagle server (port 40032)
bazel run //src/main/scala/net/eagle0/eagle:eagle_server -- --eagle-grpc-port 40032
# Or: ./scripts/eagle_run.sh
# Shardok server
bazel run //src/main/cpp/net/eagle0/shardok:shardok-server --compilation_mode=opt
# Or: ./scripts/shardok_run.sh
```
### Testing
```bash
# Run all tests
bazel test //src/test/... //src/main/go/...
# Component-specific tests
bazel test //src/test/scala/... # Scala Eagle tests
bazel test //src/test/cpp/... # C++ Shardok tests
```
### Code Generation
```bash
bazel run gazelle # Update Go build files
./scripts/updateActionResultTypes.sh # Update protocol buffer mappings
```
## Language-Specific Patterns
**Scala (Strategic Layer):**
- Use `EngineImpl.scala` for core game logic modifications
- Follow event sourcing pattern - all changes through immutable actions
- gRPC streaming for real-time client updates via `EagleServiceImpl.scala`
- LLM integration in `/common/llm_integration/` for narrative generation
**C++ (Tactical Layer):**
- Performance-critical combat in `ShardokEngine.hpp/.cpp`
- FlatBuffers for efficient serialization in `/flatbuffer/` directory
- AI systems in `/ai/` subdirectory with pluggable strategy selectors
- Extensive unit testing with Google Test framework
**Protocol Buffers:**
- Three-layer structure: `api/` (client), `internal/` (server), `views/` (projections)
- Use `shardok_internal_interface.proto` for Eagle-Shardok communication
- Maintain backward compatibility when modifying existing messages
**C# (Unity Client):**
- Located in `/src/main/csharp/net/eagle0/clients/unity/eagle0/`
- Uses Unity 6 (6000.0.32f1) with comprehensive protobuf integration (100+ .proto files)
- Key components: `EagleConnection.cs` (gRPC client), `EagleGameController.cs` (main game logic)
- Real-time bidirectional streaming with server via `PersistentClientConnection.cs`
- Strategic map UI in `Assets/Eagle/`, tactical battle UI in `Assets/Shardok/`
- Seamless transition between strategic gameplay and hex-based tactical combat
**Go (Build Tools):**
- Build automation and code generation utilities
- AWS S3 integration for deployment artifacts
## Testing Strategy
- Comprehensive unit tests for both Scala and C++ components
- Integration tests for Eagle-Shardok communication
- Map validation tests ensure game content integrity
- Use `GameSettings_test_utils.cpp` and `ShardokEngineBasedTestData.cpp` for C++ test helpers
## Game Content
**Maps:** `.e0mj` files in `/src/main/resources/net/eagle0/shardok/maps/`
**Configuration:** Game parameters in `/src/main/resources/net/eagle0/eagle/game_parameters.json`
**Data Files:** TSV format for battalions, heroes, and other game data
## Deployment
- 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`
+8 -8
View File
@@ -141,19 +141,19 @@ http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "ht
bazel_dep(name = "flatbuffers", version = "25.2.10")
#
# gtl (for parallel_hashmap)
# parallel-hashmap
#
gtl_version = "1.2.0"
parallel_hashmap_version = "1.4.1"
gtl_sha = "1969c45dd76eac0dd87e9e2b65cffe358617f4fe1bcd203f72f427742537913a"
parallel_hashmap_sha = "aac333eac3627698ca922102fd2a5921df8976906dff6b8e247a49e8cf363911"
http_archive(
name = "gtl",
build_file = "@//external:BUILD.gtl",
sha256 = gtl_sha,
strip_prefix = "gtl-%s" % gtl_version,
url = "https://github.com/greg7mdp/gtl/archive/refs/tags/v%s.zip" % gtl_version,
name = "parallel_hashmap",
build_file = "@//external:BUILD.parallel_hashmap",
sha256 = parallel_hashmap_sha,
strip_prefix = "parallel-hashmap-%s" % parallel_hashmap_version,
url = "https://github.com/greg7mdp/parallel-hashmap/archive/refs/tags/v%s.zip" % parallel_hashmap_version,
)
#
-6
View File
@@ -1,6 +0,0 @@
cc_library(
name = "gtl",
hdrs = glob(["include/gtl/*.hpp"]),
includes = ["include"],
visibility = ["//visibility:public"],
)
+5
View File
@@ -0,0 +1,5 @@
cc_library(
name = "parallel_hashmap",
hdrs = glob(["parallel_hashmap/*.h"]),
visibility = ["//visibility:public"],
)
-30
View File
@@ -1,30 +0,0 @@
#!/bin/zsh
echo "***"
echo "*** Moving files to workspace"
mv /Users/dancrosby/NewInvokeAI/outputs/images/*.png /Users/dancrosby/Downloads/new_heroes/
echo "***"
echo "*** Renaming files"
bazel run src/main/go/net/eagle0/util/hero_generation/pngorganizer -- /Users/dancrosby/Downloads/new_heroes/
# echo "***"
# echo "*** Moving files to generated"
# mv /Users/dancrosby/Downloads/new_heroes/generated/*.png /Users/dancrosby/Documents/headshots/generated
# echo "***"
# echo "*** Syncing to server"
# ./scripts/sync_headshots.sh
# echo "***"
# echo "*** Checking which new heroes have images and adjusting TSVs"
# bazel run //src/main/go/net/eagle0/util/hero_generation/imagechecker -- /Users/dancrosby/CodingProjects/github/eagle0/src/main/resources/net/eagle0/eagle/waiting_headshots_heroes.herodata /Users/dancrosby/CodingProjects/github/eagle0/src/main/resources/net/eagle0/eagle/generated_heroes.tsv /Users/dancrosby/Documents/headshots/
# echo "***"
# echo "*** Deduplicate names"
# bazel run //src/main/go/net/eagle0/util/hero_generation/namededuplicator /Users/dancrosby/CodingProjects/github/eagle0/src/main/resources/net/eagle0/eagle/generated_heroes.tsv
# rm src/main/resources/net/eagle0/eagle/generated_heroes.tsv.backup
# echo "***"
# echo "*** Generating new SD prompts"
# bazel run src/main/go/net/eagle0/util/hero_generation/heroformatter ${PWD}/src/main/resources/net/eagle0/eagle/waiting_headshots_heroes.herodata ~/samplelines.txt
@@ -4,8 +4,6 @@
#include "AIAttackGroups.hpp"
#include <unordered_map>
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
@@ -14,9 +12,6 @@ namespace shardok {
constexpr double kOverpowerRatio = 2.0;
constexpr double kBraveWaterCostMultiplier = 1.2;
using std::pair;
using std::shared_ptr;
DIST_T NormalizedCostWhenBraving(const DIST_T cost) {
if (cost >= static_cast<double>(ActionPointDistances::IMPOSSIBLE) / kBraveWaterCostMultiplier)
return ActionPointDistances::IMPOSSIBLE;
@@ -34,13 +29,13 @@ struct TargetAndDistance {
: target(t),
attackLocations(al),
targetPower(tp),
distance(d) {}
distance(d){};
};
auto MinDistance(
const Coords& start,
const CoordsSet& destinations,
const shared_ptr<ActionPointDistances>& apd) -> DIST_T {
const std::shared_ptr<ActionPointDistances>& apd) -> DIST_T {
DIST_T minDistance = ActionPointDistances::IMPOSSIBLE;
for (const Coords& dest : destinations) {
@@ -55,8 +50,8 @@ auto MinDistance(
auto MinDistanceIncludingBraving(
const Coords& start,
const CoordsSet& destinations,
const shared_ptr<ActionPointDistances>& notBravingApd,
const shared_ptr<ActionPointDistances>& bravingApd) {
const std::shared_ptr<ActionPointDistances>& notBravingApd,
const std::shared_ptr<ActionPointDistances>& bravingApd) {
// First try to get there without braving
if (const DIST_T notBravingDistance = MinDistance(start, destinations, notBravingApd);
notBravingDistance < ActionPointDistances::IMPOSSIBLE) {
@@ -98,7 +93,7 @@ auto EffectiveDistance(
const int braveWaterCost) -> DIST_T {
const auto& battType = settings.GetBattalionType(unit->battalion().type());
const auto& notBravingApd = apdCache->Get(map, mapId, battType, false);
shared_ptr<ActionPointDistances> bravingApd{nullptr};
std::shared_ptr<ActionPointDistances> bravingApd = nullptr;
if (battType->allowsBraveWater) {
bravingApd = apdCache->Get(map, mapId, battType, true, braveWaterCost);
}
@@ -106,14 +101,6 @@ auto EffectiveDistance(
return MinDistanceIncludingBraving(unit->location(), locations, notBravingApd, bravingApd);
}
auto EffectiveDistance(
const Unit* unit,
const shared_ptr<ActionPointDistances>& notBravingApd,
const shared_ptr<ActionPointDistances>& bravingApd,
const CoordsSet& locations) -> DIST_T {
return MinDistanceIncludingBraving(unit->location(), locations, notBravingApd, bravingApd);
}
auto Power(const Unit* unit) -> double { return unit->battalion().size(); }
auto CoordsIndex(const Coords& coords, const int columnCount) {
@@ -143,27 +130,10 @@ auto GenerateTargetPriorities(
vector<const Unit*> sortedAttackers = remainingUnits;
// Handle stronger units first
std::ranges::sort(sortedAttackers, [](const Unit* left, const Unit* right) {
return Power(left) > Power(right);
});
// Pre-compute ActionPointDistances for all unique battalion types
std::unordered_map<
int,
pair<shared_ptr<ActionPointDistances>, shared_ptr<ActionPointDistances>>>
apdByBattType;
for (const Unit* unit : sortedAttackers) {
if (const auto battTypeId = unit->battalion().type(); !apdByBattType.contains(battTypeId)) {
const auto& battType = settings.GetBattalionType(battTypeId);
const auto& notBravingApd = apdCache->Get(map, mapId, battType, false);
shared_ptr<ActionPointDistances> bravingApd{nullptr};
if (battType->allowsBraveWater) {
bravingApd = apdCache->Get(map, mapId, battType, true, braveWaterCost);
}
apdByBattType[battTypeId] = {notBravingApd, bravingApd};
}
}
std::sort(
begin(sortedAttackers),
end(sortedAttackers),
[](const Unit* left, const Unit* right) { return Power(left) > Power(right); });
// For each unit, sort the targets by distance from the unit to an attack location for the
// target
@@ -174,8 +144,6 @@ auto GenerateTargetPriorities(
vector<TargetAndDistance> targetsWithDistance;
const auto& [notBravingApd, bravingApd] = apdByBattType[unit->battalion().type()];
for (const Coords& targetLocation : targets) {
const auto coordsIndex = CoordsIndex(targetLocation, cc);
const auto& occupant = occupants[coordsIndex];
@@ -186,13 +154,18 @@ auto GenerateTargetPriorities(
double occupantPower = Power(occupant);
if (unit->location().row() >= 0) {
const auto& attackLocsForUnit = attackLocations.LocationsWithEnemyInRange(unit);
auto distance =
EffectiveDistance(unit, notBravingApd, bravingApd, attackLocsForUnit);
auto distance = EffectiveDistance(
unit,
map,
mapId,
apdCache,
attackLocations,
settings,
braveWaterCost);
targetsWithDistance.emplace_back(
targetLocation,
attackLocsForUnit,
attackLocations.LocationsWithEnemyInRange(unit),
occupantPower,
distance);
} else {
@@ -206,8 +179,9 @@ auto GenerateTargetPriorities(
}
// Sort by distance
std::ranges::sort(
targetsWithDistance,
std::sort(
begin(targetsWithDistance),
end(targetsWithDistance),
[&powerAttackingEachTarget,
cc](const TargetAndDistance& left, const TargetAndDistance& right) {
const auto leftIndex = CoordsIndex(left.target, cc);
@@ -56,12 +56,6 @@ auto EffectiveDistance(
const SettingsGetter& settings,
int braveWaterCost) -> DIST_T;
auto EffectiveDistance(
const Unit* unit,
const std::shared_ptr<ActionPointDistances>& notBravingApd,
const std::shared_ptr<ActionPointDistances>& bravingApd,
const CoordsSet& locations) -> DIST_T;
// Chooses a list of targets in priority order for each unit.
auto GenerateTargetPriorities(
const vector<const Unit*>& occupants,
@@ -1,599 +0,0 @@
//
// Filter obviously bad commands for performance
//
#include "AICommandFilter.hpp"
#include <algorithm>
#include <cmath>
#include "src/main/cpp/net/eagle0/shardok/library/BattalionType.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexCubeUtils.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 {
using net::eagle0::shardok::common::CommandType;
using net::eagle0::shardok::storage::fb::Unit;
CoordsSet AICommandFilter::BuildEnemyLocations(const GameState* gameState, PlayerId pid) {
CoordsSet enemyLocations(gameState->hex_map());
const auto* units = gameState->units();
for (int i = 0; i < units->size(); ++i) {
const auto* unit = units->Get(i);
if (unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT &&
unit->player_id() != pid && !unit->hidden() && unit->location().column() != -1) {
enemyLocations.Add(unit->location());
}
}
return enemyLocations;
}
std::vector<size_t> AICommandFilter::FilterCommands(
const CommandListSPtr& commands,
PlayerId pid,
bool isDefender,
const GameState* gameState,
const SettingsGetter& settings,
const APDCache& apdCache) {
std::vector<size_t> filteredIndices;
filteredIndices.reserve(commands->size());
// Build enemy and castle locations once for efficiency
const CoordsSet enemyLocations = BuildEnemyLocations(gameState, pid);
const CoordsSet castleLocations = AllCastleCoords(gameState->hex_map());
// Calculate minimum distance to enemies once for all filters
const double minDistToEnemies = MinDistanceToEnemyUnits(gameState, pid, enemyLocations);
for (size_t i = 0; i < commands->size(); ++i) {
const auto& cmd = (*commands)[i];
// Always allow END_TURN commands
if (cmd->GetCommandType() == CommandType::END_TURN_COMMAND) {
filteredIndices.push_back(i);
continue;
}
// Filter obviously bad moves
bool shouldFilter = false;
// Check spell preparation waste
if (IsWastefulAction(
*cmd,
pid,
isDefender,
gameState,
settings,
apdCache,
enemyLocations,
castleLocations,
minDistToEnemies)) {
shouldFilter = true;
}
// Check movement waste
if (!shouldFilter && IsWastefulMovement(
*cmd,
pid,
isDefender,
gameState,
settings,
apdCache,
enemyLocations,
minDistToEnemies)) {
shouldFilter = true;
}
// Check strategic blunders
if (!shouldFilter &&
IsStrategicBlunder(*cmd, pid, isDefender, gameState, settings, minDistToEnemies)) {
shouldFilter = true;
}
if (!shouldFilter) { filteredIndices.push_back(i); }
}
return filteredIndices;
}
bool AICommandFilter::IsWastefulAction(
const ShardokCommand& cmd,
PlayerId pid,
bool isDefender,
const GameState* gameState,
const SettingsGetter& settings,
const APDCache& apdCache,
const CoordsSet& enemyLocations,
const CoordsSet& castleLocations,
double minDistToEnemies) {
const auto cmdType = cmd.GetCommandType();
// Handle different spell types
switch (cmdType) {
case CommandType::METEOR_START_COMMAND: {
// Meteor preparation filtering
// Meteor takes 3 rounds (start -> target -> cast) and locks the mage in place
// Asymmetric filtering based on attacker vs defender role
if (!isDefender) {
// Attackers: Don't start meteor when too far from enemies OR castles
// Check distance to castles as well since meteor can deny castle access
double minDistToCastles = MinDistanceToCastles(gameState, pid, castleLocations);
// More aggressive filtering for attackers: filter if >4 hexes from targets
// Meteor has range 3, so being >4 hexes from enemies AND castles is wasteful
if (minDistToEnemies > 4.0 && minDistToCastles > 4.0) {
return true; // Too far from enemies and castles, advance first
}
}
// Defenders: Allow meteor in most cases since it's great for area denial
break;
}
case CommandType::START_FIRE_COMMAND: {
// Fire spell filtering - be very restrictive for attackers
// Fire only affects adjacent tiles and lasts multiple rounds
if (!isDefender) {
// Attackers: Only allow fire if the target location is on or adjacent to an enemy
const auto cmdProto = cmd.GetCommandProto();
if (!cmdProto.has_target()) {
return true; // Can't analyze without target info
}
const auto& targetCoords = cmdProto.target();
const Coords fireLocation{
static_cast<int8_t>(targetCoords.row()),
static_cast<int8_t>(targetCoords.column())};
// Check if any enemy is on the fire location or adjacent to it
bool enemyNearFireLocation = false;
// First check the fire location itself
if (enemyLocations.Contains(fireLocation)) {
enemyNearFireLocation = true;
} else {
// Check adjacent tiles (at most 6 coordinates)
const auto& adjacentCoords =
HexMapUtils::GetAdjacentCoords(gameState->hex_map(), fireLocation);
for (const auto& adjCoord : adjacentCoords) {
if (enemyLocations.Contains(adjCoord)) {
enemyNearFireLocation = true;
break;
}
}
}
if (!enemyNearFireLocation) {
return true; // No enemies on or adjacent to fire location, fire would be
// wasteful
}
}
// Defenders: Allow fire for area denial
break;
}
case CommandType::FORTIFY_COMMAND: {
// Fortify filtering - attackers shouldn't fortify when far from objectives
// Fortify improves defense but also allows an engineer to use a Reduce command next
if (!isDefender) {
// Attackers: Only allow fortify if within 3 hexes of enemies or castles
const auto cmdProto = cmd.GetCommandProto();
if (!cmdProto.has_actor()) {
return true; // Can't analyze without actor info
}
const auto unitId = cmdProto.actor().value();
// Get the acting unit directly by ID
const Unit* actingUnit = gameState->units()->Get(unitId);
// verify the unit is still active
if (actingUnit &&
actingUnit->status() !=
net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) {
actingUnit = nullptr; // Not a valid unit
}
// Verify it's our unit (not enemy)
if (actingUnit && actingUnit->player_id() != pid) { actingUnit = nullptr; }
if (!actingUnit) {
return true; // Unit not found or belongs to enemy
}
const auto& unitCoords = actingUnit->location();
const Cube unitCube = OffsetToCube(unitCoords);
// Check if within 3 hexes of any enemy
bool nearObjective = false;
for (const auto& enemyCoords : enemyLocations) {
const Cube enemyCube = OffsetToCube(enemyCoords);
const int hexDistance = CubeDistance(unitCube, enemyCube);
if (hexDistance <= 3) {
nearObjective = true;
break;
}
}
// If not near enemies, check if near castles
if (!nearObjective) {
for (const auto& castleCoord : castleLocations) {
const Cube castleCube = OffsetToCube(castleCoord);
const int hexDistance = CubeDistance(unitCube, castleCube);
if (hexDistance <= 3) {
nearObjective = true;
break;
}
}
}
if (!nearObjective) {
return true; // Too far from enemies and castles, fortify is wasteful for
// attacker
}
}
// Defenders: Allow fortify in most cases since it's about holding positions
break;
}
case CommandType::BUILD_BRIDGE_COMMAND:
case CommandType::FREEZE_WATER_COMMAND: {
// Bridge/freeze filtering - only allow if it creates significant tactical shortcuts
// These actions can fail, so we need high confidence of benefit (8+ action points
// saved)
const auto cmdProto = cmd.GetCommandProto();
if (!cmdProto.has_actor() || !cmdProto.has_target()) {
return true; // Can't analyze without full command info
}
const auto unitId = cmdProto.actor().value();
const auto& targetCoords = cmdProto.target();
const Coords waterLocation{
static_cast<int8_t>(targetCoords.row()),
static_cast<int8_t>(targetCoords.column())};
// Get the acting unit directly by ID
const Unit* actingUnit = gameState->units()->Get(unitId);
// Verify the unit is still active
if (actingUnit &&
actingUnit->status() != net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) {
actingUnit = nullptr; // Not a valid unit or not ours
}
// Verify it's our unit (not enemy)
if (actingUnit && actingUnit->player_id() != pid) { actingUnit = nullptr; }
if (!actingUnit) {
return true; // Unit not found or belongs to enemy
}
// Get action point distances for this unit's battalion type
const auto& battType = settings.GetBattalionType(actingUnit->battalion().type());
auto apd = apdCache->Get(
gameState->hex_map(),
ActionPointDistancesCache::GetMapId(gameState->hex_map()),
battType,
false);
const auto& casterCoords = actingUnit->location();
// Check if bridge creates significant shortcuts to any tactical objective
bool worthwhileShortcut = false;
// Get tiles on the "other side" of the water (adjacent to bridge location)
const auto& adjacentTiles =
HexMapUtils::GetAdjacentCoords(gameState->hex_map(), waterLocation);
// Check shortcuts to enemies
for (const auto& enemyCoords : enemyLocations) {
const auto currentDistance = apd->Distance(casterCoords, enemyCoords);
if (currentDistance == ActionPointDistances::IMPOSSIBLE) continue;
// Check if going via any adjacent tile creates a shortcut
for (const auto& adjacentCoord : adjacentTiles) {
const auto distanceToAdjacent = apd->Distance(casterCoords, adjacentCoord);
const auto adjacentToObjective = apd->Distance(adjacentCoord, enemyCoords);
if (distanceToAdjacent != ActionPointDistances::IMPOSSIBLE &&
adjacentToObjective != ActionPointDistances::IMPOSSIBLE) {
// New route: caster -> adjacent tile -> objective (plus ~2 for crossing)
const auto newRouteDistance = distanceToAdjacent + adjacentToObjective + 2;
if (currentDistance >= newRouteDistance + 8) { // 8+ action points saved
worthwhileShortcut = true;
break;
}
}
}
if (worthwhileShortcut) break;
}
// Check shortcuts to castles if no enemy shortcut found
if (!worthwhileShortcut) {
for (const auto& castleCoord : castleLocations) {
const auto currentDistance = apd->Distance(casterCoords, castleCoord);
if (currentDistance == ActionPointDistances::IMPOSSIBLE) continue;
// Check if going via any adjacent tile creates a shortcut
for (const auto& adjacentCoord : adjacentTiles) {
const auto distanceToAdjacent = apd->Distance(casterCoords, adjacentCoord);
const auto adjacentToObjective = apd->Distance(adjacentCoord, castleCoord);
if (distanceToAdjacent != ActionPointDistances::IMPOSSIBLE &&
adjacentToObjective != ActionPointDistances::IMPOSSIBLE) {
// New route: caster -> adjacent tile -> objective (plus ~2 for
// crossing)
const auto newRouteDistance =
distanceToAdjacent + adjacentToObjective + 2;
if (currentDistance >=
newRouteDistance + 8) { // 8+ action points saved
worthwhileShortcut = true;
break;
}
}
}
if (worthwhileShortcut) break;
}
}
if (!worthwhileShortcut) {
return true; // No significant shortcut found, filter out this bridge/freeze
}
break;
}
case CommandType::REPAIR_COMMAND: {
// Repair filtering - filter repairs with high integrity targets
// Note: RepairCommandFactory already filters enemy-occupied targets
const auto cmdProto = cmd.GetCommandProto();
if (!cmdProto.has_target()) {
return true; // Can't analyze without target info
}
const auto& targetCoords = cmdProto.target();
const Coords repairLocation{
static_cast<int8_t>(targetCoords.row()),
static_cast<int8_t>(targetCoords.column())};
// Check terrain modifiers at target location
const auto* terrain = GetTerrain(gameState->hex_map(), repairLocation);
const auto& modifier = terrain->modifier();
// Filter based on integrity thresholds
if (modifier.bridge().present()) {
// Bridge integrity filtering: >70% is wasteful
if (modifier.bridge().integrity() > 70.0f) {
return true; // Bridge integrity too high to justify repair
}
} else if (modifier.castle().present()) {
// Castle integrity filtering: >90% is wasteful
if (modifier.castle().integrity() > 90.0f) {
return true; // Castle integrity too high to justify repair
}
}
break;
}
case CommandType::EXTINGUISH_FIRE_COMMAND: {
// Extinguish fire filtering - don't extinguish fires on enemy-occupied tiles
const auto cmdProto = cmd.GetCommandProto();
if (!cmdProto.has_target()) {
return true; // Can't analyze without target info
}
const auto& targetCoords = cmdProto.target();
const Coords fireLocation{
static_cast<int8_t>(targetCoords.row()),
static_cast<int8_t>(targetCoords.column())};
// Check if any enemy occupies the fire location - let them burn!
const auto* units = gameState->units();
std::vector<PlayerId> allyPids; // Empty for now - assume 2-player game
if (KnownEnemyOccupant(pid, units, allyPids, fireLocation)) {
return true; // Don't extinguish fires under enemies
}
break;
}
default: return false; // Don't filter other spell types for now
}
return false;
}
bool AICommandFilter::IsWastefulMovement(
const ShardokCommand& cmd,
PlayerId pid,
bool isDefender,
const GameState* gameState,
const SettingsGetter& settings,
const APDCache& apdCache,
const CoordsSet& enemyLocations,
double minDistToEnemies) {
if (cmd.GetCommandType() != CommandType::MOVE_COMMAND) { return false; }
// Only filter attacker movement when already fairly far from enemies
if (isDefender || minDistToEnemies <= 6.0) {
return false; // Don't filter defender movement or when close to enemies
}
// Get the command proto to access unit and target information
const auto cmdProto = cmd.GetCommandProto();
// Check if we have the required information
if (!cmdProto.has_actor() || !cmdProto.has_target()) {
return false; // Can't analyze without unit and target info
}
const auto unitId = cmdProto.actor().value();
const auto& targetCoords = cmdProto.target();
// Get the acting unit directly by ID
const Unit* actingUnit = gameState->units()->Get(unitId);
// Verify the unit is still active
if (actingUnit &&
actingUnit->status() != net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) {
actingUnit = nullptr; // Not a valid unit
}
// Verify it's our unit (not enemy)
if (actingUnit && actingUnit->player_id() != pid) { actingUnit = nullptr; }
if (!actingUnit) {
return false; // Unit not found or belongs to enemy
}
const auto& currentCoords = actingUnit->location();
const Coords targetCoordsFlat{
static_cast<int8_t>(targetCoords.row()),
static_cast<int8_t>(targetCoords.column())};
// Get action point distances for this unit's battalion type
const auto& battType = settings.GetBattalionType(actingUnit->battalion().type());
auto apd = apdCache->Get(
gameState->hex_map(),
ActionPointDistancesCache::GetMapId(gameState->hex_map()),
battType,
false);
// Calculate action point distance from current position to closest enemy
double currentDistToEnemies = std::numeric_limits<double>::max();
double targetDistToEnemies = std::numeric_limits<double>::max();
for (const auto& enemyCoords : enemyLocations) {
const auto currentDist = apd->Distance(currentCoords, enemyCoords);
const auto targetDist = apd->Distance(targetCoordsFlat, enemyCoords);
if (currentDist != ActionPointDistances::IMPOSSIBLE) {
currentDistToEnemies = std::min(currentDistToEnemies, static_cast<double>(currentDist));
}
if (targetDist != ActionPointDistances::IMPOSSIBLE) {
targetDistToEnemies = std::min(targetDistToEnemies, static_cast<double>(targetDist));
}
}
// Filter movement if it takes us significantly farther from all enemies
// Only when we're already far away (>6 hexes as checked above)
if (currentDistToEnemies != std::numeric_limits<double>::max() &&
targetDistToEnemies != std::numeric_limits<double>::max()) {
// Filter if move increases distance to enemies by more than 2 action points
if (targetDistToEnemies > currentDistToEnemies + 2.0) {
return true; // Wasteful move away from enemies when already far
}
}
return false;
}
bool AICommandFilter::IsStrategicBlunder(
const ShardokCommand& cmd,
PlayerId pid,
bool isDefender,
const GameState* gameState,
const SettingsGetter& settings,
double minDistToEnemies) {
// Simplified strategic blunder detection for now
// TODO: Implement proper castle abandonment detection
// TODO: Use minDistToEnemies for strategic blunder logic
return false;
}
double AICommandFilter::MinDistanceToEnemyUnits(
const GameState* gameState,
PlayerId pid,
const CoordsSet& enemyLocations) {
// Calculate minimum distance from any player unit to any enemy unit
double minDistance = std::numeric_limits<double>::max();
const auto* units = gameState->units();
for (int i = 0; i < units->size(); ++i) {
const auto* playerUnit = units->Get(i);
if (playerUnit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT &&
playerUnit->player_id() == pid) {
const auto& playerCoords = playerUnit->location();
const Cube playerCube = OffsetToCube(playerCoords);
for (const auto& enemyCoords : enemyLocations) {
const Cube enemyCube = OffsetToCube(enemyCoords);
const int hexDistance = CubeDistance(playerCube, enemyCube);
minDistance = std::min(minDistance, static_cast<double>(hexDistance));
}
}
}
return minDistance == std::numeric_limits<double>::max() ? 0.0 : minDistance;
}
double AICommandFilter::MinDistanceToCastles(
const GameState* gameState,
PlayerId pid,
const CoordsSet& castleLocations) {
// Calculate minimum distance from any player unit to any castle
double minDistance = std::numeric_limits<double>::max();
const auto* units = gameState->units();
if (castleLocations.empty()) {
return 0.0; // No castles found
}
// Find minimum hex distance from any player unit to any castle
for (int i = 0; i < units->size(); ++i) {
const auto* unit = units->Get(i);
if (unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT &&
unit->player_id() == pid) {
const auto& unitCoords = unit->location();
const Cube unitCube = OffsetToCube(unitCoords);
for (const auto& castleCoord : castleLocations) {
const Cube castleCube = OffsetToCube(castleCoord);
const int hexDistance = CubeDistance(unitCube, castleCube);
minDistance = std::min(minDistance, static_cast<double>(hexDistance));
}
}
}
return minDistance == std::numeric_limits<double>::max() ? 0.0 : minDistance;
}
bool AICommandFilter::IsPlayerOutnumbered(
const GameState* gameState,
PlayerId pid,
double threshold) {
const int playerUnitCount = CountPlayerUnits(gameState, pid);
const int enemyUnitCount = CountPlayerUnits(gameState, 1 - pid); // Assumes 2-player game
if (enemyUnitCount == 0) return false;
const double ratio = static_cast<double>(playerUnitCount) / static_cast<double>(enemyUnitCount);
return ratio < threshold;
}
int AICommandFilter::CountPlayerUnits(const GameState* gameState, PlayerId pid) {
int count = 0;
const auto* units = gameState->units();
for (int i = 0; i < units->size(); ++i) {
const auto* unit = units->Get(i);
if (unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT &&
unit->player_id() == pid) {
count++;
}
}
return count;
}
bool AICommandFilter::WouldAbandonCriticalCastle(
const ShardokCommand& cmd,
PlayerId pid,
const GameState* gameState) {
// Simplified implementation - return false for now
// TODO: Implement proper castle abandonment detection when API is available
return false;
}
} // namespace shardok
@@ -1,104 +0,0 @@
//
// Filter obviously bad commands to reduce search space for AI
//
#ifndef EAGLE0_AICOMMANDFILTER_HPP
#define EAGLE0_AICOMMANDFILTER_HPP
#include <memory>
#include <vector>
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
namespace shardok {
using GameState = net::eagle0::shardok::storage::fb::GameState;
/**
* Filters obviously bad moves to reduce search space for AI.
* This class implements heuristic filtering to eliminate moves that are
* strategically bad without requiring deep search to identify.
*/
class AICommandFilter {
public:
/**
* Filter a list of commands, removing obviously bad ones.
* @param commands Original list of all available commands
* @param pid Player ID making the move
* @param isDefender True if this player is the defender
* @param gameState Current game state
* @param settings Game settings for parameter lookup
* @param apdCache Action point distance cache for distance calculations
* @return Filtered list of commands worth evaluating
*/
static std::vector<size_t> FilterCommands(
const CommandListSPtr& commands,
PlayerId pid,
bool isDefender,
const GameState* gameState,
const SettingsGetter& settings,
const APDCache& apdCache);
private:
// Helper to build enemy locations once for efficiency
static CoordsSet BuildEnemyLocations(const GameState* gameState, PlayerId pid);
// Spell preparation filters
static bool IsWastefulAction(
const ShardokCommand& cmd,
PlayerId pid,
bool isDefender,
const GameState* gameState,
const SettingsGetter& settings,
const APDCache& apdCache,
const CoordsSet& enemyLocations,
const CoordsSet& castleLocations,
double minDistToEnemies);
// Movement filters
static bool IsWastefulMovement(
const ShardokCommand& cmd,
PlayerId pid,
bool isDefender,
const GameState* gameState,
const SettingsGetter& settings,
const APDCache& apdCache,
const CoordsSet& enemyLocations,
double minDistToEnemies);
// Strategic blunder filters
static bool IsStrategicBlunder(
const ShardokCommand& cmd,
PlayerId pid,
bool isDefender,
const GameState* gameState,
const SettingsGetter& settings,
double minDistToEnemies);
// Helper functions for distance and position analysis
static double MinDistanceToEnemyUnits(
const GameState* gameState,
PlayerId pid,
const CoordsSet& enemyLocations);
static double MinDistanceToCastles(
const GameState* gameState,
PlayerId pid,
const CoordsSet& castleLocations);
static bool IsPlayerOutnumbered(const GameState* gameState, PlayerId pid, double threshold);
static int CountPlayerUnits(const GameState* gameState, PlayerId pid);
static bool
WouldAbandonCriticalCastle(const ShardokCommand& cmd, PlayerId pid, const GameState* gameState);
};
} // namespace shardok
#endif // EAGLE0_AICOMMANDFILTER_HPP
@@ -4,15 +4,10 @@
#include "AIScoreCalculator.hpp"
#include <atomic>
#include <chrono>
#include <future>
#include <unordered_map>
#include "src/main/cpp/net/eagle0/common/SequenceRandomGenerator.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackGroups.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AICommandFilter.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.hpp"
@@ -20,153 +15,12 @@
#include "src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCommandChooser.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexCubeUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/unit_view.pb.h"
namespace shardok {
#define LOGGING_ 0
#define PERFORMANCE_LOGGING_ 1
// Performance logging for AttackerScoreForState
struct AttackerScorePerformanceLogger {
static constexpr int LOG_INTERVAL = 100000;
static std::atomic<int> callCount;
static std::atomic<double> intervalTime;
static std::atomic<double> totalTime;
static void LogCall(double duration) {
callCount.fetch_add(1);
intervalTime.fetch_add(duration);
totalTime.fetch_add(duration);
if (callCount.load() % LOG_INTERVAL == 0) {
double intervalAvg = intervalTime.load() / LOG_INTERVAL;
double overallAvg = totalTime.load() / callCount.load();
printf("AttackerScoreForState: %d calls, last %d avg: %.1f µs, overall avg: %.1f µs\n",
callCount.load(),
LOG_INTERVAL,
intervalAvg * 1000000.0,
overallAvg * 1000000.0);
intervalTime.store(0.0); // Reset for next interval
}
}
};
std::atomic<int> AttackerScorePerformanceLogger::callCount{0};
std::atomic<double> AttackerScorePerformanceLogger::intervalTime{0.0};
std::atomic<double> AttackerScorePerformanceLogger::totalTime{0.0};
// RAII timer for automatic performance logging
class AttackerScoreTimer {
private:
std::chrono::high_resolution_clock::time_point startTime;
public:
AttackerScoreTimer() : startTime(std::chrono::high_resolution_clock::now()) {}
~AttackerScoreTimer() {
auto endTime = std::chrono::high_resolution_clock::now();
auto duration =
std::chrono::duration_cast<std::chrono::duration<double>>(endTime - startTime);
AttackerScorePerformanceLogger::LogCall(duration.count());
}
};
// Pre-cached ActionPointDistances for all battalion types to avoid repeated cache lookups
struct PreCachedAPDs {
// Use FlatBuffers-generated MAX constant to get the number of battalion types
static constexpr int NUM_BATTALION_TYPES =
static_cast<int>(BattalionTypeId::BattalionTypeId_MAX) + 1;
std::array<ActionPointDistances *, NUM_BATTALION_TYPES> regular;
std::array<ActionPointDistances *, NUM_BATTALION_TYPES> braving;
std::array<BattalionTypeSPtr, NUM_BATTALION_TYPES> battalionTypes;
PreCachedAPDs(
const GameState *gameState,
const SettingsGetter &settings,
const APDCache &apdCache,
const MapId &mapId) {
ActionPoints braveWaterCost = settings.Backing().brave_water_action_point_cost();
// Initialize arrays to avoid incremental allocation
regular.fill(nullptr);
braving.fill(nullptr);
for (int battTypeId = 0; battTypeId < NUM_BATTALION_TYPES; ++battTypeId) {
auto battType = settings.GetBattalionType(static_cast<BattalionTypeId>(battTypeId));
battalionTypes[battTypeId] = std::move(battType);
auto regularApd =
apdCache->Get(gameState->hex_map(), mapId, battalionTypes[battTypeId], false);
regular[battTypeId] = regularApd.get();
if (battalionTypes[battTypeId]->allowsBraveWater) {
auto bravingApd = apdCache->Get(
gameState->hex_map(),
mapId,
battalionTypes[battTypeId],
true,
braveWaterCost);
braving[battTypeId] = bravingApd.get();
}
}
}
ActionPointDistances *GetRegular(int battTypeId) const { return regular[battTypeId]; }
ActionPointDistances *GetBraving(int battTypeId) const { return braving[battTypeId]; }
const BattalionTypeSPtr &GetBattalionType(int battTypeId) const {
return battalionTypes[battTypeId];
}
};
// Memoization cache for EffectiveDistance calls
struct EffectiveDistanceCache {
struct CacheKey {
UnitId unitId;
Coords target;
bool operator==(const CacheKey &other) const {
return unitId == other.unitId && target == other.target;
}
};
struct CacheKeyHash {
size_t operator()(const CacheKey &key) const {
return std::hash<UnitId>{}(key.unitId) ^ (std::hash<int>{}(key.target.row()) << 1) ^
(std::hash<int>{}(key.target.column()) << 2);
}
};
mutable std::unordered_map<CacheKey, DIST_T, CacheKeyHash> cache;
DIST_T GetOrCompute(
const Unit *unit,
const Coords &target,
ActionPointDistances *notBravingApd,
ActionPointDistances *bravingApd,
const HexMap *hexMap) const {
CacheKey key{unit->unit_id(), target};
auto it = cache.find(key);
if (it != cache.end()) { return it->second; }
CoordsSet targetSet(hexMap);
targetSet.Add(target);
// Create shared_ptr wrappers for the EffectiveDistance call
std::shared_ptr<ActionPointDistances> notBravingPtr(
notBravingApd,
[](ActionPointDistances *) {});
std::shared_ptr<ActionPointDistances> bravingPtr(bravingApd, [](ActionPointDistances *) {});
DIST_T result = EffectiveDistance(unit, notBravingPtr, bravingPtr, targetSet);
cache[key] = result;
return result;
}
};
#define MULTITHREAD true
constexpr double UNITS_BASE_MULTIPLIER = 0.05;
@@ -187,13 +41,13 @@ using flatbuffers::Offset;
using net::eagle0::shardok::api::HeroView;
using net::eagle0::shardok::api::UnitView;
using GameState = fb::GameState;
using Unit = fb::Unit;
using GameState = net::eagle0::shardok::storage::fb::GameState;
using Unit = net::eagle0::shardok::storage::fb::Unit;
static const std::vector _averageSequence = {0.5};
static const std::vector<double> _averageSequence = {0.5};
static const auto _averageGenerator = std::make_shared<SequenceRandomGenerator>(_averageSequence);
static auto IsLateGame(const GameState *gs) { return gs->current_round() > 18; }
static inline auto IsLateGame(const GameState *gs) { return gs->current_round() > 18; }
static auto CommandSorter(
const AIScoreCalculator::IndexAndScore &l,
@@ -240,20 +94,11 @@ static auto RecursiveAttackerMultiplierForTargetDistance(
const vector<TargetAndAttackLocations>::const_iterator &priorityListEnd,
const vector<const Unit *> &occupants,
const HexMap *map,
const BattalionTypeSPtr &battType,
const std::shared_ptr<ActionPointDistances> &notBravingApd,
const std::shared_ptr<ActionPointDistances> &bravingApd,
bool isLateGame) -> double;
static auto RecursiveAttackerMultiplierForTargetDistance(
const Unit *attackingUnit,
vector<TargetAndAttackLocations>::const_iterator &priorityListNext,
const vector<TargetAndAttackLocations>::const_iterator &priorityListEnd,
const vector<const Unit *> &occupants,
const HexMap *map,
const BattalionTypeSPtr &battType,
const std::shared_ptr<ActionPointDistances> &notBravingApd,
const std::shared_ptr<ActionPointDistances> &bravingApd,
const MapId &mapId,
const SettingsGetter &settings,
const ALCache &alCache,
const APDCache &apdCache,
const ActionPoints braveWaterCost,
const bool isLateGame) -> double {
if (priorityListNext == priorityListEnd) return 1.0;
@@ -271,29 +116,36 @@ static auto RecursiveAttackerMultiplierForTargetDistance(
priorityListEnd,
occupants,
map,
battType,
notBravingApd,
bravingApd,
mapId,
settings,
alCache,
apdCache,
braveWaterCost,
isLateGame);
}
// Use optimized EffectiveDistance with pre-computed ActionPointDistances
// attackLocations is already the CoordsSet of attack locations for this target
const DIST_T distance =
EffectiveDistance(attackingUnit, notBravingApd, bravingApd, attackLocations);
const DIST_T distance = EffectiveDistance(
attackingUnit,
map,
mapId,
apdCache,
attackLocations,
settings,
braveWaterCost);
return kMaxProximityBuf / (1 + distance / kDistanceDebufRatio);
}
// Overload that accepts pre-computed ActionPointDistances
auto AttackerMultiplierForTargetDistance(
const Unit *attackingUnit,
const vector<TargetAndAttackLocations> &priorityList,
const vector<const Unit *> &occupants,
const HexMap *map,
const BattalionTypeSPtr &battType,
const std::shared_ptr<ActionPointDistances> &notBravingApd,
const std::shared_ptr<ActionPointDistances> &bravingApd,
const MapId &mapId,
const SettingsGetter &settings,
const ALCache &alCache,
const APDCache &apdCache,
const ActionPoints braveWaterCost,
const bool isLateGame) -> double {
auto iter = begin(priorityList);
return RecursiveAttackerMultiplierForTargetDistance(
@@ -302,9 +154,11 @@ auto AttackerMultiplierForTargetDistance(
end(priorityList),
occupants,
map,
battType,
notBravingApd,
bravingApd,
mapId,
settings,
alCache,
apdCache,
braveWaterCost,
isLateGame);
}
@@ -320,12 +174,6 @@ auto AttackerUnitsScore(
const MapId &mapId) -> ScoreValue {
bool isLateGame = IsLateGame(gameState);
// Pre-cache all ActionPointDistances for all battalion types once
PreCachedAPDs cachedAPDs(gameState, settings, apdCache, mapId);
// Memoization cache for EffectiveDistance calls
EffectiveDistanceCache distanceCache;
std::vector<const Unit *> attackerUnits{};
std::vector<const Unit *> defenderUnits{};
@@ -336,6 +184,7 @@ auto AttackerUnitsScore(
*gameState->units(),
gameState->hex_map()->row_count(),
gameState->hex_map()->column_count());
ActionPoints braveWaterCost = settings.Backing().brave_water_action_point_cost();
for (const Unit *unit : *gameState->units()) {
const auto *pi = PlayerInfoForPid(gameState, unit->player_id());
@@ -351,7 +200,7 @@ auto AttackerUnitsScore(
break;
}
case net::eagle0::shardok::storage::fb::UnitStatus_CAPTURED_UNIT: {
double thisScore = unit->has_attached_hero() && unit->attached_hero().is_vip()
double thisScore = (unit->has_attached_hero() && unit->attached_hero().is_vip())
? CAPTURED_VIP_SCORE
: CAPTURED_UNIT_SCORE;
if (pi->is_defender()) {
@@ -380,12 +229,21 @@ auto AttackerUnitsScore(
auto attackLocationsForAttacker = alCache->CachedLocations(defenderUnits, isLateGame);
const auto &locationsCausingDanger = attackLocationsForAttacker.AllLocations();
// Process attacker units using cached ActionPointDistances
vector<shared_ptr<ActionPointDistances>> actionPointDistancesByBattalionType(6);
for (const Unit *unit : attackerUnits) {
const int battTypeId = unit->battalion().type();
auto apdsForType = actionPointDistancesByBattalionType[unit->battalion().type()];
if (apdsForType == nullptr) {
apdsForType = apdCache->Get(
gameState->hex_map(),
mapId,
settings.GetBattalionType(unit->battalion().type()),
false);
actionPointDistancesByBattalionType[unit->battalion().type()] = apdsForType;
}
const auto &priorityList = std::ranges::find_if(
attackerTargetPriorities,
const auto &priorityList = std::find_if(
begin(attackerTargetPriorities),
end(attackerTargetPriorities),
[&unit](const TargetPriorityList &tpl) {
return tpl.attackingUnitId == unit->unit_id();
});
@@ -399,13 +257,11 @@ auto AttackerUnitsScore(
priorityList->priorityOrder,
occupants,
gameState->hex_map(),
cachedAPDs.GetBattalionType(battTypeId),
std::shared_ptr<ActionPointDistances>(
cachedAPDs.GetRegular(battTypeId),
[](ActionPointDistances *) {}),
std::shared_ptr<ActionPointDistances>(
cachedAPDs.GetBraving(battTypeId),
[](ActionPointDistances *) {}),
mapId,
settings,
alCache,
apdCache,
braveWaterCost,
isLateGame);
auto uv = UnitValue(
@@ -419,9 +275,7 @@ auto AttackerUnitsScore(
roundsRemaining,
attackLocationsForAttacker,
locationsCausingDanger,
std::shared_ptr<ActionPointDistances>(
cachedAPDs.GetRegular(battTypeId),
[](ActionPointDistances *) {}),
apdsForType,
settings);
attackerUnitsValue += distanceMultiplier * uv;
@@ -432,7 +286,6 @@ auto AttackerUnitsScore(
for (const Unit *unit : defenderUnits) {
auto defenderUnitId = unit->unit_id();
const int battTypeId = unit->battalion().type();
auto dv = UnitValue(
unit,
@@ -445,9 +298,11 @@ auto AttackerUnitsScore(
roundsRemaining,
attackLocationsForDefender,
locationsCausingDangerForAttacker,
std::shared_ptr<ActionPointDistances>(
cachedAPDs.GetRegular(battTypeId),
[](ActionPointDistances *) {}),
apdCache->Get(
gameState->hex_map(),
mapId,
settings.GetBattalionType(unit->battalion().type()),
false),
settings);
double distanceMultiplier = 1.0;
@@ -460,14 +315,15 @@ auto AttackerUnitsScore(
DIST_T closestDistanceToEnemy = 999;
for (const auto &attackerUnit : attackerUnits) {
const int attackerBattTypeId = attackerUnit->battalion().type();
const DIST_T thisDistance = distanceCache.GetOrCompute(
attackerUnit,
unit->location(),
cachedAPDs.GetRegular(attackerBattTypeId),
cachedAPDs.GetBraving(attackerBattTypeId),
gameState->hex_map());
if (thisDistance < closestDistanceToEnemy) {
if (const DIST_T thisDistance = EffectiveDistance(
attackerUnit,
gameState->hex_map(),
mapId,
apdCache,
myLocationSet,
settings,
braveWaterCost);
thisDistance < closestDistanceToEnemy) {
closestDistanceToEnemy = thisDistance;
}
}
@@ -483,14 +339,15 @@ auto AttackerUnitsScore(
if (defenderUnits.size() > 1) {
for (const auto &defenderUnit : defenderUnits) {
if (defenderUnit->unit_id() != defenderUnitId) {
const int defenderBattTypeId = defenderUnit->battalion().type();
const DIST_T thisDistance = distanceCache.GetOrCompute(
defenderUnit,
unit->location(),
cachedAPDs.GetRegular(defenderBattTypeId),
cachedAPDs.GetBraving(defenderBattTypeId),
gameState->hex_map());
if (thisDistance < closestDistanceToEnemy) {
if (const DIST_T thisDistance = EffectiveDistance(
defenderUnit,
gameState->hex_map(),
mapId,
apdCache,
myLocationSet,
settings,
braveWaterCost);
thisDistance < closestDistanceToEnemy) {
closestDistanceToFriendly = thisDistance;
}
}
@@ -543,7 +400,8 @@ auto AIScoreCalculator::DefenderScatterStrategyScoreForState(
for (const PlayerId winningPid : *gameState->status()->winning_shardok_ids()) {
if (winningPid < 0) continue;
if (gameState->player_infos()->Get(winningPid)->is_defender()) return INT_MAX;
return INT_MIN;
else
return INT_MIN;
}
return INT_MAX;
}
@@ -622,7 +480,8 @@ auto AIScoreCalculator::DefenderScoreForState(
if (winningPid < 0) continue;
if (defenderStrategy.strategyType == AIStrategy::STRATEGY_FLEE) return 0;
if (gameState->player_infos()->Get(winningPid)->is_defender()) return INT_MAX;
return INT_MIN;
else
return INT_MIN;
}
return INT_MIN;
}
@@ -672,16 +531,14 @@ auto AIScoreCalculator::AttackerScoreForState(
const SettingsGetter &settings,
const ALCache &alCache,
const APDCache &apdCache) -> ScoreValue {
#if PERFORMANCE_LOGGING_
AttackerScoreTimer timer;
#endif // # PERFORMANCE_LOGGING_
if (gameState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
for (const PlayerId winningPid : *gameState->status()->winning_shardok_ids()) {
if (winningPid < 0) continue;
if (attackerStrategy.strategyType == AIStrategy::STRATEGY_FLEE) return 0;
if (gameState->player_infos()->Get(winningPid)->is_defender()) return INT_MIN;
return INT_MAX;
else
return INT_MAX;
}
return INT_MAX;
}
@@ -765,15 +622,16 @@ auto AIScoreCalculator::AttackerScoreForState(
settingsGetter,
alCache,
apdCache);
} else {
return AttackerScoreForState(
state,
aiStrategy,
allCastleCoords,
roundsRemaining,
settingsGetter,
alCache,
apdCache);
}
return AttackerScoreForState(
state,
aiStrategy,
allCastleCoords,
roundsRemaining,
settingsGetter,
alCache,
apdCache);
}
void PrintCommand(
@@ -795,7 +653,6 @@ void PrintCommand(
auto AIScoreCalculator::BasicLookaheadCalculator(
const PlayerId pid,
const bool isDefender,
const int remainingLookahead,
const int maxRepeatCount,
const shared_ptr<ShardokEngine> &innerEngine,
const ScoreValue currentUtility,
@@ -811,7 +668,7 @@ auto AIScoreCalculator::BasicLookaheadCalculator(
const auto [index, type, lookaheadScore, immediateScore] = BestCommandIndex(
pid,
isDefender,
remainingLookahead - 1,
-1,
maxRepeatCount,
*innerEngine,
attackerStrategy,
@@ -848,7 +705,7 @@ auto AIScoreCalculator::CalcOne(
auto innerEngine = std::make_shared<ShardokEngine>(guessedEngine, false);
innerEngine->PostCommand(pid, commandIndex, randomGenerator);
auto innerUtility = GuessedStateScore(
auto innerUtility = AIScoreCalculator::GuessedStateScore(
isDefender,
innerEngine->GetCurrentGameState(),
attackerStrategy,
@@ -868,14 +725,13 @@ auto AIScoreCalculator::CalcOne(
returnValue.immediateScore = innerUtility;
if (remainingLookahead <= 0) {
if (remainingLookahead == -1) {
std::promise<ScoreValue> p;
returnValue.lookaheadScore = p.get_future();
p.set_value(innerUtility);
} else {
auto lookaheadLambda = [pid,
isDefender,
remainingLookahead,
maxRepeatCount,
innerEngine,
attackerStrategy,
@@ -887,7 +743,6 @@ auto AIScoreCalculator::CalcOne(
return BasicLookaheadCalculator(
pid,
isDefender,
remainingLookahead,
maxRepeatCount,
innerEngine,
innerUtility,
@@ -925,61 +780,7 @@ auto AIScoreCalculator::CalcOne(
const ALCache &alCache) -> IndexAndScore {
const CommandListSPtr guessedDescriptors = guessedEngine.GetAvailableCommandsForAIPlayer(pid);
// Filter out obviously bad commands to reduce search space
const std::vector<size_t> filteredIndices = AICommandFilter::FilterCommands(
guessedDescriptors,
pid,
isDefender,
guessedEngine.GetCurrentGameState(),
settingsGetter,
apdCache);
const auto *gameState = guessedEngine.GetCurrentGameState();
// Calculate minimum hex distance to enemies for this player
double minDistToEnemies = std::numeric_limits<double>::max();
const auto *units = gameState->units();
for (int i = 0; i < units->size(); ++i) {
if (const auto *playerUnit = units->Get(i); playerUnit->player_id() == pid) {
const auto &playerCoords = playerUnit->location();
for (int j = 0; j < units->size(); ++j) {
if (const auto *enemyUnit = units->Get(j); enemyUnit->player_id() != pid) {
const auto &enemyCoords = enemyUnit->location();
// Proper hex distance calculation using cube coordinates
const Cube playerCube = OffsetToCube(playerCoords);
const Cube enemyCube = OffsetToCube(enemyCoords);
const int hexDistance = CubeDistance(playerCube, enemyCube);
minDistToEnemies = std::min(minDistToEnemies, static_cast<double>(hexDistance));
}
}
}
}
if (minDistToEnemies == std::numeric_limits<double>::max()) {
minDistToEnemies = 0.0; // No enemies found
}
#if LOGGING_
// Log command count and distance metrics for performance analysis
const auto allCommandCount = guessedDescriptors->size();
const auto filteredCommandCount = filteredIndices.size();
const int currentRound = gameState->current_round();
printf("AI_COMMAND_COUNT: Round %d, Player %d, Defender %d, MinDist %.1f, Commands %zu -> %zu "
"(%.1f%% filtered)\n",
currentRound,
static_cast<int>(pid),
isDefender ? 1 : 0,
minDistToEnemies,
allCommandCount,
filteredCommandCount,
100.0 * (allCommandCount - filteredCommandCount) / allCommandCount);
#endif
const auto commandCount = filteredIndices.size();
const auto commandCount = guessedDescriptors->size();
vector<IndexAndScore> allIndices(commandCount);
@@ -987,11 +788,10 @@ auto AIScoreCalculator::CalcOne(
vector<vector<future<ScoreValue>>> scoreFutures(commandCount);
for (uint32_t index = 0; index < commandCount; index++) {
const auto originalIndex = filteredIndices[index];
const auto &guessedDescriptor = guessedDescriptors->at(originalIndex);
const auto &guessedDescriptor = guessedDescriptors->at(index);
const auto guessedCommandType = guessedDescriptor->GetCommandType();
allIndices[index].index = originalIndex;
allIndices[index].index = index;
allIndices[index].type = guessedCommandType;
if (guessedCommandType == net::eagle0::shardok::common::END_TURN_COMMAND) {
@@ -1003,7 +803,7 @@ auto AIScoreCalculator::CalcOne(
auto [immediateScore, lookaheadScore] =
CalcOne(pid,
isDefender,
originalIndex,
index,
remainingLookahead,
maxRepeatCount,
_averageGenerator,
@@ -1025,7 +825,7 @@ auto AIScoreCalculator::CalcOne(
auto [successImmediateScore, successLookaheadScore] =
CalcOne(pid,
isDefender,
originalIndex,
index,
remainingLookahead,
maxRepeatCount,
std::make_shared<SequenceRandomGenerator>(
@@ -1042,7 +842,7 @@ auto AIScoreCalculator::CalcOne(
auto [failureImmediateScore, failureLookaheadScore] =
CalcOne(pid,
isDefender,
originalIndex,
index,
remainingLookahead,
maxRepeatCount,
std::make_shared<SequenceRandomGenerator>(
@@ -1059,9 +859,8 @@ auto AIScoreCalculator::CalcOne(
auto successSF = successLookaheadScore.share();
auto failureSF = failureLookaheadScore.share();
scoreFutures[index].push_back(std::async(
std::launch::deferred,
[successSF, failureSF, successChance]() -> double {
scoreFutures[index].push_back(
std::async(std::launch::deferred, [successSF, failureSF, successChance]() {
return std::lerp(failureSF.get(), successSF.get(), successChance);
}));
} else {
@@ -1074,7 +873,7 @@ auto AIScoreCalculator::CalcOne(
auto [immediateScore, lookaheadScore] =
CalcOne(pid,
isDefender,
originalIndex,
index,
remainingLookahead,
maxRepeatCount,
std::make_shared<SequenceRandomGenerator>(sequence),
@@ -1099,138 +898,7 @@ auto AIScoreCalculator::CalcOne(
allIndices[i].lookaheadScore = total / count;
}
return *std::ranges::max_element(allIndices, CommandSorter);
}
auto AIScoreCalculator::EvaluateCommand(
const PlayerId pid,
const bool isDefender,
const uint32_t commandIndex,
const int remainingLookahead,
const int maxRepeatCount,
const ShardokEngine &guessedEngine,
const AIStrategy &attackerStrategy,
const ScoreValue currentUtility,
const SettingsGetter &settingsGetter,
const CoordsSet &allCastleCoords,
const APDCache &apdCache,
const ALCache &alCache) -> CommandEvaluationResult {
const CommandListSPtr guessedDescriptors = guessedEngine.GetAvailableCommandsForAIPlayer(pid);
if (commandIndex >= guessedDescriptors->size()) { return {currentUtility, currentUtility}; }
const auto &guessedDescriptor = guessedDescriptors->at(commandIndex);
if (const auto guessedCommandType = guessedDescriptor->GetCommandType();
guessedCommandType == net::eagle0::shardok::common::END_TURN_COMMAND) {
return {currentUtility, currentUtility};
} else if (IsDeterministic(guessedCommandType)) {
auto [immediateScore, lookaheadScore] =
CalcOne(pid,
isDefender,
commandIndex,
remainingLookahead,
maxRepeatCount,
_averageGenerator,
guessedEngine,
attackerStrategy,
settingsGetter,
allCastleCoords,
apdCache,
alCache);
return {immediateScore, lookaheadScore.get()};
} else if (guessedDescriptor->HasOdds()) {
const auto successChancePercentile = guessedDescriptor->GetOddsPercentile();
const double successChance = static_cast<double>(successChancePercentile) / 100.0;
// Success attempt
auto [successImmediateScore, successLookaheadScore] = CalcOne(
pid,
isDefender,
commandIndex,
remainingLookahead,
maxRepeatCount,
std::make_shared<SequenceRandomGenerator>(std::vector{1.0 - successChance / 2.0}),
guessedEngine,
attackerStrategy,
settingsGetter,
allCastleCoords,
apdCache,
alCache);
// Failure attempt
auto [failureImmediateScore, failureLookaheadScore] = CalcOne(
pid,
isDefender,
commandIndex,
remainingLookahead,
maxRepeatCount,
std::make_shared<SequenceRandomGenerator>(std::vector{(1.0 - successChance) / 2.0}),
guessedEngine,
attackerStrategy,
settingsGetter,
allCastleCoords,
apdCache,
alCache);
// Return weighted average of success and failure
return {std::lerp(failureImmediateScore, successImmediateScore, successChance),
std::lerp(failureLookaheadScore.get(), successLookaheadScore.get(), successChance)};
} else {
// For non-deterministic commands without odds, use multiple attempts
ScoreValue totalImmediateScore = 0.0;
ScoreValue totalLookaheadScore = 0.0;
for (int repeatIteration = 0; repeatIteration < maxRepeatCount; repeatIteration++) {
auto sequence = std::vector{
static_cast<double>(repeatIteration) / static_cast<double>(maxRepeatCount - 1)};
auto [immediateScore, lookaheadScore] =
CalcOne(pid,
isDefender,
commandIndex,
remainingLookahead,
maxRepeatCount,
std::make_shared<SequenceRandomGenerator>(sequence),
guessedEngine,
attackerStrategy,
settingsGetter,
allCastleCoords,
apdCache,
alCache);
totalImmediateScore += immediateScore;
totalLookaheadScore += lookaheadScore.get();
}
return {totalImmediateScore / maxRepeatCount, totalLookaheadScore / maxRepeatCount};
}
}
[[nodiscard]] auto AIScoreCalculator::CommandScore(
const PlayerId pid,
const bool isDefender,
const int remainingLookahead,
const int maxRepeatCount,
const ShardokEngine &guessedEngine,
const AIStrategy &attackerStrategy,
const ScoreValue currentUtility,
const SettingsGetter &settingsGetter,
const CoordsSet &allCastleCoords,
const APDCache &apdCache,
const ALCache &alCache,
const size_t commandIndex) -> ScoreValue {
const auto result = EvaluateCommand(
pid,
isDefender,
commandIndex,
remainingLookahead,
maxRepeatCount,
guessedEngine,
attackerStrategy,
currentUtility,
settingsGetter,
allCastleCoords,
apdCache,
alCache);
return result.lookaheadScore;
return *std::max_element(std::begin(allIndices), std::end(allIndices), CommandSorter);
}
} // namespace shardok
@@ -5,7 +5,10 @@
#ifndef EAGLE0_AISCORECALCULATOR_HPP
#define EAGLE0_AISCORECALCULATOR_HPP
#include <flatbuffers/flatbuffers.h>
#include <future>
#include <utility>
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
@@ -13,6 +16,7 @@
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateGuesser.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/api/game_state_view.pb.h"
@@ -20,7 +24,7 @@
namespace shardok {
using net::eagle0::shardok::api::GameStateView;
using GameState = fb::GameState;
using GameState = net::eagle0::shardok::storage::fb::GameState;
using shardok::PlayerId;
using std::future;
using std::vector;
@@ -32,7 +36,7 @@ class AIScoreCalculator {
public:
struct IndexAndScore {
size_t index;
CommandType type;
net::eagle0::shardok::common::CommandType type;
ScoreValue lookaheadScore;
ScoreValue immediateScore;
};
@@ -83,7 +87,6 @@ private:
static auto BasicLookaheadCalculator(
PlayerId pid,
bool isDefender,
int remainingLookahead,
int maxRepeatCount,
const shared_ptr<ShardokEngine> &innerEngine,
ScoreValue currentUtility,
@@ -107,25 +110,6 @@ private:
const APDCache &apdCache,
const ALCache &alCache) -> ImmediateAndLookaheadScore;
struct CommandEvaluationResult {
ScoreValue immediateScore;
ScoreValue lookaheadScore;
};
static auto EvaluateCommand(
PlayerId pid,
bool isDefender,
uint32_t commandIndex,
int remainingLookahead,
int maxRepeatCount,
const ShardokEngine &guessedEngine,
const AIStrategy &attackerStrategy,
ScoreValue currentUtility,
const SettingsGetter &settingsGetter,
const CoordsSet &allCastleCoords,
const APDCache &apdCache,
const ALCache &alCache) -> CommandEvaluationResult;
public:
[[nodiscard]] static auto GuessedStateScore(
bool isDefender,
@@ -148,20 +132,6 @@ public:
const CoordsSet &allCastleCoords,
const APDCache &apdCache,
const ALCache &alCache) -> IndexAndScore;
[[nodiscard]] static auto CommandScore(
PlayerId pid,
bool isDefender,
int remainingLookahead,
int maxRepeatCount,
const ShardokEngine &guessedEngine,
const AIStrategy &attackerStrategy,
ScoreValue currentUtility,
const SettingsGetter &settingsGetter,
const CoordsSet &allCastleCoords,
const APDCache &apdCache,
const ALCache &alCache,
size_t commandIndex) -> ScoreValue;
};
} // namespace shardok
@@ -1,91 +0,0 @@
//
// Created by Dan Crosby on 07/04/25.
//
#include "AITimeBudget.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/FlatbufferWrapper.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexCubeUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
namespace shardok {
// Static member definition
std::atomic<int> AIEvaluationCounter::activeCount{0};
AIEvaluationCounter::AIEvaluationCounter() { activeCount++; }
AIEvaluationCounter::~AIEvaluationCounter() { activeCount--; }
int AIEvaluationCounter::GetCurrentCount() { return activeCount.load(); }
auto CalculateTimeBudget(
const PlayerId playerId,
const GameSettingsSPtr &settings,
const GameStateW &state) -> AITimeBudget {
const auto settingsGetter = settings->GetGetter();
const auto castleCoords = AllCastleCoords(state->hex_map());
// Determine proximity (≤4 hex distance) - applies to both attackers and defenders
bool isClose = false;
const auto *units = state->units();
for (int i = 0; i < units->size() && !isClose; ++i) {
const auto *myUnit = units->Get(i);
if (myUnit->player_id() != playerId) continue;
const auto &myCoords = myUnit->location();
// Skip units that haven't been placed on the map yet
if (myCoords.row() == -1) continue;
const Cube myCube = OffsetToCube(myCoords);
// Check distance to enemy units
for (int j = 0; j < units->size(); ++j) {
const auto *enemyUnit = units->Get(j);
if (enemyUnit->player_id() == playerId) continue;
const auto &enemyCoords = enemyUnit->location();
// Skip enemy units that haven't been placed on the map yet
if (enemyCoords.row() == -1) continue;
const Cube enemyCube = OffsetToCube(enemyCoords);
if (const int hexDistance = CubeDistance(myCube, enemyCube); hexDistance <= 4) {
isClose = true;
break;
}
}
// Check distance to castles
if (!isClose) {
for (const auto &castleCoord : castleCoords) {
const Cube castleCube = OffsetToCube(castleCoord);
if (const int hexDistance = CubeDistance(myCube, castleCube); hexDistance <= 4) {
isClose = true;
break;
}
}
}
}
// Get time budget from settings
const auto budget = std::chrono::duration<double>(
isClose ? settingsGetter.Backing().lookahead_time_budget_close_in_seconds()
: settingsGetter.Backing().lookahead_time_budget_far_in_seconds());
const auto remainingBudget = std::chrono::duration_cast<std::chrono::milliseconds>(budget);
// Get minimum depth requirement
const int minDepth = settingsGetter.Backing().min_lookahead_turns();
return AITimeBudget{
.remainingBudget = remainingBudget,
.minDepthRequired = minDepth,
.isCloseToEnemy = isClose};
}
} // namespace shardok
@@ -1,48 +0,0 @@
//
// Created by Dan Crosby on 07/04/25.
//
#ifndef EAGLE0_AITIMEBUDGET_HPP
#define EAGLE0_AITIMEBUDGET_HPP
#include <atomic>
#include <chrono>
#include <memory>
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/FlatbufferWrapper.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
namespace shardok {
// Forward declarations
class GameSettings;
using GameStateW = Wrapper<net::eagle0::shardok::storage::fb::GameState>;
using GameSettingsSPtr = std::shared_ptr<GameSettings>;
// RAII counter for tracking concurrent AI command evaluations
class AIEvaluationCounter {
static std::atomic<int> activeCount;
public:
AIEvaluationCounter();
~AIEvaluationCounter();
static int GetCurrentCount();
};
// Configuration structure for iterative deepening time budget
struct AITimeBudget {
std::chrono::milliseconds remainingBudget; // Time budget remaining (decremented as used)
int minDepthRequired; // Minimum depth from minLookaheadTurns
bool isCloseToEnemy; // Proximity flag for budget selection
};
// Calculate time budget based on proximity to enemies and castles
auto CalculateTimeBudget(
PlayerId playerId,
const GameSettingsSPtr &settings,
const GameStateW &state) -> AITimeBudget;
} // namespace shardok
#endif // EAGLE0_AITIMEBUDGET_HPP
@@ -1,603 +0,0 @@
# Eagle0 AI Scoring System: Proposed Improvements
## Executive Summary
This document outlines proposed improvements to the Eagle0 AI scoring system to make it more robust and strategically intelligent. The current system makes reasonable local tactical decisions but lacks strategic depth, contextual awareness, and multi-turn planning. These improvements would transform the AI from a competent but predictable opponent into a genuinely challenging strategic adversary.
## Current System Weaknesses
### 1. Static Unit Valuation
- Fixed multipliers (1.0x infantry, 2.0x cavalry) regardless of context
- No consideration for terrain advantages or disadvantages
- Missing unit synergy and combined arms tactics
- Undervaluation of situational effectiveness
### 2. Primitive Spell Intelligence
- Hard-coded spell values that don't scale with game state
- Lightning severely undervalued (0.05 vs 38 for archery)
- Limited spell selection intelligence beyond meteor (which already has sophisticated cluster analysis)
- Poor timing for multi-turn spells like meteor preparation
### 3. Lack of Strategic Planning
- Each command evaluated independently
- No multi-turn goal coordination
- Reactive rather than proactive strategy changes
- Missing opportunity cost analysis
### 4. Limited Positional Understanding
- Simple distance-based scoring
- No chokepoint control evaluation
- Missing flanking and formation concepts
- Inadequate terrain advantage assessment
### 5. Poor Victory Condition Integration
- Static additive scoring regardless of game phase
- No dynamic priority adjustment based on time remaining
- Weak endgame transition strategies
## Proposed Improvements
### Phase 1: Immediate Impact Improvements
#### 1.1 Dynamic Unit Valuation System
**Objective**: Replace static unit multipliers with context-aware valuation
**Implementation**:
```cpp
class ContextualUnitEvaluator {
public:
struct UnitContext {
TerrainType terrain;
bool inCastle;
bool hasSupport;
std::vector<UnitType> adjacentAllies;
std::vector<UnitType> nearbyEnemies;
int distanceToObjective;
};
double CalculateContextualValue(const Unit& unit, const UnitContext& context) {
double baseValue = GetBaseUnitValue(unit);
// Terrain modifiers
baseValue *= GetTerrainModifier(unit.type, context.terrain);
// Castle bonuses/penalties
if (context.inCastle) {
baseValue *= GetCastleModifier(unit.type);
}
// Combined arms bonuses
baseValue *= CalculateSynergyBonus(unit.type, context.adjacentAllies);
// Threat assessment
baseValue *= AssessThreatLevel(unit, context.nearbyEnemies);
return baseValue;
}
private:
double GetTerrainModifier(UnitType type, TerrainType terrain) {
switch (type) {
case CAVALRY:
return (terrain == PLAINS) ? 1.4 :
(terrain == FOREST) ? 0.8 : 1.0;
case LONGBOWMEN:
return (terrain == HILLS) ? 1.3 : 1.0;
// ... more terrain interactions
}
}
double GetCastleModifier(UnitType type) {
switch (type) {
case LONGBOWMEN: return 1.4; // Excellent in castles
case CAVALRY: return 0.7; // Vulnerable in castles
case HEAVY_INFANTRY: return 1.2; // Good defenders
default: return 1.0;
}
}
};
```
**Benefits**:
- Cavalry properly devalued when attacking fortified positions
- Longbowmen bonus for castle and hill positions
- Combined arms tactics encouraged
- Situational unit effectiveness captured
#### 1.2 Intelligent Spell Scoring
**Objective**: Replace static spell constants with dynamic evaluation
**Implementation**:
```cpp
class SpellEvaluator {
public:
double EvaluateLightning(const GameState& state, Coords target) {
// Base damage potential
double value = CountTargetableEnemies(state, target) * kLightningDamagePerUnit;
// Bonus for hitting valuable targets
value += EvaluateTargetValue(state, target);
// Opportunity cost (could we do something better?)
value -= CalculateOpportunityCost(state);
return value;
}
double EvaluateMeteor(const GameState& state, Coords target, int turnsToLand) {
// Predict enemy positions when meteor lands
auto predictedPositions = PredictEnemyPositions(state, turnsToLand);
// Direct damage value
double directValue = CalculateMeteorDamage(predictedPositions, target);
// Area denial value
double denialValue = CalculateAreaDenialValue(state, target, turnsToLand);
// Movement forcing value
double forcingValue = CalculateMovementForcingValue(state, target);
return directValue + denialValue + forcingValue;
}
double EvaluateAOESpell(const GameState& state, Coords center, int radius) {
// Note: Meteor already has sophisticated cluster analysis in meteorDropRawValue()
// This example shows how similar logic could be applied to other potential AOE spells
auto targets = GetUnitsInRadius(state, center, radius);
// Cluster bonus - more valuable against grouped enemies
double clusterBonus = std::min(2.0, targets.size() * 0.3);
double totalValue = 0;
for (const auto& target : targets) {
totalValue += GetUnitValue(target) * clusterBonus;
}
return totalValue;
}
};
```
**Benefits**:
- Lightning properly valued based on target selection
- Meteor timing accounts for enemy movement patterns
- Builds on existing sophisticated meteor cluster analysis
- Area denial and positioning effects included for other spells
#### 1.3 Dynamic Victory Condition Weighting
**Objective**: Adjust priorities based on game state and time remaining
**Implementation**:
```cpp
class VictoryConditionEvaluator {
public:
struct GamePhase {
enum Type { OPENING, MIDGAME, ENDGAME, DESPERATE };
Type phase;
int roundsRemaining;
double urgencyFactor;
};
double CalculateVictoryScore(const GameState& state, PlayerId player) {
GamePhase phase = DetermineGamePhase(state);
double castleScore = EvaluateCastleControl(state, player) *
GetCastleWeight(phase);
double unitScore = EvaluateUnitAdvantage(state, player) *
GetUnitWeight(phase);
double positionScore = EvaluatePositionalAdvantage(state, player) *
GetPositionalWeight(phase);
return castleScore + unitScore + positionScore;
}
private:
double GetCastleWeight(const GamePhase& phase) {
switch (phase.phase) {
case OPENING: return 0.3; // Positioning important
case MIDGAME: return 0.6; // Balanced approach
case ENDGAME: return 1.2; // Castles critical
case DESPERATE: return 2.0; // Must secure castles
}
}
GamePhase DetermineGamePhase(const GameState& state) {
int roundsRemaining = GetMaxRounds() - state.current_round();
double urgency = 1.0 - (double)roundsRemaining / GetMaxRounds();
if (roundsRemaining > 20) return {GamePhase::OPENING, roundsRemaining, urgency};
if (roundsRemaining > 10) return {GamePhase::MIDGAME, roundsRemaining, urgency};
if (roundsRemaining > 3) return {GamePhase::ENDGAME, roundsRemaining, urgency};
return {GamePhase::DESPERATE, roundsRemaining, urgency};
}
};
```
**Benefits**:
- Castle control prioritized more heavily as time runs out
- Opening game focuses on positioning
- Endgame desperation properly modeled
### Phase 2: Strategic Depth Improvements
#### 2.1 Multi-Turn Strategic Planning
**Objective**: Add strategic planning layer above tactical command evaluation
**Implementation**:
```cpp
class StrategicPlanner {
public:
enum StrategicGoal {
SECURE_CASTLES,
ELIMINATE_ENEMIES,
CONTROL_CHOKEPOINTS,
PROTECT_VIPS,
SETUP_COMBOS
};
struct StrategicPlan {
StrategicGoal primaryGoal;
StrategicGoal secondaryGoal;
std::vector<TacticalObjective> objectives;
int turnsToExecute;
double expectedValue;
};
StrategicPlan CreatePlan(const GameState& state, PlayerId player, int horizon) {
auto goals = PrioritizeGoals(state, player);
auto plan = GeneratePlan(state, goals, horizon);
// Evaluate plan using lookahead
plan.expectedValue = EvaluatePlanOutcome(state, plan);
return plan;
}
void AdaptPlan(StrategicPlan& plan, const GameState& newState,
const Command& opponentMove) {
// Assess if opponent action invalidates current plan
if (PlanStillViable(plan, newState, opponentMove)) {
// Minor adjustments
AdjustTactics(plan, newState);
} else {
// Major replanning needed
plan = CreatePlan(newState, plan.player, plan.turnsToExecute - 1);
}
}
private:
std::vector<StrategicGoal> PrioritizeGoals(const GameState& state, PlayerId player) {
// Analyze current position and determine goal priorities
auto analysis = AnalyzePosition(state, player);
std::vector<StrategicGoal> goals;
if (analysis.isWinning) {
goals.push_back(SECURE_CASTLES);
goals.push_back(PROTECT_VIPS);
} else if (analysis.isLosing) {
goals.push_back(ELIMINATE_ENEMIES);
goals.push_back(CONTROL_CHOKEPOINTS);
} else {
// Balanced approach
goals.push_back(SECURE_CASTLES);
goals.push_back(ELIMINATE_ENEMIES);
}
return goals;
}
};
```
**Benefits**:
- Coherent multi-turn strategies
- Adaptive planning based on opponent actions
- Goal-oriented tactical decisions
#### 2.2 Positional Intelligence System
**Objective**: Add sophisticated positional evaluation
**Implementation**:
```cpp
class PositionalEvaluator {
public:
struct InfluenceMap {
std::vector<std::vector<double>> controlValues;
std::vector<std::vector<double>> threatValues;
std::vector<std::vector<double>> mobilityValues;
};
InfluenceMap CalculateInfluenceMap(const GameState& state, PlayerId player) {
InfluenceMap map(state.hex_map().width(), state.hex_map().height());
// Calculate control influence for each unit
for (const auto& unit : GetPlayerUnits(state, player)) {
AddUnitInfluence(map, unit, GetUnitThreatRange(unit));
}
// Add terrain modifiers
ApplyTerrainModifiers(map, state.hex_map());
return map;
}
double EvaluatePosition(const GameState& state, PlayerId player) {
auto influenceMap = CalculateInfluenceMap(state, player);
double controlScore = EvaluateBoardControl(influenceMap);
double chokepointScore = EvaluateChokepointControl(state, influenceMap);
double formationScore = EvaluateFormations(state, player);
double mobilityScore = EvaluateMobility(state, player);
return controlScore + chokepointScore + formationScore + mobilityScore;
}
private:
double EvaluateChokepointControl(const GameState& state,
const InfluenceMap& influence) {
double score = 0;
for (const auto& chokepoint : IdentifyChokepoints(state.hex_map())) {
if (influence.controlValues[chokepoint.x][chokepoint.y] > 0.5) {
score += kChokepointControlValue;
}
}
return score;
}
double EvaluateFormations(const GameState& state, PlayerId player) {
double score = 0;
auto units = GetPlayerUnits(state, player);
// Look for beneficial formations
for (size_t i = 0; i < units.size(); ++i) {
for (size_t j = i + 1; j < units.size(); ++j) {
score += CalculateFormationBonus(units[i], units[j]);
}
}
return score;
}
};
```
**Benefits**:
- Board control properly evaluated
- Chokepoint importance recognized
- Formation bonuses encouraged
- Terrain advantages captured
#### 2.3 Command Opportunity Cost Analysis
**Objective**: Evaluate what the AI gives up by choosing each command
**Implementation**:
```cpp
class OpportunityCostAnalyzer {
public:
struct CommandOpportunity {
Command command;
double directValue;
double opportunityCost;
double netValue;
};
std::vector<CommandOpportunity> AnalyzeCommands(
const GameState& state,
const std::vector<Command>& commands,
PlayerId player) {
std::vector<CommandOpportunity> opportunities;
for (const auto& command : commands) {
CommandOpportunity opp;
opp.command = command;
opp.directValue = EvaluateDirectValue(state, command);
opp.opportunityCost = CalculateOpportunityCost(state, command, commands);
opp.netValue = opp.directValue - opp.opportunityCost;
opportunities.push_back(opp);
}
return opportunities;
}
private:
double CalculateOpportunityCost(const GameState& state,
const Command& chosenCommand,
const std::vector<Command>& allCommands) {
double maxAlternativeValue = 0;
for (const auto& alternative : allCommands) {
if (alternative.unit_id() == chosenCommand.unit_id() &&
alternative != chosenCommand) {
double altValue = EvaluateDirectValue(state, alternative);
maxAlternativeValue = std::max(maxAlternativeValue, altValue);
}
}
// Also consider resource opportunity costs
double resourceCost = CalculateResourceOpportunityCost(chosenCommand);
return maxAlternativeValue + resourceCost;
}
double CalculateResourceOpportunityCost(const Command& command) {
// High-cost actions have higher opportunity cost
switch (command.command_type()) {
case METEOR_START: return 50; // Locks mage for multiple turns
case HOLY_WAVE: return 30; // High vigor cost
case MELEE: return 10; // Risk of casualties
default: return 0;
}
}
};
```
**Benefits**:
- Better resource management
- Reduced wasteful actions
- Improved action economy
### Phase 3: Advanced Intelligence
#### 3.1 Opponent Modeling System
**Objective**: Adapt strategy based on opponent behavior patterns
**Implementation**:
```cpp
class OpponentModel {
public:
enum PlayStyle {
AGGRESSIVE,
DEFENSIVE,
OPPORTUNISTIC,
UNPREDICTABLE
};
struct OpponentProfile {
PlayStyle style;
double aggressionLevel;
double riskTolerance;
std::map<std::string, double> tacticFrequency;
std::vector<Command> commonOpenings;
};
void UpdateModel(const std::vector<Command>& opponentMoves,
const GameState& resultingState) {
// Analyze opponent decision patterns
AnalyzeAggressionLevel(opponentMoves);
AnalyzeRiskTolerance(opponentMoves, resultingState);
UpdateTacticFrequency(opponentMoves);
}
std::vector<Command> PredictOpponentMoves(const GameState& state) {
auto profile = GetCurrentProfile();
// Weight potential moves by opponent's historical preferences
auto possibleMoves = GetOpponentPossibleMoves(state);
std::vector<Command> predictions;
for (const auto& move : possibleMoves) {
double probability = CalculateMoveProbability(move, profile);
if (probability > kPredictionThreshold) {
predictions.push_back(move);
}
}
return predictions;
}
void AdaptStrategy(StrategicPlan& plan, const OpponentProfile& profile) {
switch (profile.style) {
case AGGRESSIVE:
// Prepare strong defenses, look for counter-attacks
plan.primaryGoal = PROTECT_VIPS;
plan.secondaryGoal = ELIMINATE_ENEMIES;
break;
case DEFENSIVE:
// Apply pressure, force engagements
plan.primaryGoal = CONTROL_CHOKEPOINTS;
plan.secondaryGoal = SECURE_CASTLES;
break;
// ... other adaptations
}
}
};
```
**Benefits**:
- Adaptive strategy based on opponent type
- Prediction of opponent moves
- Counter-strategy development
#### 3.2 Machine Learning Integration Points
**Future Enhancement Areas**:
```cpp
class MLEnhancedEvaluator {
public:
// Neural network for position evaluation
double EvaluatePositionML(const GameState& state, PlayerId player) {
auto features = ExtractFeatures(state, player);
return neuralNetwork.Evaluate(features);
}
// Reinforcement learning for strategy selection
StrategicGoal SelectStrategyRL(const GameState& state,
const OpponentProfile& opponent) {
auto stateVector = EncodeGameState(state, opponent);
return strategyNetwork.SelectAction(stateVector);
}
// Opening book learned from successful games
Command GetOpeningMove(const GameState& state) {
auto position = HashPosition(state);
if (openingBook.contains(position)) {
return openingBook[position].bestMove;
}
return Command{}; // Fall back to regular evaluation
}
};
```
## Implementation Roadmap
### Phase 1 (3-4 weeks): Foundation
1. Implement ContextualUnitEvaluator
2. Create SpellEvaluator system
3. Add VictoryConditionEvaluator with game phase detection
4. Integrate into existing AIScoreCalculator
### Phase 2 (6-8 weeks): Strategic Layer
1. Build StrategicPlanner framework
2. Implement PositionalEvaluator with influence maps
3. Add OpportunityCostAnalyzer
4. Create goal-oriented command selection
### Phase 3 (8-12 weeks): Advanced Features
1. Develop OpponentModel system
2. Add prediction and adaptation mechanisms
3. Create ML integration points
4. Implement learning systems
## Expected Impact
### Immediate (Phase 1):
- **25-40% improvement** in tactical decision quality
- Better spell usage and timing
- More appropriate unit deployment
- Adaptive endgame strategy
### Medium-term (Phase 2):
- **50-75% improvement** in strategic coherence
- Multi-turn planning execution
- Superior positional play
- Efficient resource management
### Long-term (Phase 3):
- **AI competitive with strong human players**
- Adaptive learning from experience
- Opponent-specific strategies
- Novel tactical discoveries
## Testing and Validation
### Automated Testing:
- Unit tests for each evaluator component
- Integration tests with existing AI pipeline
- Performance regression testing
- Strategic scenario validation
### Human Testing:
- A/B testing against current AI
- Human expert evaluation sessions
- Tournament play against various skill levels
- Long-term learning validation
This comprehensive improvement plan would transform the Eagle0 AI from a competent but predictable opponent into a genuinely challenging strategic adversary that could provide engaging gameplay for both casual and expert players.
@@ -1,213 +0,0 @@
# Eagle0 AI Scoring System: Technical Documentation
## Overview
The Eagle0 AI scoring system is a sophisticated game state evaluation framework designed for the Shardok tactical combat layer. It uses a combination of immediate and lookahead scoring, handles both deterministic and non-deterministic commands, and employs different strategies for attackers and defenders.
## Architecture
### Main Entry Points
The `AIScoreCalculator` class provides four main entry points:
1. **`GuessedStateScore`** - Evaluates a game state based on the player's role (attacker/defender) and strategy
2. **`BestCommandIndex`** - Finds the best command from available options using lookahead search
3. **`CommandScore`** - Evaluates a specific command's score
4. **`EvaluateCommand`** - Lower-level command evaluation returning both immediate and lookahead scores
### Scoring Pipeline Flow
```
BestCommandIndex
├── AICommandFilter::FilterCommands (reduce search space)
├── For each filtered command:
│ ├── Determine command type (deterministic/non-deterministic/has odds)
│ ├── CalcOne (execute command with appropriate randomness)
│ │ ├── Create inner engine copy
│ │ ├── Execute command
│ │ ├── GuessedStateScore (immediate evaluation)
│ │ └── BasicLookaheadCalculator (recursive lookahead)
│ └── Aggregate scores based on command type
└── Select command with best lookahead score (tiebreak on immediate)
```
## Core Scoring Components
### 1. State Evaluation (`GuessedStateScore`)
The state scorer delegates to strategy-specific evaluators:
**Attacker Strategies:**
- `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
**Defender Strategies:**
- `STRATEGY_HOLD_CASTLES` - Defend critical castle positions
- `STRATEGY_SCATTER` - Spread units to avoid elimination
- `STRATEGY_FLEE` - Escape-focused scoring
### 2. Unit Value Calculation (`AIUnitScoreCalculator`)
Unit scores are computed using multiple factors:
**Base Unit Value:**
```cpp
battalionValue = battalionTypeMultiplier * (0.5 + armament/100) *
(0.5 + training/100) * (0.5 + morale/100) * battalion.size
heroValue = max(0, kHeroExistenceBuf + statsValue + professionValue + vigorValue)
contextFreeValue = battalionValue + heroValue
```
**Battalion Type Multipliers:**
- Light Infantry: 1.0
- Heavy Infantry/Light Cavalry: 1.5
- Heavy Cavalry: 2.0
- Longbowmen: 1.25
- Undead: 0.25
**Contextual Modifiers:**
- Castle bonus: `1 + kCastleMultiplierBonus * (integrity + 25) / 100`
- On fire penalty: 0.25x multiplier
- Adjacent fire: 0.99x per adjacent fire
- On ice penalty: Based on ice integrity
- VIP in danger: -200 if VIP unit < 200 size and in enemy attack range
**Special Unit Considerations:**
- Undead value decreases with distance from enemies: `value / (1 + minimumDistance)`
- Defenders that attackers must kill (when not targeting castles): +200 existence bonus
- Controlled undead this round: +50 bonus
### 3. Victory Condition Scoring (`AIVictoryConditionScoreCalculator`)
**Critical Tile Holdings:**
- Attacker holding tile with claimable unit: 0 penalty
- Castle on fire: -200 * distance debuff to extinguishing position
- Unoccupied/held by unclaimable: -100 * distance debuff
- Defender-held: Varies based on unit value and distance
**Last Player Standing:**
- -200 per surviving enemy unit * distance debuff
### 4. Distance-Based Scoring
The system uses sophisticated distance calculations incorporating:
- Action point distances (movement cost)
- Brave water crossing capability
- Attack location analysis (adjacent, archery, mage, engineer positions)
**Distance Debuff Formula:**
```cpp
distanceDebuff = kMaxProximityBuf / (1 + distance / kDistanceDebufRatio)
where kMaxProximityBuf = 1.5, kDistanceDebufRatio = 8.0
```
## Command Type Handling
### Deterministic Commands
Commands with predictable outcomes (MOVE, CONTROL, END_TURN, etc.):
- Evaluated once with average random value (0.5)
- No repeated simulations needed
### Commands with Odds
Commands with success/failure chances (SCOUT, FEAR, etc.):
- Two evaluations: success case (high roll) and failure case (low roll)
- Final score: `lerp(failureScore, successScore, successChance)`
- Success roll: `1.0 - successChance/2`
- Failure roll: `(1.0 - successChance)/2`
### Non-Deterministic Commands
Commands with variable outcomes (MELEE, ARCHERY, etc.):
- Multiple evaluations with different random seeds
- Default: `maxRepeatCount` iterations (typically 3-5)
- Random values evenly distributed: `i / (maxRepeatCount - 1)`
- Final score: average of all evaluations
## Lookahead Search
The system uses recursive lookahead with:
- Configurable depth (`remainingLookahead` parameter)
- Asynchronous execution for parallelization
- Early termination on END_TURN commands
- Score propagation from future states
## Command Filtering
`AICommandFilter` reduces search space by eliminating obviously bad moves:
**Filtered Actions:**
- Meteor start when >4 hexes from enemies AND castles (attackers only)
- Fire spells not adjacent to enemies (attackers only)
- Fortify when far from objectives (attackers)
- Retreating/fleeing when winning
- Moving away from all enemies when outnumbered
- Abandoning last defender in critical castle
## Key Constants and Multipliers
### Unit Scoring
- `UNITS_BASE_MULTIPLIER`: 0.05
- `FLEE_UNIT_SCORE`: -10,000
- `CAPTURED_UNIT_SCORE`: -10,000
- `CAPTURED_VIP_SCORE`: -25,000
- `kHeroExistenceBuf`: 50
- `kProfessionValue`: 200
### Ranged Attack Values
- `kArcheryPossibleValue`: 38
- `kMeteorDirectTargetingEnemy`: 2 per soldier
- `kMeteorSplashTargetingEnemy`: 1 per soldier
- `kLightningPossibleValue`: 0.05 per soldier
### Victory Condition Values
- `MAX_DEFENDER_HELD_VALUE`: -1,200
- `UNHELD_VALUE`: 100
- `ON_FIRE_VALUE`: 200
- `SURVIVING_ENEMY_VALUE`: -200
## Score Aggregation
Final score calculation:
```cpp
score = UNITS_BASE_MULTIPLIER * roundsMultiplier * unitsTotal + victoryConditionTotal
```
Where:
- `roundsMultiplier = roundsRemaining / maxRounds`
- `unitsTotal` = sum of all unit values (attacker positive, defender negative)
- `victoryConditionTotal` = sum of victory condition scores
## Performance Optimizations
1. **Command Filtering**: Reduces search space by 30-70% on average
2. **Parallel Lookahead**: Async execution of future state evaluations
3. **Cached Distance Calculations**: ActionPointDistances and AttackLocations caching
4. **Early Game/Late Game Differentiation**: Simplified calculations after round 18
5. **Multithreading**: Controlled by `MULTITHREAD` compile flag
## Implementation Notes
### Random Number Generation
- Uses `SequenceRandomGenerator` for deterministic testing
- Multiple random seeds for non-deterministic command evaluation
- Carefully controlled randomness for consistent AI behavior
### Distance Calculations
- **Action Point Distances**: Accounts for movement costs, terrain, water crossing
- **Attack Locations**: Pre-computed valid attack positions for units
- **Caching**: Expensive distance calculations are cached and reused
### Strategy Selection
- Attackers use `AIAttackerStrategySelector` to choose appropriate strategy
- Defenders use `AIDefenderStrategySelector` based on game state
- Strategy affects unit valuations and objective prioritization
### Score Interpretation
- **Positive scores**: Favor the evaluating player
- **Negative scores**: Favor the opponent
- **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.
@@ -121,25 +121,6 @@ cc_library(
],
)
cc_library(
name = "ai_command_filter",
srcs = ["AICommandFilter.cpp"],
hdrs = ["AICommandFilter.hpp"],
copts = COPTS,
visibility = [
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
deps = [
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//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/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
],
)
cc_library(
name = "ai_score_calculator",
srcs = ["AIScoreCalculator.cpp"],
@@ -150,7 +131,6 @@ cc_library(
],
deps = [
":ai_attacker_strategy_selector",
":ai_command_filter",
":ai_unit_score_calculator",
":ai_victory_condition_score_calculator",
"//src/main/cpp/net/eagle0/common:sequence_random_generator",
@@ -240,44 +220,6 @@ cc_library(
],
)
cc_library(
name = "ai_time_budget",
srcs = ["AITimeBudget.cpp"],
hdrs = ["AITimeBudget.hpp"],
copts = COPTS,
visibility = [
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
deps = [
"//src/main/cpp/net/eagle0/shardok/library/fb_helpers:flatbuffer_wrapper",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_cube_utils",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
],
)
cc_library(
name = "ai_iterative_deepening",
srcs = ["IterativeDeepeningAI.cpp"],
hdrs = ["IterativeDeepeningAI.hpp"],
copts = COPTS,
visibility = [
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
deps = [
":ai_attacker_strategy_selector",
":ai_defender_strategy_selector",
":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/library:engine",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
],
)
cc_library(
name = "shardok_ai_client",
srcs = ["ShardokAIClient.cpp"],
@@ -287,9 +229,7 @@ cc_library(
deps = [
":ai_attacker_strategy_selector",
":ai_defender_strategy_selector",
":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/library/action_point_distances",
@@ -1,320 +0,0 @@
//
// Created by Dan Crosby on 07/04/25.
//
#include "IterativeDeepeningAI.hpp"
#include <algorithm>
#include <numeric>
#include <utility>
#include "AIAttackerStrategySelector.hpp"
#include "AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/common/TimeUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
namespace shardok {
#define DEBUG_ITERATIVE_DEEPENING_TIMINGS 1
IterativeDeepeningAI::IterativeDeepeningAI(
const PlayerId playerId,
const bool isDefender,
AIStrategy strategy,
const CoordsSet& castleCoords,
const APDCache& apdCache,
const ALCache& alCache)
: playerId(playerId),
isDefender(isDefender),
strategy(std::move(strategy)),
castleCoords(castleCoords),
apdCache(apdCache),
alCache(alCache) {}
auto IterativeDeepeningAI::IterativeSearch(
const GameSettingsSPtr& settings,
const GameStateW& state,
const std::vector<CommandProto>& commands,
const AITimeBudget& initialBudget) const -> SearchResult {
// Make a mutable copy of the time budget to track remaining time
AITimeBudget timeBudget = initialBudget;
const auto startTime = std::chrono::steady_clock::now();
SearchResult result;
if (commands.empty()) {
#if DEBUG_ITERATIVE_DEEPENING_TIMINGS
printf("ID AI: Commands are empty, returning early\n");
#endif
result.searchCompleted = true;
return result;
}
// Calculate current utility and create engine once for all command evaluations
const auto& settingsGetter = settings->GetGetter();
const auto guessedEngine = ShardokEngine(settings, state);
const auto maxRepeatCount = settingsGetter.Backing().ai_utility_repeat_count();
const ScoreValue currentUtility = AIScoreCalculator::GuessedStateScore(
isDefender,
state,
strategy,
castleCoords,
settingsGetter,
apdCache,
alCache);
// Step 1: Evaluate ALL commands at depth 1 - reuse vector to reduce allocations
reusableDepth1Results.clear();
reusableDepth1Results.reserve(commands.size());
for (size_t i = 0; i < commands.size(); ++i) {
if (IsTimeExpired(timeBudget)) {
#if DEBUG_ITERATIVE_DEEPENING_TIMINGS
printf("ID AI: Time expired during depth 1 evaluation at command %zu\n", i);
#endif
break;
}
auto commandResult = SearchCommandAtDepthWithEngine(
guessedEngine,
settingsGetter,
maxRepeatCount,
commands,
i,
1,
currentUtility,
timeBudget);
reusableDepth1Results.push_back(commandResult);
}
if (reusableDepth1Results.empty()) {
#if DEBUG_ITERATIVE_DEEPENING_TIMINGS
printf("ID AI: No depth 1 results, returning default\n");
#endif
result.searchCompleted = false;
return result;
}
// Step 2: Sort commands by depth-1 scores (highest first) - reuse vector
reusableSortedIndices.clear();
reusableSortedIndices.resize(reusableDepth1Results.size());
std::iota(reusableSortedIndices.begin(), reusableSortedIndices.end(), 0);
std::ranges::sort(reusableSortedIndices, [this](const size_t a, const size_t b) {
return reusableDepth1Results[a].bestScore > reusableDepth1Results[b].bestScore;
});
// Best result starts with the highest-scoring depth-1 command - use reference to avoid copy
const SearchResult& bestDepth1ResultRef = reusableDepth1Results[reusableSortedIndices[0]];
SearchResult bestDepth1Result = bestDepth1ResultRef; // Copy only when we need to modify
bestDepth1Result.depthAchieved = 1;
bestDepth1Result.minimumDepthCompleted = 1 >= timeBudget.minDepthRequired;
// Step 3: Selectively evaluate individual commands at depth 2
int depth2CompletedCount = 0;
SearchResult bestDepth2Result;
bestDepth2Result.searchCompleted = false;
for (const unsigned long commandIndex : reusableSortedIndices) {
if (IsTimeExpired(timeBudget)) { break; }
auto depth2Result = SearchCommandAtDepthWithEngine(
guessedEngine,
settingsGetter,
maxRepeatCount,
commands,
commandIndex,
2,
currentUtility,
timeBudget);
depth2CompletedCount++;
// Keep the best depth-2 result
if (depth2CompletedCount == 1 || (depth2Result.bestScore > bestDepth2Result.bestScore)) {
depth2Result.depthAchieved = 2;
depth2Result.minimumDepthCompleted = 2 >= timeBudget.minDepthRequired;
bestDepth2Result = depth2Result;
}
}
printf("ID AI: Successfully completed %d depth-2 evaluations (out of %lu commands)\n",
depth2CompletedCount,
commands.size());
// Step 4: Choose final result
if (depth2CompletedCount) {
#if DEBUG_ITERATIVE_DEEPENING_TIMINGS
if (bestDepth2Result.bestCommandIndex != bestDepth1Result.bestCommandIndex) {
printf("ID AI: Best depth-2 command differs from depth-1: \n");
printf(" depth-2 command %zu, depth-1 command %zu\n",
bestDepth2Result.bestCommandIndex,
bestDepth1Result.bestCommandIndex);
printf("depth 1 command: %s\n",
commands[bestDepth1Result.bestCommandIndex].DebugString().c_str());
printf("depth 2 command: %s\n",
commands[bestDepth2Result.bestCommandIndex].DebugString().c_str());
}
#endif
result = bestDepth2Result;
} else {
result = bestDepth1Result; // Fall back to best depth-1 result
#if DEBUG_ITERATIVE_DEEPENING_TIMINGS
printf("ID AI: Falling back to best depth-1 result\n");
#endif
}
result.searchCompleted = result.minimumDepthCompleted;
result.timeUsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - startTime);
return result;
}
auto IterativeDeepeningAI::SearchAtDepth(
const GameSettingsSPtr& settings,
const GameStateW& state,
const std::vector<CommandProto>& commands,
const int depth) const -> SearchResult {
SearchResult result;
#if DEBUG_ITERATIVE_DEEPENING_TIMINGS
printf("SearchAtDepth: depth=%d, commands=%zu\n", depth, commands.size());
#endif
if (commands.empty()) {
result.searchCompleted = true;
return result;
}
const auto& settingsGetter = settings->GetGetter();
const auto guessedEngine = ShardokEngine(settings, state);
const auto maxRepeatCount = settingsGetter.Backing().ai_utility_repeat_count();
const ScoreValue currentUtility = AIScoreCalculator::GuessedStateScore(
isDefender,
state,
strategy,
castleCoords,
settingsGetter,
apdCache,
alCache);
// Perform search at specified depth
const auto indexAndScore = AIScoreCalculator::BestCommandIndex(
playerId,
isDefender,
depth, // Use the specified depth for lookahead
maxRepeatCount,
guessedEngine,
strategy,
currentUtility,
settingsGetter,
castleCoords,
apdCache,
alCache);
result.bestCommandIndex = indexAndScore.index;
result.bestScore = indexAndScore.lookaheadScore;
result.searchCompleted = true;
return result;
}
bool IterativeDeepeningAI::IsTimeExpired(const AITimeBudget& budget) {
return budget.remainingBudget <= std::chrono::milliseconds(0);
}
auto IterativeDeepeningAI::SearchAllCommandsAtDepth(
const GameSettingsSPtr& settings,
const GameStateW& state,
const std::vector<CommandProto>& commands,
const int depth) const -> std::vector<SearchResult> {
// Use SearchAtDepth to get the best overall result
const auto bestResult = SearchAtDepth(settings, state, commands, depth);
std::vector<SearchResult> results;
results.reserve(commands.size());
for (size_t i = 0; i < commands.size(); ++i) {
SearchResult result;
result.bestCommandIndex = i;
result.depthAchieved = depth;
result.searchCompleted = true;
result.minimumDepthCompleted = true;
// For the best command, use the actual score
// For others, use a slightly lower score (this is a simplification for Phase 2)
if (i == bestResult.bestCommandIndex) {
result.bestScore = bestResult.bestScore;
} else {
result.bestScore = bestResult.bestScore * 0.95; // Slightly lower but reasonable
}
results.push_back(result);
}
return results;
}
auto IterativeDeepeningAI::SearchCommandAtDepthWithEngine(
const ShardokEngine& guessedEngine,
const GameSettings::Getter& settingsGetter,
const int maxRepeatCount,
const std::vector<CommandProto>& commands,
const size_t commandIndex,
const int depth,
const ScoreValue currentUtility,
AITimeBudget& timeBudget) const -> SearchResult {
SearchResult result;
result.bestCommandIndex = commandIndex;
result.depthAchieved = depth;
result.searchCompleted = true;
result.minimumDepthCompleted = true;
if (commandIndex >= commands.size()) {
result.bestScore = 0.0;
return result;
}
try {
// Track concurrent evaluations and adjust time accounting
AIEvaluationCounter counter;
const auto startTime = std::chrono::steady_clock::now();
// Use CommandScore to evaluate the specific command at the given depth
const auto commandScore = AIScoreCalculator::CommandScore(
playerId,
isDefender,
depth,
maxRepeatCount,
guessedEngine,
strategy,
currentUtility,
settingsGetter,
castleCoords,
apdCache,
alCache,
commandIndex);
// Calculate time used and adjust based on concurrent evaluations
const auto elapsed = std::chrono::steady_clock::now() - startTime;
const int concurrentCount = counter.GetCurrentCount();
const auto adjustedElapsed = elapsed / std::max(1, concurrentCount);
const auto adjustedElapsedMs =
std::chrono::duration_cast<std::chrono::milliseconds>(adjustedElapsed);
// Deduct adjusted time from remaining budget
timeBudget.remainingBudget -= adjustedElapsedMs;
result.bestScore = commandScore;
} catch (const std::exception& e) {
// If evaluation fails, return a neutral score rather than crashing
#if DEBUG_ITERATIVE_DEEPENING_TIMINGS
printf("SearchCommandAtDepthWithEngine: evaluation failed with exception: %s\n", e.what());
#endif
result.bestScore = 0.0;
}
return result;
}
} // namespace shardok
@@ -1,98 +0,0 @@
//
// Created by Dan Crosby on 07/04/25.
//
#ifndef EAGLE0_ITERATIVEDEEPENINGAI_HPP
#define EAGLE0_ITERATIVEDEEPENINGAI_HPP
#include <chrono>
#include <vector>
#include "AIStrategy.hpp"
#include "AITimeBudget.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.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/util/HexMapUtils.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
namespace shardok {
// Forward declarations
class ShardokEngine;
using ScoreValue = double;
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
class IterativeDeepeningAI {
public:
struct SearchResult {
size_t bestCommandIndex;
ScoreValue bestScore;
int depthAchieved;
std::chrono::milliseconds timeUsed;
bool minimumDepthCompleted;
bool searchCompleted;
SearchResult()
: bestCommandIndex(0),
bestScore(0),
depthAchieved(0),
timeUsed(0),
minimumDepthCompleted(false),
searchCompleted(false) {}
};
IterativeDeepeningAI(
PlayerId playerId,
bool isDefender,
AIStrategy strategy,
const CoordsSet& castleCoords,
const APDCache& apdCache,
const ALCache& alCache);
[[nodiscard]] SearchResult IterativeSearch(
const GameSettingsSPtr& settings,
const GameStateW& state,
const std::vector<CommandProto>& commands,
const AITimeBudget& timeBudget) const;
private:
PlayerId playerId;
bool isDefender;
AIStrategy strategy;
CoordsSet castleCoords;
const APDCache& apdCache;
const ALCache& alCache;
// Reusable vectors to reduce memory allocations
mutable std::vector<SearchResult> reusableDepth1Results;
mutable std::vector<size_t> reusableSortedIndices;
[[nodiscard]] SearchResult SearchAtDepth(
const GameSettingsSPtr& settings,
const GameStateW& state,
const std::vector<CommandProto>& commands,
int depth) const;
[[nodiscard]] static bool IsTimeExpired(const AITimeBudget& budget);
[[nodiscard]] std::vector<SearchResult> SearchAllCommandsAtDepth(
const GameSettingsSPtr& settings,
const GameStateW& state,
const std::vector<CommandProto>& commands,
int depth) const;
[[nodiscard]] SearchResult SearchCommandAtDepthWithEngine(
const ShardokEngine& guessedEngine,
const GameSettings::Getter& settingsGetter,
int maxRepeatCount,
const std::vector<CommandProto>& commands,
size_t commandIndex,
int depth,
ScoreValue currentUtility,
AITimeBudget& timeBudget) const;
};
} // namespace shardok
#endif // EAGLE0_ITERATIVEDEEPENINGAI_HPP
@@ -12,16 +12,13 @@
#include "AIAttackerStrategySelector.hpp"
#include "AIDefenderStrategySelector.hpp"
#include "AITimeBudget.hpp"
#include "IterativeDeepeningAI.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"
#include "src/main/protobuf/net/eagle0/shardok/api/action_result_view.pb.h"
namespace shardok {
static constexpr bool kDebugTimings = true;
const static bool kDebugTimings = false;
using net::eagle0::shardok::api::ActionResultView;
using net::eagle0::shardok::api::GameStateView;
@@ -66,20 +63,13 @@ auto ShardokAIClient::StandardChooseCommandIndex(
const vector<CommandProto> &realAvailableCommands) const -> size_t {
const auto settingsGetter = settings->GetGetter();
const auto guessedEngine = ShardokEngine(settings, guessedState);
// Calculate time budget based on game situation using new settings
const auto timeBudget = CalculateTimeBudget(playerId, settings, guessedState);
const auto castleCoords = AllCastleCoords(guessedState->hex_map());
const auto maxLookahead = settingsGetter.Backing().max_lookahead_turns();
const auto maxRepeatCount = settingsGetter.Backing().ai_utility_repeat_count();
const auto guessedCommands = guessedEngine.GetAvailableCommandProtos(playerId, false);
const auto commandCount = guessedCommands.size();
assert(commandCount == realAvailableCommands.size());
for (int i = 0; i < commandCount; i++) {
CheckCommand(realAvailableCommands[i], guessedCommands[i]);
}
// Determine strategy once for consistent scoring throughout iterative deepening
const auto castleCoords = AllCastleCoords(guessedState->hex_map());
const AIStrategy strategy = isDefender ? AIDefenderStrategySelector::BestDefenderStrategy(
guessedState,
castleCoords,
@@ -95,21 +85,42 @@ auto ShardokAIClient::StandardChooseCommandIndex(
waterCrossingCommandChooser,
realAvailableCommands);
// 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);
assert(commandCount == realAvailableCommands.size());
for (int i = 0; i < commandCount; i++) {
CheckCommand(realAvailableCommands[i], guessedCommands[i]);
}
return search_result.bestCommandIndex;
const ScoreValue currentUtility = AIScoreCalculator::GuessedStateScore(
isDefender,
guessedState,
strategy,
castleCoords,
settingsGetter,
apdCache,
alCache);
return AIScoreCalculator::BestCommandIndex(
playerId,
isDefender,
maxLookahead,
maxRepeatCount,
guessedEngine,
strategy,
currentUtility,
settingsGetter,
castleCoords,
apdCache,
alCache)
.index;
}
auto ShardokAIClient::LateRoundAttackerChooseCommandIndex(
const GameSettingsSPtr &settings,
const GameStateW &guessedState,
const vector<CommandProto> &realAvailableCommands) const -> size_t {
if (const auto dismissCommand = std::ranges::find_if(
realAvailableCommands,
if (const auto dismissCommand = std::find_if(
realAvailableCommands.begin(),
realAvailableCommands.end(),
[](const net::eagle0::shardok::api::CommandDescriptor &cmd) {
return cmd.type() == net::eagle0::shardok::common::DISMISS_UNIT_COMMAND;
});
@@ -124,8 +135,9 @@ auto ShardokAIClient::FinalRoundAttackerChooseCommandIndex(
const GameSettingsSPtr &settings,
const GameStateW &guessedState,
const vector<CommandProto> &realAvailableCommands) const -> size_t {
if (const auto fleeCommand = std::ranges::find_if(
realAvailableCommands,
if (const auto fleeCommand = std::find_if(
realAvailableCommands.begin(),
realAvailableCommands.end(),
[](const net::eagle0::shardok::api::CommandDescriptor &cmd) {
return cmd.type() == net::eagle0::shardok::common::FLEE_COMMAND;
});
@@ -171,8 +183,8 @@ auto ShardokAIClient::ChooseCommandIndex(
}
}
std::ranges::sort(choices);
std::ranges::reverse(choices);
std::sort(choices.begin(), choices.end());
std::reverse(choices.begin(), choices.end());
for (const auto &[index, choice] : choices) {
printf("%5d %s\n", index, CommandType_Name(choice).c_str());
}
@@ -13,7 +13,6 @@
#include "src/main/cpp/net/eagle0/common/RandomGenerator.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"
#include "src/main/protobuf/net/eagle0/shardok/api/game_state_view.pb.h"
@@ -4,7 +4,6 @@
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistances.hpp"
#include <queue>
#include <utility>
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/HexMapHelpers.hpp"
@@ -32,22 +31,23 @@ auto ActionPointDistances::BraveWaterPossibleCoords(const HexMap* hexMap) const
std::shared_ptr<BraveableTileInfo> info = std::make_shared<BraveableTileInfo>(hexMap);
CoordsSet& braveWaterPossibleCoords = info->cs;
const int indexCount = hexMap->row_count() * hexMap->column_count();
const auto indexToCoords = CreateIndexToCoords(hexMap);
for (int i = 0; i < indexCount; i++) {
if (const Terrain* terrain = hexMap->terrain()->Get(i);
IsWater(terrain->type()) && !terrain->modifier().bridge().present() &&
const Terrain* terrain = hexMap->terrain()->Get(i);
if (IsWater(terrain->type()) && !terrain->modifier().bridge().present() &&
!terrain->modifier().ice().present()) {
const Coords& currentCoords = indexToCoords[i];
const Coords currentCoords =
Coords(int8_t(i / hexMap->column_count()), int8_t(i % hexMap->column_count()));
const CoordsSet adjacentToWaterCoords =
HexMapUtils::GetAdjacentCoords(hexMap, currentCoords);
CoordsSet braveableTo(hexMap);
for (const Coords& adjacentToWater : adjacentToWaterCoords) {
for (auto acrossWaterCoords = GetTilesAcrossWater(hexMap, adjacentToWater);
const auto& braveCoords : acrossWaterCoords) {
auto acrossWaterCoords = GetTilesAcrossWater(hexMap, adjacentToWater);
for (const auto& braveCoords : acrossWaterCoords) {
const auto braveIndex = ToIndex(braveCoords);
if (const auto* swimTerrain = hexMap->terrain()->Get(braveIndex);
IsWater(swimTerrain->type()) ||
const auto* swimTerrain = hexMap->terrain()->Get(braveIndex);
if (IsWater(swimTerrain->type()) ||
swimTerrain->type() ==
net::eagle0::shardok::storage::fb::Terrain_::Type_MOUNTAIN) {
continue;
@@ -68,86 +68,46 @@ auto ActionPointDistances::BraveWaterPossibleCoords(const HexMap* hexMap) const
return info;
}
auto ActionPointDistances::CreateIndexToCoords(const HexMap* hexMap) -> vector<Coords> {
const int8_t columnCount = hexMap->column_count();
const int indexCount = hexMap->row_count() * columnCount;
vector<Coords> indexToCoords;
indexToCoords.reserve(indexCount);
for (int i = 0; i < indexCount; i++) {
indexToCoords.emplace_back(
static_cast<int8_t>(i / columnCount),
static_cast<int8_t>(i % columnCount));
}
return indexToCoords;
}
auto ActionPointDistances::CreateAdjacencyTable(const HexMap* hexMap)
-> vector<std::array<int, 6>> {
const int8_t columnCount = hexMap->column_count();
const int indexCount = hexMap->row_count() * columnCount;
vector<std::array<int, 6>> adjacencyTable;
adjacencyTable.reserve(indexCount);
for (int i = 0; i < indexCount; i++) {
const Coords coords(
static_cast<int8_t>(i / columnCount),
static_cast<int8_t>(i % columnCount));
const CoordsSet adjacentCoords = HexMapUtils::GetAdjacentCoords(hexMap, coords);
std::array<int, 6> neighbors{};
neighbors.fill(-1); // -1 indicates invalid/no neighbor
int neighborIdx = 0;
for (const auto adjacentIndex : adjacentCoords.indexIterator()) {
if (neighborIdx < 6) { neighbors[neighborIdx++] = static_cast<int>(adjacentIndex); }
}
adjacencyTable.push_back(neighbors);
}
return adjacencyTable;
}
void ActionPointDistances::PopulateOne(
vector<DIST_T>& ds,
const HexMap* hexMap,
const bool includeBravingWater,
const int braveWaterCost,
const BattalionTypeSPtr& battalionType,
const std::shared_ptr<BraveableTileInfo>& braveWaterPossibleCoords,
const vector<Coords>& indexToCoords,
const vector<std::array<int, 6>>& adjacencyTable) {
const std::shared_ptr<BraveableTileInfo>& braveableTileInfo) {
vector<uint8_t> visited(ds.size());
// Priority queue for efficient minimum selection: {distance, index}
std::priority_queue<std::pair<DIST_T, int>, std::vector<std::pair<DIST_T, int>>, std::greater<>>
pq;
const int indexCount = hexMap->row_count() * hexMap->column_count();
// Find starting index (the one with distance 0)
for (int i = 0; i < static_cast<int>(ds.size()); i++) {
if (ds[i] == 0) {
pq.emplace(0, i);
break;
}
}
int firstUnvisitedIndex = 0;
while (true) {
// Choose the current index
int currentIndex = -1;
int currentIndexDistance = IMPOSSIBLE;
while (!pq.empty()) {
auto [currentIndexDistance, currentIndex] = pq.top();
pq.pop();
// Skip if already visited (can happen due to multiple insertions)
if (visited[currentIndex]) continue;
// Skip if we found a better path since insertion
if (currentIndexDistance > ds[currentIndex]) continue;
const Coords& currentCoords = indexToCoords[currentIndex];
visited[currentIndex] = true;
// Prefetch terrain data for all neighbors to reduce memory stalls
const std::array<int, 6>& neighbors = adjacencyTable[currentIndex];
for (int i = 0; i < 6 && neighbors[i] != -1; i++) {
__builtin_prefetch(hexMap->terrain()->Get(neighbors[i]), 0, 3);
bool foundUnvisited = false;
for (int toIndex = firstUnvisitedIndex; toIndex < indexCount; toIndex++) {
if (!visited[toIndex]) {
if (!foundUnvisited) {
firstUnvisitedIndex = toIndex;
foundUnvisited = true;
}
if (ds[toIndex] != IMPOSSIBLE &&
(currentIndex == -1 || ds[toIndex] < currentIndexDistance)) {
currentIndex = toIndex;
currentIndexDistance = ds[toIndex];
}
}
}
for (int adjacentIndex : neighbors) {
if (adjacentIndex == -1) break; // End of valid neighbors
if (currentIndex == -1 || currentIndexDistance == IMPOSSIBLE) return;
const Coords currentCoords =
Coords(int8_t(currentIndex / hexMap->column_count()),
int8_t(currentIndex % hexMap->column_count()));
const CoordsSet adjacentCoords = HexMapUtils::GetAdjacentCoords(hexMap, currentCoords);
for (const auto adjacentIndex : adjacentCoords.indexIterator()) {
if (visited[adjacentIndex]) continue;
const auto adjacentTerrain = hexMap->terrain()->Get(adjacentIndex);
@@ -156,47 +116,40 @@ void ActionPointDistances::PopulateOne(
if (adjacentCost.type == ActionCost::impossible) continue;
const auto costThroughCurrentTile = currentIndexDistance + adjacentCost.points;
if (const int currentBestDistance = ds[adjacentIndex];
currentBestDistance > costThroughCurrentTile) {
const int currentBestDistance = ds[adjacentIndex];
if (currentBestDistance > costThroughCurrentTile) {
ds[adjacentIndex] = static_cast<DIST_T>(costThroughCurrentTile);
// Add to priority queue for future processing
pq.emplace(ds[adjacentIndex], adjacentIndex);
}
}
// check for swimmable tiles
if (includeBravingWater && battalionType->allowsBraveWater &&
braveWaterPossibleCoords->cs.Contains(currentCoords)) {
auto entry = std::ranges::find_if(
braveWaterPossibleCoords->details,
braveableTileInfo->cs.Contains(currentCoords)) {
auto entry = std::find_if(
braveableTileInfo->details.begin(),
braveableTileInfo->details.end(),
[currentCoords](const BraveableTileInfo::BraveableFromInfo& from) {
return from.from == currentCoords;
});
if (entry != braveWaterPossibleCoords->details.end()) {
// Prefetch terrain data for water braving targets
for (const auto braveIndex : entry->to.indexIterator()) {
__builtin_prefetch(hexMap->terrain()->Get(braveIndex), 0, 3);
}
if (entry != braveableTileInfo->details.end()) {
for (const auto braveIndex : entry->to.indexIterator()) {
if (visited[braveIndex]) continue;
const auto* swimTerrain = hexMap->terrain()->Get(braveIndex);
const auto adjacentCost = battalionType->GetCostToEnterTerrain(swimTerrain);
if (const auto adjacentCost = battalionType->GetCostToEnterTerrain(swimTerrain);
adjacentCost.type == ActionCost::impossible)
continue;
if (adjacentCost.type == ActionCost::impossible) continue;
const auto costThroughCurrentTile = currentIndexDistance + braveWaterCost;
if (const int currentBestDistance = ds[braveIndex];
currentBestDistance > costThroughCurrentTile) {
ds[braveIndex] = static_cast<DIST_T>(costThroughCurrentTile);
// Add to priority queue for future processing
pq.push({ds[braveIndex], braveIndex});
const int currentBestDistance = ds[braveIndex];
if (currentBestDistance > costThroughCurrentTile) {
ds[braveIndex] = DIST_T(costThroughCurrentTile);
}
}
}
}
visited[currentIndex] = true;
}
}
@@ -213,19 +166,13 @@ auto ActionPointDistances::GenerateDistances(
ds[fromIndex] = 0;
if (!includeBravingWater || battalionType->allowsBraveWater) {
// Create lookup tables once per distance calculation
const auto indexToCoords = CreateIndexToCoords(hexMap);
const auto adjacencyTable = CreateAdjacencyTable(hexMap);
PopulateOne(
ds,
hexMap,
includeBravingWater,
braveWaterCost,
battalionType,
braveWaterPossibleCoords,
indexToCoords,
adjacencyTable);
braveWaterPossibleCoords);
}
return ds;
@@ -239,7 +186,7 @@ OnDemandActionPointDistances::OnDemandActionPointDistances(
: ActionPointDistances(map->column_count()),
hexMap(fb::CopyHexMap(map)),
battalionType(std::move(battTp)),
distances(static_cast<size_t>(map->row_count() * map->column_count())) {
distances(map->row_count() * map->column_count()) {
const int indexCount = map->row_count() * map->column_count();
distances.resize(indexCount);
@@ -249,6 +196,7 @@ OnDemandActionPointDistances::OnDemandActionPointDistances(
distances[fromIndex] = std::async(
std::launch::deferred,
&OnDemandActionPointDistances::GenerateDistances,
this,
fromIndex,
hexMap,
includeBravingWater,
@@ -6,7 +6,9 @@
#define EAGLE0_ACTIONPOINTDISTANCES_HPP
#include <future>
#include <map>
#include <optional>
#include <utility>
#include "src/main/cpp/net/eagle0/shardok/library/BattalionType.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/FlatbufferWrapper.hpp"
@@ -34,30 +36,22 @@ private:
protected:
struct BraveableTileInfo;
static void PopulateOne(
void PopulateOne(
vector<DIST_T> &ds,
const net::eagle0::shardok::storage::fb::HexMap *hexMap,
bool includeBravingWater,
int braveWaterCost,
const BattalionTypeSPtr &battalionType,
const std::shared_ptr<BraveableTileInfo> &braveWaterPossibleCoords,
const vector<Coords> &indexToCoords,
const vector<std::array<int, 6>> &adjacencyTable);
static auto GenerateDistances(
const std::shared_ptr<BraveableTileInfo> &braveWaterPossibleCoords);
auto GenerateDistances(
int fromIndex,
const HexMap *hexMap,
bool includeBravingWater,
int braveWaterCost,
const BattalionTypeSPtr &battalionType,
const std::shared_ptr<BraveableTileInfo> &braveWaterPossibleCoords) -> vector<DIST_T>;
const std::shared_ptr<BraveableTileInfo> &braveableTileInfo) -> vector<DIST_T>;
auto BraveWaterPossibleCoords(const HexMap *hexMap) const -> std::shared_ptr<BraveableTileInfo>;
// Create coordinate lookup table for efficient index->coords conversion
static auto CreateIndexToCoords(const HexMap *hexMap) -> vector<Coords>;
// Create adjacency lookup table for efficient neighbor access
static auto CreateAdjacencyTable(const HexMap *hexMap) -> vector<std::array<int, 6>>;
[[nodiscard]] auto ToIndex(const Coords &coords) const -> int {
return coords.row() * column_count + coords.column();
}
@@ -74,7 +68,7 @@ public:
virtual auto Distance(const Coords &from, const Coords &to) -> DIST_T = 0;
};
class OnDemandActionPointDistances final : public ActionPointDistances {
class OnDemandActionPointDistances : public ActionPointDistances {
private:
const HexMapW hexMap;
const BattalionTypeSPtr battalionType;
@@ -89,11 +83,11 @@ private:
public:
explicit OnDemandActionPointDistances(
const HexMap *map,
BattalionTypeSPtr battTp,
BattalionTypeSPtr battalionType,
bool includeBravingWater,
int braveWaterActionPointCost = -1);
~OnDemandActionPointDistances() override = default;
~OnDemandActionPointDistances() override{};
auto Distance(const int fromIndex, const int toIndex) -> int16_t override {
return distances[fromIndex].get()[toIndex];
@@ -4,32 +4,14 @@
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include <unordered_map>
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/HexMapHasher.hpp"
#define CACHE_STATS_LOGGING_ false
namespace shardok {
// Thread-local cache to avoid mutex contention for repeated lookups
thread_local std::unordered_map<FullCacheKey, shared_ptr<ActionPointDistances>, FullCacheKeyHash>
localCache;
#if CACHE_STATS_LOGGING_
// Thread-local statistics for performance monitoring
thread_local struct {
int localHits = 0;
int localMisses = 0;
int sharedAccesses = 0;
int evictionEvents = 0;
} cacheStats;
#endif
class BadHashException : public std::exception {
public:
BadHashException() = default;
BadHashException() : std::exception() {}
[[nodiscard]] auto what() const noexcept -> const char* override { return "Bad map hash!"; };
};
@@ -54,82 +36,15 @@ auto ActionPointDistancesCache::Get(
const BattalionTypeSPtr& battalionType,
const bool includeBravingWater,
const int braveWaterActionPointCost) -> std::shared_ptr<ActionPointDistances> {
// Create full cache key for thread-local lookup
FullCacheKey fullKey{
.mapId = mapId,
.battalionTypeId = battalionType->typeId,
.includeBravingWater = includeBravingWater,
.braveWaterCost = braveWaterActionPointCost};
// Check thread-local cache first (no locks needed!)
auto localIt = localCache.find(fullKey);
if (localIt != localCache.end()) {
#if CACHE_STATS_LOGGING_
cacheStats.localHits++;
// Print stats every 100 requests to monitor effectiveness
if ((cacheStats.localHits + cacheStats.localMisses) % 100 == 0) {
printf("Thread cache stats: %d local hits, %d misses, %d shared accesses, %d eviction "
"events\n",
cacheStats.localHits,
cacheStats.localMisses,
cacheStats.sharedAccesses,
cacheStats.evictionEvents);
}
#endif
return localIt->second; // Direct shared_ptr access - no atomic lock() needed!
}
#if CACHE_STATS_LOGGING_
cacheStats.localMisses++;
#endif
// Thread-local cache miss - access shared cache
auto result = GetFromSharedCache(
map,
mapId,
battalionType,
includeBravingWater,
braveWaterActionPointCost);
// Cache result locally for future lookups by this thread
localCache[fullKey] = result;
// Prevent unbounded cache growth - limit to reasonable size
if (localCache.size() > 100) {
// Simple eviction: clear half the cache when it gets too large
#if CACHE_STATS_LOGGING_
cacheStats.evictionEvents++;
#endif
auto it = localCache.begin();
std::advance(it, localCache.size() / 2);
localCache.erase(localCache.begin(), it);
}
return result;
}
auto ActionPointDistancesCache::GetFromSharedCache(
const HexMap* map,
const MapId& mapId,
const BattalionTypeSPtr& battalionType,
const bool includeBravingWater,
const int braveWaterActionPointCost) -> std::shared_ptr<ActionPointDistances> {
#if CACHE_STATS_LOGGING_
cacheStats.sharedAccesses++;
#endif
auto& vec = includeBravingWater ? bravingDistances : noBravingDistances;
auto& distancesMap = vec[battalionType->typeId];
shared_ptr<ActionPointDistances> toReturn;
// Try shared read lock first (multiple threads can read simultaneously)
if (distancesMap.if_contains(mapId, [&toReturn](const auto& kv) { toReturn = kv.second; })) {
return toReturn;
}
// Cache miss - need to create new entry with exclusive access
distancesMap.lazy_emplace_l(
mapId,
[&toReturn](const auto& kv) { toReturn = kv.second; },
@@ -5,14 +5,11 @@
#ifndef EAGLE0_ACTIONPOINTDISTANCESCACHE_HPP
#define EAGLE0_ACTIONPOINTDISTANCESCACHE_HPP
#include <shared_mutex>
#include <unordered_map>
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistances.hpp"
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wthread-safety-analysis"
#pragma GCC diagnostic ignored "-Wunused-result"
#include <gtl/phmap.hpp>
#include "parallel_hashmap/phmap.h"
#pragma GCC diagnostic pop
namespace shardok {
@@ -24,7 +21,7 @@ struct MapId {
int64_t modifierId;
friend size_t hash_value(const MapId& id) {
return gtl::HashState::combine(0, id.terrainTypesId, id.modifierId);
return phmap::HashState::combine(0, id.terrainTypesId, id.modifierId);
}
auto operator==(const MapId& other) const -> bool {
@@ -34,53 +31,20 @@ struct MapId {
using APDKey = MapId;
// Extended key for thread-local cache that includes battalion type
struct FullCacheKey {
MapId mapId;
int battalionTypeId;
bool includeBravingWater;
int braveWaterCost;
bool operator==(const FullCacheKey& other) const {
return mapId == other.mapId && battalionTypeId == other.battalionTypeId &&
includeBravingWater == other.includeBravingWater &&
braveWaterCost == other.braveWaterCost;
}
};
// Hash function for FullCacheKey
struct FullCacheKeyHash {
size_t operator()(const FullCacheKey& key) const {
return gtl::HashState::combine(
hash_value(key.mapId),
key.battalionTypeId,
key.includeBravingWater,
key.braveWaterCost);
}
};
class ActionPointDistancesCache {
private:
using APDMap = gtl::parallel_flat_hash_map<
using APDMap = phmap::parallel_flat_hash_map<
APDKey,
shared_ptr<ActionPointDistances>,
gtl::priv::hash_default_hash<APDKey>,
gtl::priv::hash_default_eq<APDKey>,
phmap::priv::hash_default_hash<APDKey>,
phmap::priv::hash_default_eq<APDKey>,
std::allocator<std::pair<const APDKey, shared_ptr<ActionPointDistances>>>,
6,
4,
std::mutex>;
vector<APDMap> noBravingDistances;
vector<APDMap> bravingDistances;
// Private method for accessing shared cache with improved locking
auto GetFromSharedCache(
const HexMap* map,
const MapId& mapId,
const BattalionTypeSPtr& battalionType,
bool includeBravingWater,
int braveWaterActionPointCost) -> shared_ptr<ActionPointDistances>;
public:
explicit ActionPointDistancesCache();
@@ -30,7 +30,7 @@ cc_library(
":fixed_action_point_distances",
"//src/main/cpp/net/eagle0/shardok/library/map:hex_map_hasher",
"//src/main/protobuf/net/eagle0/shardok/storage:action_result_cc_proto",
"@gtl",
"@parallel_hashmap",
],
)
@@ -4,18 +4,9 @@
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
#include <thread>
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
// Dynamic thread count based on hardware capabilities
static const int ASYNC_COUNT = []() {
const int cores = static_cast<int>(std::thread::hardware_concurrency());
// Use cores-2 to leave room for OS and other processes, minimum 4 threads
const int threadCount = std::max(4, cores - 4);
printf("ActionPointDistances using %d threads (detected %d cores)\n", threadCount, cores);
return threadCount;
}();
static constexpr int ASYNC_COUNT = 8;
namespace shardok {
@@ -75,24 +66,25 @@ FixedActionPointDistances::FixedActionPointDistances(
int chunkSize = (indexCount + ASYNC_COUNT - 1) / ASYNC_COUNT;
// Break into chunks for async
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
futures[chunkIdx] = std::async(std::launch::async, [=]() -> vector<vector<DIST_T>> {
vector<vector<DIST_T>> chunkVec;
chunkVec.reserve(chunkSize);
const int chunkStartIndex = chunkIdx * chunkSize;
futures[chunkIdx] =
std::async(std::launch::async, [=, this]() -> vector<vector<DIST_T>> {
vector<vector<DIST_T>> chunkVec;
chunkVec.reserve(chunkSize);
const int chunkStartIndex = chunkIdx * chunkSize;
for (int i = 0; i < chunkSize; i++) {
const auto fromIndex = chunkStartIndex + i;
if (fromIndex >= indexCount) { continue; }
chunkVec.push_back(GenerateDistances(
fromIndex,
map,
includeBravingWater,
braveWaterActionPointCost,
battalionType,
braveWaterPossibleCoords));
}
return chunkVec;
});
for (int i = 0; i < chunkSize; i++) {
const auto fromIndex = chunkStartIndex + i;
if (fromIndex >= indexCount) { continue; }
chunkVec.push_back(GenerateDistances(
fromIndex,
map,
includeBravingWater,
braveWaterActionPointCost,
battalionType,
braveWaterPossibleCoords));
}
return chunkVec;
});
}
distances.reserve(indexCount);
@@ -36,10 +36,9 @@ auto main(int argc, char** argv) -> int {
const auto start = system_clock::now();
for (int i = 0; i < 100; i++) {
for (int i = 0; i < 300000; i++) {
for (const HexMapW& hexMap : hexMaps) {
shardok::FixedActionPointDistances
distances(hexMap, 0x1234, 0xABCD, battalionType, true, 5);
shardok::FixedActionPointDistances distances(hexMap, 0x1234, battalionType, true, 5);
}
}
const auto end = system_clock::now();
@@ -1,59 +0,0 @@
# Action Point Distances Performance Optimization Status
This document tracks the performance optimization work for the Shardok tactical combat pathfinding system.
## Current Implementation Status
The system uses **Dijkstra's algorithm** with significant optimizations implemented, achieving ~8x performance improvement over the original implementation.
## 🎯 **Next Steps - Remaining Optimization Opportunities**
### **1. Fibonacci Heap** ⚠️ **High Complexity**
**Expected:** 20-40% speedup on larger maps
**Effort:** Very High (3-5 days)
**Complexity:** Complex data structure with circular doubly-linked lists, cascading cuts, degree tracking
Replace `std::priority_queue` with Fibonacci heap for O(1) decrease-key operations vs O(log V).
### **2. d-ary Heap** ⚠️ **Simpler Alternative**
**Expected:** 10-20% potential speedup
**Effort:** Low (1-2 hours)
**Complexity:** Much simpler than Fibonacci heap
Use 4-ary or 8-ary heap for better cache performance compared to binary heap.
## Performance Projections
| Map Size | Original | Current | Remaining Potential | Final Target |
|---------------------|---------------|--------------|---------------------|--------------|
| 12×14 (168 tiles) | 14,196 ops | ~1,800 ops | ~1,400 ops | **10-12x** |
| 24×28 (672 tiles) | 226,128 ops | ~28,000 ops | ~20,000 ops | **11-15x** |
| 48×56 (2,688 tiles) | 3,612,516 ops | ~450,000 ops | ~250,000 ops | **14-20x** |
## Implementation Priority
### **Recommended Next Steps**
1. **d-ary heap** - Low effort, moderate potential gain
2. **Fibonacci heap** - High effort, uncertain benefit for typical map sizes
### **Conclusion**
**Current optimizations have achieved the primary performance goals.** Further optimizations show diminishing returns due to the algorithm being memory-bound rather than compute-bound on typical map sizes.
## Memory Usage Impact
**Current:** ~168² × 2 bytes = 56KB per distance matrix
- 50-80% reduction in computation memory bandwidth
- Better cache hit rates (80% → 95%+)
- Reduced memory allocation churn from lookup tables
## Compatibility Notes
- All optimizations maintain the same public API
- Cache file format unchanged
- Thread safety preserved
- No breaking changes to existing code
@@ -7,7 +7,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -27,7 +26,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -45,7 +43,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -66,7 +63,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -83,7 +79,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -104,7 +99,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -125,7 +119,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -148,7 +141,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -170,7 +162,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -191,7 +182,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -210,7 +200,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -227,7 +216,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -250,7 +238,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -267,7 +254,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -294,7 +280,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -316,7 +301,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -338,7 +322,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -357,7 +340,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -377,7 +359,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -398,7 +379,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -419,7 +399,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -439,7 +418,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -460,7 +438,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -482,7 +459,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -500,7 +476,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -518,7 +493,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -538,7 +512,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -554,7 +527,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -576,7 +548,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -598,7 +569,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -619,7 +589,6 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/library:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/library:__subpackages__",
],
deps = [
@@ -60,7 +60,6 @@
<Analyzer Include="/Applications/Unity/Hub/Editor/6000.0.32f1/Unity.app/Contents/Tools/Unity.SourceGenerators/Unity.UIToolkit.SourceGenerator.dll" />
</ItemGroup>
<ItemGroup>
<Compile Include="Assets/Eagle/GeneratedTextListener.cs" />
<Compile Include="Assets/Eagle/Notifications/NewRoundNotificationGenerator.cs" />
<Compile Include="Assets/common/CommonExtensions.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Switch/SwitchManagerEditor.cs" />
@@ -77,7 +76,6 @@
<Compile Include="Assets/Modern UI Pack/Scripts/Slider/SliderManager.cs" />
<Compile Include="Assets/common/GUIUtils/TableRowHoverDetector.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ResolveAllianceCommandSelector.cs" />
<Compile Include="Assets/Eagle/Table Rows/DynamicHeroTextUpdater.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/WithdrewForTruceDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/util/HeroDropdownController.cs" />
<Compile Include="Assets/Eagle/Notifications/PrisonerReturnedNotificationGenerator.cs" />
@@ -124,8 +122,8 @@
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerIcon.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/HandleRiotDoNothingCommandSelector.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/PrisonerExecutedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/common/AWSResourceFetcher.cs" />
<Compile Include="Assets/Eagle/CommandButtonPanelController.cs" />
<Compile Include="Assets/Eagle/Table Rows/DynamicFactionTextUpdater.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Demo/DemoListShadow.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Slider/RangeMinSlider.cs" />
<Compile Include="Assets/HoveringTooltipTextProvider.cs" />
@@ -134,6 +132,7 @@
<Compile Include="Assets/HoveringTooltip.cs" />
<Compile Include="Assets/Bluetooth/DiceInterface.cs" />
<Compile Include="Assets/ButtonColors.cs" />
<Compile Include="Assets/common/GUIUtils/ErrorPanel.cs" />
<Compile Include="Assets/Eagle/MapController.cs" />
<Compile Include="Assets/Eagle/Table Rows/MovingArmyTableRow.cs" />
<Compile Include="Assets/common/DisclosureTriangle.cs" />
@@ -206,7 +205,6 @@
<Compile Include="Assets/MainQueue.cs" />
<Compile Include="Assets/PlayerColors.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/CapturedHeroReturnedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/common/GUIUtils/ErrorHandler.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/DivineCommandSelector.cs" />
<Compile Include="Assets/Shardok/Unit.cs" />
<Compile Include="Assets/Eagle/ProvinceInfoPanelController.cs" />
@@ -228,11 +226,9 @@
<Compile Include="Assets/Eagle/PanelPositions.cs" />
<Compile Include="Assets/Eagle/Notifications/NotificationGenerator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Switch/SwitchManager.cs" />
<Compile Include="Assets/Eagle/Notifications/DynamicTextNotification.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerToggle.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ProvinceHeldDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Bluetooth/DieInfo.cs" />
<Compile Include="Assets/common/ResourceFetcher.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerAnimatedIcon.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Input Field/CustomInputField.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Context Menu/ContextMenuContent.cs" />
@@ -48,8 +48,9 @@ public class AvailableGameItem : MonoBehaviour {
joinButton.interactable = true;
buttonText.text = "JOIN";
List<HeroView> heroOptions =
new List<HeroView> { new() { Id = 0, Profession = Profession.NoProfession } };
List<HeroView> heroOptions = new List<HeroView> {
new() { Name = "Random", Id = 0, Profession = Profession.NoProfession }
};
for (int i = 0; i < leaders.Count; i++) {
var leader = leaders[i];
heroOptions.Add(new HeroView {
@@ -59,7 +60,6 @@ public class AvailableGameItem : MonoBehaviour {
});
}
heroDropdownController.FallbackText = "Random";
heroDropdownController.AvailableHeroes = heroOptions;
}
}
@@ -2,13 +2,10 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using common;
using common.GUIUtils;
using eagle;
using Grpc.Core;
using Grpc.Core.Interceptors;
@@ -22,13 +19,9 @@ public class AuthInterceptor : Interceptor {
private readonly string headerName = "Authorization";
private string headerValue;
public static string HeaderValue(string name, string password) {
String toEncode = name + ":" + password;
return Convert.ToBase64String(Encoding.UTF8.GetBytes(toEncode));
}
public AuthInterceptor(String name, String password) {
headerValue = "Basic " + HeaderValue(name, password);
String toEncode = name + ":" + password;
headerValue = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes(toEncode));
}
public override TResponse BlockingUnaryCall<TRequest, TResponse>(
TRequest request,
@@ -85,7 +78,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber {
public GameObject connectionPanel;
public GameObject gameSelectionPanel;
public GameObject customBattlePanel;
public ErrorHandler errorHandler;
public ErrorPanel errorPanel;
public GameObject runningGamesListArea;
public GameObject runningGamesListItemPrefab;
@@ -105,7 +98,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber {
int fetchedMaxPlayerCount;
private EagleConnection eagleConnection;
private PersistentClientConnection _persistentClientConnection;
private HttpClient _httpClient;
private Thread lobbyUpdatesThread;
private bool listen = false;
private readonly Object pendingReplyLock = new Object();
@@ -145,8 +137,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber {
connectionPanel.gameObject.SetActive(true);
gameSelectionPanel.gameObject.SetActive(false);
customBattlePanel.gameObject.SetActive(false);
errorHandler.gameObject.SetActive(true);
}
public void EditorButtonClicked() { SceneManager.LoadScene("Map Editor"); }
@@ -181,7 +171,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber {
fetchedMaxPlayerCount = lobbyResponse.NewGameOptions.MaxSupportedPlayers;
MainQueue.Q.Enqueue(() => {
ResourceFetcher.headshotFetcher.Prefetch(
AWSResourceFetcher.headshotFetcher.Prefetch(
fetchedNewGameLeaders.Select(a => a.ImagePath));
connectionPanel.gameObject.SetActive(false);
@@ -266,13 +256,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber {
eagleConnection.credentials,
EagleConnection.EagleCancellationToken);
_persistentClientConnection.Connect();
errorHandler.PersistentClientConnection = _persistentClientConnection;
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", eagleConnection.authHeader);
ResourceFetcher.SetUpConnection(_httpClient);
AWSResourceFetcher.SetUpConnection(_persistentClientConnection);
}
private void _internalCustomBattle() {
@@ -319,9 +303,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber {
RequestMaps();
StartListeningForLobbyUpdates();
} catch (RpcException e) {
MainQueue.Q.Enqueue(() => { errorHandler.Add(e); });
MainQueue.Q.Enqueue(() => { errorPanel.Add(e); });
} catch (Exception e) {
MainQueue.Q.Enqueue(() => { errorHandler.Add(e); });
MainQueue.Q.Enqueue(() => { errorPanel.Add(e); });
}
}
@@ -60,8 +60,9 @@ public class CreateGameItem : MonoBehaviour {
buttonText.text = "CREATE";
List<HeroView> heroOptions =
new List<HeroView> { new() { Id = 0, Profession = Profession.NoProfession } };
List<HeroView> heroOptions = new List<HeroView> {
new HeroView { Name = "Random", Id = 0, Profession = Profession.NoProfession }
};
for (int i = 0; i < leaders.Count; i++) {
var leader = leaders[i];
heroOptions.Add(new HeroView {
@@ -72,7 +73,6 @@ public class CreateGameItem : MonoBehaviour {
});
}
heroDropdownController.FallbackText = "Random";
heroDropdownController.AvailableHeroes = heroOptions;
heroDropdownController.HeadshotImage.gameObject.SetActive(false);
@@ -135,7 +135,7 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
var unit = yourUnitsController.ComponentAt<CustomUnitRowController>(i).Unit;
unit.Hero.EagleHeroId = nextEagleHeroId;
yourSetupInfo.Units.Add(unit);
_heroNames[nextEagleHeroId] = unit.Hero.NameTextId;
_heroNames[nextEagleHeroId] = unit.Hero.Name;
_heroImages[nextEagleHeroId] = "no_profession/male/000107.1333771077.png";
nextEagleHeroId++;
}
@@ -157,7 +157,7 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
var unit = aiUnitsController.ComponentAt<CustomUnitRowController>(i).Unit;
unit.Hero.EagleHeroId = nextEagleHeroId;
aiSetupInfo.Units.Add(unit);
_heroNames[nextEagleHeroId] = unit.Hero.NameTextId;
_heroNames[nextEagleHeroId] = unit.Hero.Name;
nextEagleHeroId++;
}
@@ -26,48 +26,58 @@ namespace eagle {
private readonly HashSet<IClientTextListener> _listeners = new();
public void Clear() { _streamingTexts.Clear(); }
public void Clear() {
lock (this) { _streamingTexts.Clear(); }
}
public Dictionary<String, TextEntry> All() {
return new Dictionary<string, TextEntry>(_streamingTexts);
lock (this) { return new Dictionary<string, TextEntry>(_streamingTexts); }
}
public String
HandleNewStreamingText(String llmId, String newText, Int32 knownByteCount, bool completed) {
var currentText = "";
if (_streamingTexts.TryGetValue(llmId, out var entry)) { currentText = entry.Text; }
lock (this) {
var currentText = "";
if (_streamingTexts.TryGetValue(llmId, out var entry)) { currentText = entry.Text; }
var currentTextBytes = Encoding.UTF8.GetBytes(currentText);
var truncatedBytes = currentTextBytes.Take(knownByteCount).ToArray();
var currentTextBytes = Encoding.UTF8.GetBytes(currentText);
var truncatedBytes = currentTextBytes.Take(knownByteCount).ToArray();
var updatedText = Encoding.UTF8.GetString(truncatedBytes) + newText;
_streamingTexts[llmId] = new TextEntry(updatedText, completed);
var updatedText = Encoding.UTF8.GetString(truncatedBytes) + newText;
_streamingTexts[llmId] = new TextEntry(updatedText, completed);
_listeners.Where(x => x.TextId() == llmId)
.ToList()
.ForEach(x => x.OnTextUpdate(updatedText, completed));
_listeners.Where(x => x.TextId() == llmId)
.ToList()
.ForEach(x => x.OnTextUpdate(updatedText, completed));
return updatedText;
return updatedText;
}
}
public TextEntry GetTextEntry(string streamId) {
if (String.IsNullOrEmpty(streamId)) return new TextEntry("", false);
lock (this) {
if (String.IsNullOrEmpty(streamId)) return new TextEntry("", false);
if (ClientPregeneratedText.Provider.TryGetText(streamId, out var text)) {
return new TextEntry(text, true);
if (ClientPregeneratedText.Provider.TryGetText(streamId, out var text)) {
return new TextEntry(text, true);
}
return _streamingTexts.GetValueOrDefault(streamId, null);
}
return _streamingTexts.GetValueOrDefault(streamId, null);
}
public void AddListener(IClientTextListener listener) {
_listeners.Add(listener);
if (listener.TextId() is {} textId && !String.IsNullOrEmpty(textId)) {
var entry = GetTextEntry(textId);
if (entry != null) { listener.OnTextUpdate(entry.Text, entry.Completed); }
lock (this) {
if (listener.TextId() is {} textId && !String.IsNullOrEmpty(textId)) {
_listeners.Add(listener);
var entry = GetTextEntry(textId);
if (entry != null) { listener.OnTextUpdate(entry.Text, entry.Completed); }
}
}
}
public void RemoveListener(IClientTextListener listener) { _listeners.Remove(listener); }
public void RemoveListener(IClientTextListener listener) {
lock (this) { _listeners.Remove(listener); }
}
}
}
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using common.GUIUtils;
using Net.Eagle0.Eagle.Api;
using TMPro;
using UnityEngine;
@@ -23,13 +22,15 @@ namespace eagle {
}
}
}
public SelectedCommand SelectedCommand => Selector == null ? null : Selector.Command;
public SelectedCommand SelectedCommand {
get { return Selector == null ? null : Selector.Command; }
}
public TMP_Text headerLabel;
public Button commitCommandButton;
public TextMeshProUGUI commitButtonText;
public IGameModel Model { get; set; }
public ErrorHandler errorHandler;
public ErrorPanel errorPanel;
public CommandWarningPanelController commandWarningPanelController;
private AvailableCommand _availableCommand;
@@ -37,9 +38,7 @@ namespace eagle {
get => _availableCommand;
private
set {
if (_availableCommand == null || !_availableCommand.Equals(value)) {
_availableCommand = value;
}
if (_availableCommand != value) { _availableCommand = value; }
}
}
@@ -60,7 +59,7 @@ namespace eagle {
Selector.UpdateAvailableCommand(Model, AvailableCommand);
headerLabel.text = Selector.HeaderString;
} catch (Exception e) { errorHandler.Add(e); }
} catch (Exception e) { errorPanel.Add(e); }
}
public List<CommandSelector> AppropriateSelectors(AvailableCommand cmd) =>
@@ -138,7 +137,7 @@ namespace eagle {
}
} catch (Exception e) {
Debug.Log($"Event type is {eventType}");
errorHandler.Add(e);
errorPanel.Add(e);
}
}
}
@@ -13,7 +13,6 @@ namespace eagle {
using FactionId = Int32;
public class ApprehendOutlawCommandSelector : CommandSelector {
private List<GeneratedTextListener> outlawListeners = new();
private HeroId SelectedHeroId => actingHeroDropdownController.SelectedHeroId;
private BattalionId? SelectedBattalionId =>
actingBattalionDropdown.value == 0
@@ -49,22 +48,13 @@ namespace eagle {
.OrderBy(batt => batt.Type)
.ToList();
private void ClearOutlawListeners() {
foreach (var listener in outlawListeners) {
ClientTextProvider.Provider.RemoveListener(listener);
private string outlawDropdownString(ResidentOutlaw outlaw) {
string lastFactionString = "";
if (outlaw.LastFactionId is FactionId fid) {
lastFactionString = $" ({_model.FactionName(fid)})";
}
outlawListeners.Clear();
}
private void SetOutlawText(int index, string text) {
if (index >= targetOutlawDropdown.options.Count) return;
targetOutlawDropdown.options[index].text = text;
targetOutlawDropdown.RefreshShownValue();
}
private string GetFactionSuffix(ResidentOutlaw outlaw) {
if (outlaw.LastFactionId is FactionId fid) { return $" ({_model.FactionName(fid)})"; }
return "";
return $"{outlaw.Hero.Name}{lastFactionString}";
}
private TMP_Dropdown.OptionData BattalionOption(BattalionView bv) {
@@ -94,34 +84,11 @@ namespace eagle {
actingBattalionDropdown.value = Math.Min(1, AvailableBattalions.Count);
targetOutlawDropdown.ClearOptions();
var outlawNames =
ApprehendOutlawCommand.Outlaws.Select(opt => outlawDropdownString(opt))
.ToList();
ClearOutlawListeners();
// Create empty options first, text will be populated by listeners
var emptyOptions = ApprehendOutlawCommand.Outlaws
.Select(
_ => new TMP_Dropdown.OptionData { text = "" })
.ToList();
targetOutlawDropdown.AddOptions(emptyOptions);
// Set up listeners for each outlaw's name text
for (int i = 0; i < ApprehendOutlawCommand.Outlaws.Count; i++) {
var outlaw = ApprehendOutlawCommand.Outlaws[i];
var suffix = GetFactionSuffix(outlaw);
if (!string.IsNullOrEmpty(outlaw.Hero.NameTextId)) {
var listener = new GeneratedTextListener(
outlaw.Hero.NameTextId,
"",
suffix,
i,
SetOutlawText);
outlawListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
}
targetOutlawDropdown.AddOptions(outlawNames);
targetOutlawDropdown.value = 0;
}
@@ -133,7 +100,5 @@ namespace eagle {
ActingHeroId = SelectedHeroId
}
};
void OnDestroy() { ClearOutlawListeners(); }
}
}
@@ -13,8 +13,6 @@ namespace eagle {
using HeroId = Int32;
public class DefendCommandSelector : CommandSelector {
private GeneratedTextListener warningTextListener;
private string cachedWarningText = "";
private int RallyPointProvinceIndex => rallyPointDropdown.value;
private List<CombatUnit> SelectedActingUnits => unitSelector.SelectedActingUnits;
@@ -74,11 +72,9 @@ namespace eagle {
if (BadlyConstructedUnits) {
var count = unitSelector.HeroIdsLeadingInappropriateUnits.Count;
if (count == 1) {
txt += !string.IsNullOrEmpty(cachedWarningText)
? cachedWarningText
: "A hero is leading a battalion type that would restrict their abilities.\n\n";
txt += $"{_model.Heroes[unitSelector.HeroIdsLeadingInappropriateUnits[0]].Name} is leading a battalion type that would restrict their abilities.\n\n";
} else {
txt += "Some of your heroes are leading battalion types that would restrict their abilities.\n\n";
txt += $"Some of your heroes are leading battalion types that would restrict their abilities.\n\n";
}
}
@@ -151,36 +147,6 @@ namespace eagle {
troopCountText.text = $"{DefendCommand.HostileTroopCount}";
SetUpUnitSelector();
SetupWarningTextListener();
}
private void ClearWarningTextListener() {
if (warningTextListener != null) {
ClientTextProvider.Provider.RemoveListener(warningTextListener);
warningTextListener = null;
}
}
private void SetupWarningTextListener() {
ClearWarningTextListener();
// Only set up if there's exactly one hero with inappropriate units
if (unitSelector.HeroIdsLeadingInappropriateUnits.Count == 1) {
var heroId = unitSelector.HeroIdsLeadingInappropriateUnits[0];
var hero = _model.Heroes[heroId];
if (!string.IsNullOrEmpty(hero.NameTextId)) {
warningTextListener = new GeneratedTextListener(
hero.NameTextId,
"",
" is leading a battalion type that would restrict their abilities.\n\n",
0,
(index, text) => { cachedWarningText = text; });
ClientTextProvider.Provider.AddListener(warningTextListener);
}
}
}
public override SelectedCommand Command {
@@ -193,7 +159,5 @@ namespace eagle {
return cmd;
}
}
void OnDestroy() { ClearWarningTextListener(); }
}
}
@@ -17,7 +17,7 @@ namespace eagle {
private ProvinceId ActingProvinceId => FeastAvailableCommand.ActingProvinceId;
public override SelectedCommand Command =>
new() { FeastCommand = new FeastSelectedCommand() };
new SelectedCommand { FeastCommand = new FeastSelectedCommand() };
protected override void SetUpUI() {
GoldCostLabel.text = _availableCommand.FeastCommand.GoldCost.ToString();
@@ -11,8 +11,6 @@ namespace eagle {
using HeroId = Int32;
public class HeroGiftCommandSelector : CommandSelector {
private GeneratedTextListener warningTextListener;
private string cachedWarningText = "";
public TMP_Dropdown provinceDropdown;
public HeroDropdownController heroDropdownController;
public Slider goldSlider;
@@ -84,7 +82,8 @@ namespace eagle {
}
}
public override bool WarnOnCommitButton => SelectedHero.Loyalty.Stat >= 100;
public override string CommitWarningText => cachedWarningText;
public override string CommitWarningText =>
$"{SelectedHero.Name} already has maximum loyalty!";
void Awake() {
heroDropdownController.ConditionAccessor = hero => hero.Loyalty;
@@ -109,28 +108,6 @@ namespace eagle {
goldSlider.maxValue = AvailableGold;
}
private void ClearWarningTextListener() {
if (warningTextListener != null) {
ClientTextProvider.Provider.RemoveListener(warningTextListener);
warningTextListener = null;
}
}
private void SetupWarningTextListener() {
ClearWarningTextListener();
if (!string.IsNullOrEmpty(SelectedHero.NameTextId)) {
warningTextListener = new GeneratedTextListener(
SelectedHero.NameTextId,
"",
" already has maximum loyalty!",
0,
(index, text) => { cachedWarningText = text; });
ClientTextProvider.Provider.AddListener(warningTextListener);
}
}
public void SetUpHeroDropdown() {
var heroes = AvailableHeroIds.Select(hid => _model.Heroes[hid]).ToList();
@@ -143,17 +120,11 @@ namespace eagle {
}
}
heroDropdownController.SelectedHeroId = minLoyaltyHero.Id;
// Set up warning text listener for the selected hero
SetupWarningTextListener();
}
public void HeroSelected() {
goldSlider.maxValue = AvailableGold;
goldSlider.value = Math.Min(goldSlider.value, AvailableGold);
// Update warning text listener when hero selection changes
SetupWarningTextListener();
}
public void GoldPlusClicked() {
@@ -180,7 +151,5 @@ namespace eagle {
return cmd;
}
}
void OnDestroy() { ClearWarningTextListener(); }
}
}
@@ -13,7 +13,6 @@ namespace eagle {
using ProvinceId = Int32;
public class IssueOrdersCommandSelector : CommandSelector {
private List<GeneratedTextListener> rulingHeroListeners = new();
IList<ProvinceOrderType> availableOrderTypes;
Dictionary<ProvinceId, ProvinceOrderType> currentOrders;
List<ProvinceOrders> newOrders;
@@ -64,15 +63,7 @@ namespace eagle {
(SelectedFocusProvince != startingFocusProvince);
public override string DisabledCommitButtonReason => "No province orders have changed";
private void ClearRulingHeroListeners() {
foreach (var listener in rulingHeroListeners) {
ClientTextProvider.Provider.RemoveListener(listener);
}
rulingHeroListeners.Clear();
}
override protected void SetUpUI() {
ClearRulingHeroListeners();
availableOrderTypes = IssueOrdersAvailableCommand.AvailableOrders;
currentOrders = IssueOrdersAvailableCommand.CurrentOrders.ToDictionary(
x => x.ProvinceId,
@@ -110,36 +101,7 @@ namespace eagle {
var textComponents = newRow.GetComponentsInChildren<TextMeshProUGUI>();
textComponents[0].text = province.Name;
textComponents[1].text = GUIUtils.ConditionString(province.FullInfo.Support);
// Set up ruling hero name with listener
var rulingHeroTextComponent = textComponents[2];
rulingHeroTextComponent.text = ""; // Start empty, will be populated by listener
if (province.FullInfo.RulingHeroId.HasValue) {
var rulingHero = _model.Heroes[province.FullInfo.RulingHeroId.Value];
if (!string.IsNullOrEmpty(rulingHero.NameTextId)) {
var listener = new GeneratedTextListener(
rulingHero.NameTextId,
"",
"",
i,
(index, text) => {
if (index < scrollContent.transform.childCount) {
var rowTransform = scrollContent.transform.GetChild(index);
var rowTextComponents =
rowTransform
.GetComponentsInChildren<TextMeshProUGUI>();
if (rowTextComponents.Length > 2) {
rowTextComponents[2].text = text;
}
}
});
rulingHeroListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
}
textComponents[2].text = _model.Heroes[province.FullInfo.RulingHeroId.Value].Name;
textComponents[3].text = province.FullInfo.RulingFactionHeroIds.Count.ToString();
var ordersDropdown = newRow.GetComponentInChildren<TMP_Dropdown>();
@@ -179,7 +141,5 @@ namespace eagle {
public void ProvinceOrdersValueChanged(int index, Int32 newValue) {
newOrders[index].Orders = availableOrderTypes[newValue];
}
void OnDestroy() { ClearRulingHeroListeners(); }
}
}
@@ -22,8 +22,6 @@ namespace eagle {
}
public class MarchCommandSelector : CommandSelector {
private GeneratedTextListener warningTextListener;
private string cachedWarningText = "";
private const string NoProvinceSelectedText = "Select...";
private bool HasNoneOption => toDropdown.options.Last().text == NoProvinceSelectedText;
@@ -140,9 +138,7 @@ namespace eagle {
if (BadlyConstructedUnits) {
var count = unitSelector.HeroIdsLeadingInappropriateUnits.Count;
if (count == 1) {
txt += !string.IsNullOrEmpty(cachedWarningText)
? cachedWarningText
: "A hero is leading a battalion type that would restrict their abilities\n\n";
txt += $"{_model.Heroes[unitSelector.HeroIdsLeadingInappropriateUnits[0]].Name} is leading a battalion type that would restrict their abilities\n\n";
} else {
txt += "Some of your heroes are leading battalion types that would restrict their abilities\n\n";
}
@@ -324,36 +320,6 @@ namespace eagle {
SetFoodLabels();
SetGoldLabels();
SetupWarningTextListener();
}
private void ClearWarningTextListener() {
if (warningTextListener != null) {
ClientTextProvider.Provider.RemoveListener(warningTextListener);
warningTextListener = null;
}
}
private void SetupWarningTextListener() {
ClearWarningTextListener();
// Only set up if there's exactly one hero with inappropriate units
if (unitSelector.HeroIdsLeadingInappropriateUnits.Count == 1) {
var heroId = unitSelector.HeroIdsLeadingInappropriateUnits[0];
var hero = _model.Heroes[heroId];
if (!string.IsNullOrEmpty(hero.NameTextId)) {
warningTextListener = new GeneratedTextListener(
hero.NameTextId,
"",
" is leading a battalion type that would restrict their abilities\n\n",
0,
(index, text) => { cachedWarningText = text; });
ClientTextProvider.Provider.AddListener(warningTextListener);
}
}
}
public void GoldMinusClicked() {
@@ -415,7 +381,5 @@ namespace eagle {
return null;
}
}
void OnDestroy() { ClearWarningTextListener(); }
}
}
@@ -8,8 +8,6 @@ namespace eagle {
using ProvinceId = Int32;
public class PleaseRecruitMeCommandSelector : PopupPanelController {
private List<GeneratedTextListener> heroNameListeners = new();
private List<string> cachedHeroNames = new();
struct HeroInfo {
public ProvinceId provinceId;
public ExpandedUnaffiliatedHero hero;
@@ -28,11 +26,9 @@ namespace eagle {
set {
_availableCommand = value;
if (value == null) {
ClearHeroNameListeners();
_offers = new List<HeroInfo>();
PopupInfos = new List<PopupInfo>();
} else {
ClearHeroNameListeners();
_offers = _availableCommand.PleaseRecruitMeCommand.AvailableProvinces
.SelectMany(
p => p.AvailableHeroes.Select(
@@ -42,18 +38,16 @@ namespace eagle {
}))
.ToList();
// Initialize cached hero names and set up listeners
cachedHeroNames = new List<string>(new string[_offers.Count]);
SetupHeroNameListeners();
PopupInfos = _offers.Select((heroInfo, index) => new PopupInfo {
titleText = "Please Recruit Me!",
mainText = GetHeroMainText(index, heroInfo.provinceId),
mainTextLlmId = heroInfo.hero.PleaseRecruitMeTextId,
affectedProvinceIds =
new List<ProvinceId>(heroInfo.provinceId)
})
.ToList();
PopupInfos =
_offers.Select(heroInfo => new PopupInfo {
titleText = "Please Recruit Me!",
mainText =
$"{heroInfo.hero.Hero.Name} wishes to join us in {Model.Provinces[heroInfo.provinceId].Name}.\n\n",
mainTextLlmId = heroInfo.hero.PleaseRecruitMeTextId,
affectedProvinceIds =
new List<ProvinceId>(heroInfo.provinceId)
})
.ToList();
SetUpPanel();
}
@@ -62,53 +56,6 @@ namespace eagle {
private List<HeroInfo> _offers = new List<HeroInfo>();
private void ClearHeroNameListeners() {
foreach (var listener in heroNameListeners) {
ClientTextProvider.Provider.RemoveListener(listener);
}
heroNameListeners.Clear();
}
private void SetupHeroNameListeners() {
for (int i = 0; i < _offers.Count; i++) {
var heroInfo = _offers[i];
if (!string.IsNullOrEmpty(heroInfo.hero.Hero.NameTextId)) {
var listener = new GeneratedTextListener(
heroInfo.hero.Hero.NameTextId,
"",
"",
i,
(index, text) => {
if (index < cachedHeroNames.Count) {
cachedHeroNames[index] = text;
UpdatePopupInfoMainText(index);
}
});
heroNameListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
}
}
private string GetHeroMainText(int index, ProvinceId provinceId) {
var heroName = !string.IsNullOrEmpty(cachedHeroNames[index]) ? cachedHeroNames[index]
: "A hero";
return $"{heroName} wishes to join us in {Model.Provinces[provinceId].Name}.\n\n";
}
private void UpdatePopupInfoMainText(int index) {
if (index < PopupInfos.Count && index < _offers.Count) {
var heroInfo = _offers[index];
PopupInfos[index] = new PopupInfo {
titleText = "Please Recruit Me!",
mainText = GetHeroMainText(index, heroInfo.provinceId),
mainTextLlmId = heroInfo.hero.PleaseRecruitMeTextId,
affectedProvinceIds = new List<ProvinceId>(heroInfo.provinceId)
};
}
}
private SelectedCommand SelectedCommand(bool accept) {
return new SelectedCommand {
PleaseRecruitMeCommand =
@@ -139,7 +86,5 @@ namespace eagle {
heroDetailsController.SetHero(_offers[DisplayedIndex].hero.Hero, Model);
}
}
void OnDestroy() { ClearHeroNameListeners(); }
}
}
@@ -14,8 +14,6 @@ namespace eagle {
using ProvinceId = Int32;
public class RansomCommandSelector : CommandSelector {
private List<GeneratedTextListener> prisonerDropdownListeners = new();
private List<GeneratedTextListener> tableListeners = new();
private FactionId SelectedFactionId =>
AvailableTargetFactionIds[targetFactionDropdown.value];
private PrisonerToBeRansomed SelectedPrisonerToBeRansomed =>
@@ -190,34 +188,9 @@ namespace eagle {
options.Select(opt => opt.RansomOffer.PrisonerToBeRansomed).ToList();
prisonerToBeRansomedDropdown.ClearOptions();
ClearPrisonerDropdownListeners();
// Create empty options first, text will be populated by listeners
var emptyOptions = AvailablePrisonersToBeRansomed
.Select(
_ => new TMP_Dropdown.OptionData { text = "" })
.ToList();
prisonerToBeRansomedDropdown.AddOptions(emptyOptions);
// Set up listeners for each prisoner's name text
for (int i = 0; i < AvailablePrisonersToBeRansomed.Count; i++) {
var prisoner = AvailablePrisonersToBeRansomed[i];
var hero = _model.Heroes[prisoner.PrisonerHeroId];
if (!string.IsNullOrEmpty(hero.NameTextId)) {
var listener = new GeneratedTextListener(
hero.NameTextId,
"",
"",
i,
SetPrisonerDropdownText);
prisonerDropdownListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
}
prisonerToBeRansomedDropdown.AddOptions(
AvailablePrisonersToBeRansomed.Select(p => _model.Heroes[p.PrisonerHeroId].Name)
.ToList());
prisonerToBeRansomedDropdown.value = 0;
AvailableHostages = RansomOffer.HostagesOffered.Select(p => p.HeroId).ToList();
@@ -226,91 +199,21 @@ namespace eagle {
UpdateTables();
}
private void ClearPrisonerDropdownListeners() {
foreach (var listener in prisonerDropdownListeners) {
ClientTextProvider.Provider.RemoveListener(listener);
}
prisonerDropdownListeners.Clear();
}
private void ClearTableListeners() {
foreach (var listener in tableListeners) {
ClientTextProvider.Provider.RemoveListener(listener);
}
tableListeners.Clear();
}
private void SetPrisonerDropdownText(int index, string text) {
if (index >= prisonerToBeRansomedDropdown.options.Count) return;
prisonerToBeRansomedDropdown.options[index].text = text;
prisonerToBeRansomedDropdown.RefreshShownValue();
}
private void UpdateTables() {
ClearTableListeners();
prisonersTable.RowCount = 0;
int prisonerIndex = 0;
foreach (HeroId prisonerHid in PrisonerHeroIds) {
var row = prisonersTable.AddRowWithComponent<AvailableHeroTableRow>();
var hero = _model.Heroes[prisonerHid];
// Start with empty name, will be populated by listener
row.HeroName = "";
row.HeroName = _model.Heroes[prisonerHid].Name;
row.Selectable = true;
row.Selected = false;
// Set up listener for this table row
if (!string.IsNullOrEmpty(hero.NameTextId)) {
var listener = new GeneratedTextListener(
hero.NameTextId,
"",
"",
prisonerIndex,
(index, text) => {
if (index < prisonersTable.RowCount) {
var tableRow = prisonersTable.GetComponentInChildren<
AvailableHeroTableRow>();
if (tableRow != null) tableRow.HeroName = text;
}
});
tableListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
prisonerIndex++;
}
hostagesTable.RowCount = 0;
int hostageIndex = 0;
foreach (HeroId hostageHid in HostageHeroIds) {
var row = hostagesTable.AddRowWithComponent<AvailableHeroTableRow>();
var hero = _model.Heroes[hostageHid];
// Start with empty name, will be populated by listener
row.HeroName = "";
row.HeroName = _model.Heroes[hostageHid].Name;
row.Selectable = true;
row.Selected = false;
// Set up listener for this table row
if (!string.IsNullOrEmpty(hero.NameTextId)) {
var listener = new GeneratedTextListener(
hero.NameTextId,
"",
"",
hostageIndex,
(index, text) => {
if (index < hostagesTable.RowCount) {
var tableRow = hostagesTable.GetComponentInChildren<
AvailableHeroTableRow>();
if (tableRow != null) tableRow.HeroName = text;
}
});
tableListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
hostageIndex++;
}
}
@@ -323,10 +226,5 @@ namespace eagle {
PrisonerHeroIds.RemoveAt(index);
UpdateTables();
}
void OnDestroy() {
ClearPrisonerDropdownListeners();
ClearTableListeners();
}
}
}
@@ -5,40 +5,13 @@ using Net.Eagle0.Eagle.Common;
namespace eagle {
public class ResolveAllianceCommandSelector : ResolveDiplomacyCommandSelector {
private GeneratedTextListener messengerTextListener;
private string cachedPrefixText = "";
protected override IList<DiplomacyOffer> Offers =>
_availableCommand?.ResolveAllianceOfferCommand?.Offers.ToList() ??
new List<DiplomacyOffer>();
protected override string PrefixedText(DiplomacyOffer offer) {
SetupMessengerTextListener();
return !string.IsNullOrEmpty(cachedPrefixText)
? cachedPrefixText
: $"{Model.FactionName(OriginatingFactionId)}'s representative:\n";
}
private void ClearMessengerTextListener() {
if (messengerTextListener != null) {
ClientTextProvider.Provider.RemoveListener(messengerTextListener);
messengerTextListener = null;
}
}
private void SetupMessengerTextListener() {
if (messengerTextListener != null) return; // Already set up
var messengerHero = Model.Heroes[MessengerHeroId];
if (!string.IsNullOrEmpty(messengerHero.NameTextId)) {
messengerTextListener = new GeneratedTextListener(
messengerHero.NameTextId,
$"{Model.FactionName(OriginatingFactionId)}'s representative ",
":\n",
0,
(index, text) => { cachedPrefixText = text; });
ClientTextProvider.Provider.AddListener(messengerTextListener);
}
var messengerName = Model.Heroes[MessengerHeroId].Name;
return $"{Model.FactionName(OriginatingFactionId)}'s representative {messengerName}:\n";
}
protected override string TitleText => "Alliance Proposed";
@@ -49,7 +22,5 @@ namespace eagle {
OriginatingFactionId = OriginatingFactionId
}
};
void OnDestroy() { ClearMessengerTextListener(); }
}
}
@@ -5,40 +5,13 @@ using Net.Eagle0.Eagle.Common;
namespace eagle {
public class ResolveBreakAllianceCommandSelector : ResolveDiplomacyCommandSelector {
private GeneratedTextListener messengerTextListener;
private string cachedPrefixText = "";
protected override IList<DiplomacyOffer> Offers =>
_availableCommand?.ResolveBreakAllianceCommand?.Offers.ToList() ??
new List<DiplomacyOffer>();
protected override string PrefixedText(DiplomacyOffer offer) {
SetupMessengerTextListener();
return !string.IsNullOrEmpty(cachedPrefixText)
? cachedPrefixText
: $"{Model.FactionName(OriginatingFactionId)}'s representative:\n";
}
private void ClearMessengerTextListener() {
if (messengerTextListener != null) {
ClientTextProvider.Provider.RemoveListener(messengerTextListener);
messengerTextListener = null;
}
}
private void SetupMessengerTextListener() {
if (messengerTextListener != null) return; // Already set up
var messengerHero = Model.Heroes[MessengerHeroId];
if (!string.IsNullOrEmpty(messengerHero.NameTextId)) {
messengerTextListener = new GeneratedTextListener(
messengerHero.NameTextId,
$"{Model.FactionName(OriginatingFactionId)}'s representative ",
":\n",
0,
(index, text) => { cachedPrefixText = text; });
ClientTextProvider.Provider.AddListener(messengerTextListener);
}
var messengerName = Model.Heroes[MessengerHeroId].Name;
return $"{Model.FactionName(OriginatingFactionId)}'s representative {messengerName}:\n";
}
protected override string TitleText => "Alliance Broken";
@@ -49,7 +22,5 @@ namespace eagle {
OriginatingFactionId = OriginatingFactionId
}
};
void OnDestroy() { ClearMessengerTextListener(); }
}
}
@@ -6,41 +6,12 @@ using Net.Eagle0.Eagle.Common;
namespace eagle {
public class ResolveInvitationCommandSelector : ResolveDiplomacyCommandSelector {
private GeneratedTextListener messengerTextListener;
private string cachedPrefixText = "";
protected override IList<DiplomacyOffer> Offers =>
_availableCommand?.ResolveInvitationCommand?.Invitations.ToList() ??
new List<DiplomacyOffer>();
protected override string PrefixedText(DiplomacyOffer offer) {
SetupMessengerTextListener();
return !string.IsNullOrEmpty(cachedPrefixText)
? cachedPrefixText
: $"A messenger brings an invitation to join {Model.FactionName(OriginatingFactionId)}.\n";
}
private void ClearMessengerTextListener() {
if (messengerTextListener != null) {
ClientTextProvider.Provider.RemoveListener(messengerTextListener);
messengerTextListener = null;
}
}
private void SetupMessengerTextListener() {
if (messengerTextListener != null) return; // Already set up
var messengerHero = Model.Heroes[MessengerHeroId];
if (!string.IsNullOrEmpty(messengerHero.NameTextId)) {
messengerTextListener = new GeneratedTextListener(
messengerHero.NameTextId,
"",
$" brings an invitation to join {Model.FactionName(OriginatingFactionId)}.\n",
0,
(index, text) => { cachedPrefixText = text; });
ClientTextProvider.Provider.AddListener(messengerTextListener);
}
}
protected override string PrefixedText(DiplomacyOffer offer) =>
$"{Model.Heroes[MessengerHeroId].Name} brings an invitation to join {Model.FactionName(OriginatingFactionId)}.\n";
protected override string TitleText => "Invited";
public override SelectedCommand selectedCommand(DiplomacyOfferStatus resolution) =>
@@ -51,7 +22,5 @@ namespace eagle {
OriginatingFactionId = OriginatingFactionId
}
};
void OnDestroy() { ClearMessengerTextListener(); }
}
}
@@ -65,7 +65,7 @@ namespace eagle {
var demandingFaction = _model.MaybeDestroyedFaction(SelectedDemand.DemandingFactionId);
factionDescription.text =
$"{DisplayNames.ResolvedFactionString(demandingFaction, _model)} demands tribute!";
$"{DisplayNames.FactionString(demandingFaction, _model)} demands tribute!";
goldAmountLabel.text = SelectedDemand.TributeDemanded.Gold.ToString();
foodAmountLabel.text = SelectedDemand.TributeDemanded.Food.ToString();
@@ -5,40 +5,13 @@ using Net.Eagle0.Eagle.Common;
namespace eagle {
public class ResolveTruceCommandSelector : ResolveDiplomacyCommandSelector {
private GeneratedTextListener messengerTextListener;
private string cachedPrefixText = "";
protected override IList<DiplomacyOffer> Offers =>
_availableCommand?.ResolveTruceOfferCommand?.Offers.ToList() ??
new List<DiplomacyOffer>();
protected override string PrefixedText(DiplomacyOffer offer) {
SetupMessengerTextListener();
return !string.IsNullOrEmpty(cachedPrefixText)
? cachedPrefixText
: $"{Model.FactionName(OriginatingFactionId)}'s representative:\n";
}
private void ClearMessengerTextListener() {
if (messengerTextListener != null) {
ClientTextProvider.Provider.RemoveListener(messengerTextListener);
messengerTextListener = null;
}
}
private void SetupMessengerTextListener() {
if (messengerTextListener != null) return; // Already set up
var messengerHero = Model.Heroes[MessengerHeroId];
if (!string.IsNullOrEmpty(messengerHero.NameTextId)) {
messengerTextListener = new GeneratedTextListener(
messengerHero.NameTextId,
$"{Model.FactionName(OriginatingFactionId)}'s representative ",
":\n",
0,
(index, text) => { cachedPrefixText = text; });
ClientTextProvider.Provider.AddListener(messengerTextListener);
}
var messengerName = Model.Heroes[MessengerHeroId].Name;
return $"{Model.FactionName(OriginatingFactionId)}'s representative {messengerName}:\n";
}
protected override string TitleText => "Truce Proposed";
@@ -50,7 +23,5 @@ namespace eagle {
OriginatingFactionId = OriginatingFactionId
}
};
void OnDestroy() { ClearMessengerTextListener(); }
}
}
@@ -39,7 +39,12 @@ namespace eagle {
? $" ({GUIUtils.ConditionString(ConditionAccessor(hero), ConditionName)})"
: "",
i,
SetGeneratedText);
(index, text) => {
if (index < Dropdown.options.Count) {
options[index].text = text;
AssignOptions();
}
});
listeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
@@ -49,14 +54,6 @@ namespace eagle {
}
}
public string FallbackText = "";
private void SetGeneratedText(int index, string text) {
if (index >= Dropdown.options.Count) return;
options[index].text = text;
AssignOptions();
}
private List<TMP_Dropdown.OptionData> options = new();
public void AssignOptions() {
Dropdown.ClearOptions();
@@ -118,7 +115,7 @@ namespace eagle {
private void SetHeadshot() {
var imagePath = AvailableHeroes[Dropdown.value].ImagePath;
if (!string.IsNullOrEmpty(imagePath)) {
ResourceFetcher.headshotFetcher.LoadIntoRawImage(HeadshotImage, imagePath);
AWSResourceFetcher.headshotFetcher.LoadIntoRawImage(HeadshotImage, imagePath);
}
}
@@ -135,7 +132,7 @@ namespace eagle {
}
private TMP_Dropdown.OptionData HeroOption(HeroView hv) {
var texture = !Textures ? null : Textures.Profession(hv.Profession);
var texture = Textures.Profession(hv.Profession);
var op = new TMP_Dropdown.OptionData {
image = !texture ? null
@@ -143,7 +140,7 @@ namespace eagle {
texture,
new Rect(0, 0, texture.width, texture.height),
new Vector2(0.5f, 0.5f)),
text = String.IsNullOrEmpty(hv.NameTextId) ? FallbackText : ""
text = ""
};
return op;
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Net.Eagle0.Eagle.Api.Command.Util;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
@@ -78,6 +79,128 @@ namespace eagle {
}
}
public static string ShortQuestString(Quest quest, IGameModel model) {
if (quest == null) { return $"There is nothing you can do"; }
switch (quest.Details.SealedValueCase) {
case SealedValueOneofCase.AllianceQuest:
return $"Secure an alliance with another faction";
case SealedValueOneofCase.DefeatFactionQuest:
return $"Defeat {model.FactionName(quest.Details.DefeatFactionQuest.TargetFactionId)}";
case SealedValueOneofCase.AlmsToProvinceQuest: {
var province = model.Provinces[quest.Details.AlmsToProvinceQuest.ProvinceId];
var desiredFood = quest.Details.AlmsToProvinceQuest.TotalFood;
var remainingFood = Math.Max(0, desiredFood - quest.ComponentsFulfilled);
var remainingText =
quest.ComponentsFulfilled > 0 ? $" ({remainingFood} remaining)" : "";
return $"Give {desiredFood} food in alms in {province.Name}{remainingText}";
}
case SealedValueOneofCase.AlmsAcrossRealmQuest: {
var desiredFood = quest.Details.AlmsAcrossRealmQuest.TotalFood;
var remainingFood = Math.Max(0, desiredFood - quest.ComponentsFulfilled);
var remainingText =
quest.ComponentsFulfilled > 0 ? $" ({remainingFood} remaining)" : "";
return $"Give {desiredFood} food in alms across the realm{remainingText}";
}
case SealedValueOneofCase.GiveToHeroesInProvinceQuest: {
var province =
model.Provinces[quest.Details.GiveToHeroesInProvinceQuest.ProvinceId];
var desiredGold = quest.Details.GiveToHeroesInProvinceQuest.TotalGold;
var remainingGold = Math.Max(0, desiredGold - quest.ComponentsFulfilled);
var remainingText =
quest.ComponentsFulfilled > 0 ? $" ({remainingGold} remaining)" : "";
return $"Give {desiredGold} gold to heroes in {province.Name}{remainingText}";
}
case SealedValueOneofCase.GiveToHeroesAcrossRealmQuest: {
var desiredGold = quest.Details.GiveToHeroesAcrossRealmQuest.TotalGold;
var remainingGold = Math.Max(0, desiredGold - quest.ComponentsFulfilled);
var remainingText =
quest.ComponentsFulfilled > 0 ? $" ({remainingGold} remaining)" : "";
return $"Give {desiredGold} gold to heroes across the realm{remainingText}";
}
case SealedValueOneofCase.ImproveAgricultureQuest:
return $"Raise Agriculture to {(int)Math.Ceiling(quest.Details.ImproveAgricultureQuest.DesiredValue)}";
case SealedValueOneofCase.ImproveEconomyQuest:
return $"Raise Economy to {(int)Math.Ceiling(quest.Details.ImproveEconomyQuest.DesiredValue)}";
case SealedValueOneofCase.ImproveInfrastructureQuest:
return $"Raise Infrastructure to {(int)Math.Ceiling(quest.Details.ImproveInfrastructureQuest.DesiredValue)}";
case SealedValueOneofCase.SpecificExpansionQuest:
return $"Expand to {model.Provinces[quest.Details.SpecificExpansionQuest.ProvinceId].Name}";
case SealedValueOneofCase.DismissSpecificVassalQuest:
return $"Dismiss {model.Heroes[quest.Details.DismissSpecificVassalQuest.TargetHeroId].Name}";
case SealedValueOneofCase.WealthQuest:
return $"Acquire {quest.Details.WealthQuest.Gold} gold and {quest.Details.WealthQuest.Food} food";
case SealedValueOneofCase.UpgradeBattalionQuest:
return $"Raise a {LongBattalionTypeNames[quest.Details.UpgradeBattalionQuest.BattalionTypeId]} to {(int)Math.Ceiling(quest.Details.UpgradeBattalionQuest.MinimumTraining)} training and {(int)Math.Ceiling(quest.Details.UpgradeBattalionQuest.MinimumArmament)} armament";
case SealedValueOneofCase.GrandArmyQuest:
return $"Have {quest.Details.GrandArmyQuest.TotalTroopCount} troops";
case SealedValueOneofCase.TruceWithFactionQuest:
return $"Sign a truce with {model.FactionName(quest.Details.TruceWithFactionQuest.TargetFactionId)}";
case SealedValueOneofCase.TruceCountQuest: {
var currentTruceString = "";
if (model.PlayerId is int fid) {
var currentTruces = model.ActiveFactions[fid].FactionRelationships.Count(
fr => fr.RelationshipLevel !=
FactionRelationshipView.Types.RelationshipLevel.Hostile);
if (currentTruces == 0) {
currentTruceString = " (currently have none)";
} else {
currentTruceString = " (currently have " + currentTruces + ")";
}
}
return $"Have truces with {quest.Details.TruceCountQuest.TruceCount} factions" +
currentTruceString;
}
case SealedValueOneofCase.ExecutePrisonerQuest: {
var details = quest.Details.ExecutePrisonerQuest;
var prisonerName = model.Heroes[details.PrisonerHeroId].Name;
var provinceName = model.Provinces[details.ProvinceId].Name;
return $"Execute prisoner {prisonerName} in {provinceName}";
}
case SealedValueOneofCase.ExilePrisonerQuest: {
var details = quest.Details.ExilePrisonerQuest;
var prisonerName = model.Heroes[details.PrisonerHeroId].Name;
var provinceName = model.Provinces[details.ProvinceId].Name;
return $"Exile prisoner {prisonerName} from {provinceName}";
}
case SealedValueOneofCase.FreePrisonerQuest: {
var details = quest.Details.FreePrisonerQuest;
var prisonerName = model.Heroes[details.PrisonerHeroId].Name;
var provinceName = model.Provinces[details.ProvinceId].Name;
return $"Release {prisonerName} from prison in {provinceName}";
}
case SealedValueOneofCase.ReturnPrisonerQuest: {
var details = quest.Details.ReturnPrisonerQuest;
var prisonerName = model.Heroes[details.PrisonerHeroId].Name;
var provinceName = model.Provinces[details.ProvinceId].Name;
var factionName = model.FactionName(details.ToFactionId);
return $"Return prisoner {prisonerName} from {provinceName} to {factionName}";
}
default:
return $"Crosby hasn't gotten to quest of type {quest.Details.SealedValueCase} yet";
}
}
public static string RecruitmentStatusString(string name, RecruitmentInfo recruitmentInfo) {
switch (recruitmentInfo.Status) {
case RecruitmentStatus.LowPrestige:
@@ -137,7 +260,6 @@ namespace eagle {
switch (quest.Details.SealedValueCase) {
case SealedValueOneofCase.DefeatFactionQuest: return "Attack Faction";
case SealedValueOneofCase.DismissSpecificVassalQuest: return "Dismiss Vassal";
case SealedValueOneofCase.AllianceQuest: return "Secure Alliance";
case SealedValueOneofCase.AlmsToProvinceQuest: return "Give Alms in Province";
case SealedValueOneofCase.AlmsAcrossRealmQuest: return "Give Alms Across Realm";
case SealedValueOneofCase.GiveToHeroesInProvinceQuest:
@@ -167,7 +289,7 @@ namespace eagle {
return new DateTime(777, month, 1).ToString("MMMM", CultureInfo.InvariantCulture);
}
private static string MonthString(Date date) {
public static string MonthString(Date date) {
return new DateTime(date.Year, date.Month, 1)
.ToString("MMMM", CultureInfo.InvariantCulture);
}
@@ -185,7 +307,7 @@ namespace eagle {
case UnaffiliatedHeroType.UnaffiliatedHeroOutlaw: return "Outlaw";
case UnaffiliatedHeroType.UnaffiliatedHeroUnknown:
default: throw new ArgumentException("Unknown free hero status");
default: throw new ArgumentOutOfRangeException("Unknown free hero status");
}
}
@@ -200,7 +322,7 @@ namespace eagle {
case ControlWeatherType.ControlWeatherEndDrought: return "End Drought";
case ControlWeatherType.ControlWeatherUnknown:
default: throw new ArgumentException("Unknown control weather type");
default: throw new ArgumentOutOfRangeException("Unknown control weather type");
}
}
@@ -214,16 +336,14 @@ namespace eagle {
}
}
public static string MaybeFactionLeaderDescriptionTemplate(
FactionView faction,
HeroView hero,
IGameModel currentModel) {
public static string
MaybeFactionLeaderDescription(FactionView faction, HeroView hero, IGameModel currentModel) {
if (faction.FactionHeadId == hero.Id) {
return "faction leader {HeroName}";
return $"faction leader {hero.Name}";
} else if (faction.Leaders.Contains(hero.Id)) {
return $"{FactionStringTemplate(faction, currentModel)}'s sworn {SiblingDescription(hero.PronounGender)} {{HeroName}}";
return $"{FactionString(faction, currentModel)}'s sworn {SiblingDescription(hero.PronounGender)} {hero.Name}";
} else {
return $"{FactionStringTemplate(faction, currentModel)}'s hero {{HeroName}}";
return $"{FactionString(faction, currentModel)}'s hero {hero.Name}";
}
}
@@ -232,7 +352,7 @@ namespace eagle {
else if (str.Length == 1)
return str.ToUpper();
else
return char.ToUpper(str[0]) + str[1..];
return char.ToUpper(str[0]) + str.Substring(1);
}
public static string ListString(IList<String> strings) {
@@ -243,7 +363,7 @@ namespace eagle {
} else {
string str = "";
for (int i = 0; i < strings.Count - 1; i++) { str += strings[i] + ", "; }
str += "and " + strings[^1];
str += "and " + strings[strings.Count - 1];
return str;
}
@@ -262,36 +382,18 @@ namespace eagle {
return ListString(list);
}
private static string ConditionalFactionStringTemplate(
FactionView faction,
IGameModel gameModel,
bool useFactionName) {
private static string
ConditionalFactionString(FactionView faction, IGameModel gameModel, bool useFactionName) {
if (useFactionName) return faction.Name;
return "{FactionLeaderName}";
return gameModel.Heroes[faction.FactionHeadId].Name;
}
public static string FactionStringTemplate(FactionView faction, IGameModel gameModel) {
return ConditionalFactionStringTemplate(faction, gameModel, DisplayFactionName);
public static string FactionString(FactionView faction, IGameModel gameModel) {
return ConditionalFactionString(faction, gameModel, DisplayFactionName);
}
public static string AlternateFactionStringTemplate(
FactionView faction,
IGameModel gameModel) {
return ConditionalFactionStringTemplate(faction, gameModel, !DisplayFactionName);
}
public static string ResolvedFactionString(FactionView faction, IGameModel gameModel) {
var template = FactionStringTemplate(faction, gameModel);
if (template.Contains("{FactionLeaderName}")) {
var hero = gameModel.Heroes[faction.FactionHeadId];
var textEntry = ClientTextProvider.Provider.GetTextEntry(hero.NameTextId);
if (textEntry != null && !string.IsNullOrEmpty(textEntry.Text)) {
return template.Replace("{FactionLeaderName}", textEntry.Text);
} else {
return template.Replace("{FactionLeaderName}", "Leader");
}
}
return template;
public static string AlternateFactionString(FactionView faction, IGameModel gameModel) {
return ConditionalFactionString(faction, gameModel, !DisplayFactionName);
}
}
}
@@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Linq;
using common;
using common.GUIUtils;
using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
@@ -46,7 +45,7 @@ namespace eagle {
public ProvinceId? SelectedProvince => mapController.SelectedProvinceId;
public ErrorHandler errorHandler;
public ErrorPanel errorPanel;
public NotificationPanel notificationPanel;
public GameObject popupCanvas;
@@ -78,8 +77,6 @@ namespace eagle {
GameModelUpdater ModelUpdater { get; set; }
IGameModel Model { get; set; }
private DynamicFactionTextUpdater factionTextUpdater = new DynamicFactionTextUpdater();
private IGameModel _newModel = null;
public Canvas shardokCanvas;
@@ -97,8 +94,6 @@ namespace eagle {
_dominionPanelController.gameObject.SetActive(false);
showChronicleButton.gameObject.SetActive(false);
errorHandler.gameObject.SetActive(true);
ArrangeLayout();
}
@@ -232,11 +227,12 @@ namespace eagle {
gameId,
playerId,
persistentClientConnection,
rollPanelController) {
UpdateAction = ModelUpdated,
NoteRecipient = (title, text, llmId, pids, displayedHeroes) =>
notificationPanel.AddNote(title, text, llmId, pids, displayedHeroes)
};
rollPanelController);
ModelUpdater.UpdateAction = ModelUpdated;
ModelUpdater.NoteRecipient = (title, text, llmId, pids, displayedHeroes) =>
notificationPanel.AddNote(title, text, llmId, pids, displayedHeroes);
commitCommandButton.gameObject.SetActive(false);
@@ -251,7 +247,7 @@ namespace eagle {
resolveRansomOfferCommandSelector.submissionAction = sc => PostCommittedCommand(0, sc);
pleaseRecruitMeCommandSelector.submissionAction = sc => PostCommittedCommand(0, sc);
ModelUpdater.ErrorHandler = errorHandler;
ModelUpdater.errorPanel = errorPanel;
MainQueue.Q.EnqueueForNextUpdate(() => { ModelUpdater.StartListeningForUpdates(); });
@@ -338,7 +334,7 @@ namespace eagle {
}
private void PrefetchHeadshotForHeroes(List<HeroView> heroViews) {
ResourceFetcher.headshotFetcher.Prefetch(heroViews.Select(h => h.ImagePath));
AWSResourceFetcher.headshotFetcher.Prefetch(heroViews.Select(h => h.ImagePath));
}
private void PrefetchHeadshots(IGameModel model) {
@@ -414,18 +410,13 @@ namespace eagle {
void SetHeaderString() {
var date = Model.CurrentDate;
if (date != null) {
string dateString = DisplayNames.DateString(date);
string factionLabel = "Observer";
if (Model.PlayerId.HasValue) {
var faction = Model.MaybeDestroyedFaction(Model.PlayerId.Value);
factionTextUpdater.SetFactionTextWithCustomFormat(
roundStatusLabel,
faction,
Model,
$"{dateString} - {{FactionLeaderName}} ({{Prestige}})",
$"{dateString} - Observer");
} else {
roundStatusLabel.text = $"{dateString} - Observer";
factionLabel =
$"{DisplayNames.FactionString(faction, Model)} ({faction.Prestige})";
}
roundStatusLabel.text = $"{DisplayNames.DateString(date)} - {factionLabel}";
}
}
@@ -600,7 +591,7 @@ namespace eagle {
private void PostCommittedCommand(ProvinceId provinceId, SelectedCommand selectedCommand) {
ModelUpdater.PostCommand(provinceId: provinceId, command: selectedCommand)
.ContinueWith(response => {
if (response.IsFaulted) { errorHandler.Add(response.Exception); }
if (response.IsFaulted) { errorPanel.Add(response.Exception); }
});
}
@@ -655,7 +646,5 @@ namespace eagle {
var textField = gameIdButton.GetComponentInChildren<Text>();
textField.text = textField.text == "" ? ModelUpdater.GameId.ToString("X") : "";
}
private void OnDestroy() { factionTextUpdater?.Dispose(); }
}
}
@@ -4,13 +4,11 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
using common;
using common.GUIUtils;
using eagle.Notifications;
using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
using Net.Eagle0.Shardok.Common;
using TMPro;
using UnityEngine;
using UnityGoDiceInterface;
using Logger = common.Logger;
@@ -90,7 +88,7 @@ namespace eagle {
notification.DisplayedHeroes);
}
private readonly ResourceFetcher resourceFetcher;
private readonly AWSResourceFetcher _awsResourceFetcher;
public long GameId { get; }
@@ -115,7 +113,7 @@ namespace eagle {
private PersistentClientConnection PersistentConnection { get; }
public ErrorHandler ErrorHandler;
public ErrorPanel errorPanel;
private int _lastUnfilteredResultCount = 0;
@@ -178,17 +176,7 @@ namespace eagle {
return DestroyedFactions[factionId];
}
public string FactionName(FactionId factionId) {
return DisplayNames.ResolvedFactionString(MaybeDestroyedFaction(factionId), this);
}
public void SetDynamicFactionText(
TMP_Text textComponent,
FactionId factionId,
DynamicFactionTextUpdater textUpdater,
string template = null,
string fallbackText = null) {
var faction = MaybeDestroyedFaction(factionId);
textUpdater.SetFactionText(textComponent, faction, this, template, fallbackText);
return DisplayNames.FactionString(MaybeDestroyedFaction(factionId), this);
}
public RollFetcher RollFetcher { get; set; }
@@ -247,10 +235,7 @@ namespace eagle {
locationName: _currentModel.Provinces[defenderProvince].Name,
month: _currentModel.CurrentDate.Month,
players: players,
heroNames: _currentModel.Heroes.ToDictionary(
kv => kv.Key,
kv => ClientTextProvider.Provider.GetTextEntry(kv.Value.NameTextId)
.Text),
heroNames: _currentModel.Heroes.ToDictionary(kv => kv.Key, kv => kv.Value.Name),
heroImages: _currentModel.Heroes.ToDictionary(
kv => kv.Key,
kv => kv.Value.ImagePath),
@@ -314,7 +299,7 @@ namespace eagle {
break;
case GameUpdate.GameUpdateDetailsOneofCase.ErrorResponse:
ErrorHandler.HandleLog(
errorPanel.HandleLog(
$"Caught server-side error handling command {updateItem.ErrorResponse.SelectedCommand}:\n{updateItem.ErrorResponse.ErrorString}",
"",
LogType.Error);
@@ -11,14 +11,11 @@ using FactionId = System.Int32;
using ProvinceId = System.Int32;
namespace eagle {
public class FactionsTableController : MonoBehaviour, System.IDisposable {
public class FactionsTableController : MonoBehaviour {
public GameObject panel;
public GameObject factionPopup;
public TMP_Text factionPopupText;
private DynamicHeroTextUpdater textUpdater = new DynamicHeroTextUpdater();
private GeneratedTextListener currentListener;
private ProvinceView _currentProvince;
public ProvinceView CurrentProvince {
get => _currentProvince;
@@ -72,47 +69,14 @@ namespace eagle {
private string ColoredFactionString(FactionId fid) {
var color = Color.Lerp(PlayerColors.LightPlayerColor(fid), Color.black, 0.5f);
var factionName = GetResolvedFactionName(fid);
return GUIUtils.ColoredString(color, factionName);
}
private string GetResolvedFactionName(FactionId fid) {
var faction = Model.MaybeDestroyedFaction(fid);
var template = DisplayNames.FactionStringTemplate(faction, Model);
if (template.Contains("{FactionLeaderName}")) {
var hero = Model.Heroes[faction.FactionHeadId];
var textEntry = ClientTextProvider.Provider.GetTextEntry(hero.NameTextId);
if (textEntry != null && !string.IsNullOrEmpty(textEntry.Text)) {
return template.Replace("{FactionLeaderName}", textEntry.Text);
} else {
return template.Replace("{FactionLeaderName}", "Leader");
}
}
return template;
}
private string GetResolvedAlternateFactionName(FactionView faction) {
var template = DisplayNames.AlternateFactionStringTemplate(faction, Model);
if (template.Contains("{FactionLeaderName}")) {
var hero = Model.Heroes[faction.FactionHeadId];
var textEntry = ClientTextProvider.Provider.GetTextEntry(hero.NameTextId);
if (textEntry != null && !string.IsNullOrEmpty(textEntry.Text)) {
return template.Replace("{FactionLeaderName}", textEntry.Text);
} else {
return template.Replace("{FactionLeaderName}", "Leader");
}
}
return template;
return GUIUtils.ColoredString(color, Model.FactionName(fid));
}
private void LongHoveredFactionChanged(FactionId? newFid) {
if (newFid is {} fid) {
var faction = Model.MaybeDestroyedFaction(fid);
var baseText = $"{ColoredFactionString(fid)} is led by ";
var text =
$"{ColoredFactionString(fid)} is led by {Model.Heroes[faction.FactionHeadId].Name}.";
string frText = "";
foreach (var fr in faction.FactionRelationships) {
@@ -131,39 +95,12 @@ namespace eagle {
}
}
var fullText = baseText + "{FactionLeader}.";
if (frText != "") { fullText += "\n" + frText; }
if (frText != "") { text += "\n" + frText; }
if (currentListener != null) {
ClientTextProvider.Provider.RemoveListener(currentListener);
}
var hero = Model.Heroes[faction.FactionHeadId];
var textEntry = ClientTextProvider.Provider.GetTextEntry(hero.NameTextId);
if (textEntry != null && !string.IsNullOrEmpty(textEntry.Text)) {
factionPopupText.text = fullText.Replace("{FactionLeader}", textEntry.Text);
} else {
factionPopupText.text = fullText.Replace("{FactionLeader}", "Leader");
currentListener = new GeneratedTextListener(
hero.NameTextId,
baseText,
".",
0,
(index, text) => {
var updatedText = baseText + text + ".";
if (frText != "") { updatedText += "\n" + frText; }
factionPopupText.text = updatedText;
});
ClientTextProvider.Provider.AddListener(currentListener);
}
factionPopupText.text = text;
factionPopup.SetActive(true);
} else {
if (currentListener != null) {
ClientTextProvider.Provider.RemoveListener(currentListener);
currentListener = null;
}
factionPopup.SetActive(false);
}
}
@@ -223,8 +160,8 @@ namespace eagle {
}
private void SetUpFactionRow(FactionView faction, FactionTableRow row) {
row.Faction = GetResolvedFactionName(faction.Id);
row.Tooltip = GetResolvedAlternateFactionName(faction);
row.Faction = DisplayNames.FactionString(faction, Model);
row.Tooltip = DisplayNames.AlternateFactionString(faction, Model);
row.FactionColor = PlayerColors.LightPlayerColor(faction.Id);
row.ProvinceCount = ProvinceCount(faction);
@@ -331,15 +268,5 @@ namespace eagle {
.ThenBy(fac => ExistingRelationship(fac)?.ResetDate?.Year ?? 0)
.ThenBy(fac => ExistingRelationship(fac)?.ResetDate?.Month ?? 0)
.ToList();
public void Dispose() {
if (currentListener != null) {
ClientTextProvider.Provider.RemoveListener(currentListener);
currentListener = null;
}
textUpdater?.Dispose();
}
private void OnDestroy() { Dispose(); }
}
}
@@ -1,30 +0,0 @@
using System;
namespace eagle {
public class GeneratedTextListener : IClientTextListener {
private readonly string textId;
private readonly string prefix;
private readonly string suffix;
private readonly int index;
private readonly Action<int, string> onTextUpdate;
public void OnTextUpdate(string text, bool completed) {
onTextUpdate(index, $"{prefix}{text}{suffix}");
}
public string TextId() { return textId; }
public GeneratedTextListener(
string textId,
string prefix,
string suffix,
int index,
Action<int, string> callback) {
this.textId = textId;
this.prefix = prefix;
this.suffix = suffix;
this.index = index;
this.onTextUpdate = callback;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 7ed95fc26230d428fa783e0be9eb353d
@@ -15,14 +15,10 @@ namespace eagle {
_currentCompleted = false;
var scrollRect = GetComponentInParent<ScrollRect>(includeInactive: true);
if (scrollRect) { scrollRect.normalizedPosition = new Vector2(0, 1); }
if (scrollRect != null) { scrollRect.normalizedPosition = new Vector2(0, 1); }
}
if (!String.IsNullOrEmpty(_textId)) {
// If the text ID is set, we want to update the view immediately
// to reflect any existing text.
OnTextUpdate(ClientTextProvider.Provider.GetTextEntry(TextId));
}
OnTextUpdate(ClientTextProvider.Provider.GetTextEntry(TextId));
}
}
@@ -34,7 +34,7 @@ namespace eagle {
private void SetHeroBasics(HeroView value) {
if (value.ImagePath is String imagePath && !string.IsNullOrEmpty(imagePath)) {
ResourceFetcher.headshotFetcher.LoadIntoRawImage(headshotImage, value.ImagePath);
AWSResourceFetcher.headshotFetcher.LoadIntoRawImage(headshotImage, value.ImagePath);
}
professionImage.texture =
gameObject.GetComponentInParent<EagleCommonTextures>().Profession(
@@ -32,7 +32,7 @@ namespace eagle {
for (int i = 0; i < movingArmy.UnitDetails.Count; i++) {
var details = movingArmy.UnitDetails[i];
var row = movingArmyPopupTable.ComponentAt<MovingArmyPopupRowController>(i);
row.SetHeroText(Model.Heroes[details.HeroId].NameTextId, "Hero");
row.HeroName.text = Model.Heroes[details.HeroId].Name;
var battalion = details.Battalion;
if (battalion != null) {
@@ -144,27 +144,10 @@ namespace eagle {
void Start() { panel.SetActive(Model != null); }
private bool IsHostileFaction(FactionId fid) {
if (Model.PlayerId is FactionId myFid) {
if (myFid == fid) return false;
var fr = Model.MaybeDestroyedFaction(myFid).FactionRelationships.FirstOrDefault(
fr => fr.TargetFactionId == fid);
if (fr == null) return false;
return fr.RelationshipLevel ==
FactionRelationshipView.Types.RelationshipLevel.Hostile;
}
return false;
}
private int MovingArmiesCount => MovingArmies.Count();
private IEnumerable<IncomingArmyView> MovingArmies =>
Model.Provinces.Values.SelectMany(p => p.KnownIncomingArmies)
.OrderByDescending(a => IsHostileFaction(a.FactionId))
.ThenByDescending(a => a.FactionId == Model.PlayerId)
.OrderBy(a => a.FactionId == Model.PlayerId)
.ThenBy(a => CurrentProvince != null &&
a.DestinationProvinceId != CurrentProvince.Id)
.ThenByDescending(a => a.HeroCount);
@@ -27,34 +27,27 @@ namespace eagle.Notifications.ARNNotifications {
currentModel.MaybeDestroyedFaction(imprisoningFactionId).FactionHeadId;
var imprisoningFactionHead = currentModel.Heroes[imprisoningFactionHeadId];
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "ImprisonedAmbassador", (imprisonedHero.NameTextId, "the ambassador") }
};
if (playerId.HasValue && playerId.Value == imprisoningFactionId) {
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Ambassador Imprisoned",
textTemplate:
$"You have rejected an alliance with {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {{ImprisonedAmbassador}}.\n\n",
heroPlaceholders: heroPlaceholders,
prefixedText:
$"You have rejected an alliance with {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {imprisonedHero.Name}.\n\n",
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { offeringFactionHead, imprisonedHero});
} else if (playerId.HasValue && playerId.Value == offeringFactionId) {
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Ambassador Imprisoned",
textTemplate:
$"{currentModel.FactionName(imprisoningFactionId)} has imprisoned our ambassador {{ImprisonedAmbassador}}!\n\n",
heroPlaceholders: heroPlaceholders,
prefixedText:
$"{currentModel.FactionName(imprisoningFactionId)} has imprisoned our ambassador {imprisonedHero.Name}!\n\n",
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { imprisoningFactionHead, imprisonedHero});
} else {
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Ambassador Imprisoned",
textTemplate:
$"{currentModel.FactionName(imprisoningFactionId)} has rejected an alliance from {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {{ImprisonedAmbassador}}.\n\n",
heroPlaceholders: heroPlaceholders,
prefixedText:
$"{currentModel.FactionName(imprisoningFactionId)} has rejected an alliance from {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {imprisonedHero.Name}.\n\n",
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { imprisoningFactionHead, offeringFactionHead, imprisonedHero });
@@ -27,34 +27,27 @@ namespace eagle.Notifications.ARNNotifications {
currentModel.MaybeDestroyedFaction(imprisoningFactionId).FactionHeadId;
var imprisoningFactionHead = currentModel.Heroes[imprisoningFactionHeadId];
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "ImprisonedAmbassador", (imprisonedHero.NameTextId, "the ambassador") }
};
if (playerId.HasValue && playerId.Value == imprisoningFactionId) {
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Ambassador Imprisoned",
textTemplate:
$"{currentModel.FactionName(offeringFactionId)} broke their alliance with us, and we and imprisoned their ambassador {{ImprisonedAmbassador}}.\n\n",
heroPlaceholders: heroPlaceholders,
prefixedText:
$"{currentModel.FactionName(offeringFactionId)} broke their alliance with us, and we and imprisoned their ambassador {imprisonedHero.Name}.\n\n",
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { offeringFactionHead, imprisonedHero});
} else if (playerId.HasValue && playerId.Value == offeringFactionId) {
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Ambassador Imprisoned",
textTemplate:
$"{currentModel.FactionName(imprisoningFactionId)} has imprisoned our ambassador {{ImprisonedAmbassador}} in response to us breaking the alliance!\n\n",
heroPlaceholders: heroPlaceholders,
prefixedText:
$"{currentModel.FactionName(imprisoningFactionId)} has imprisoned our ambassador {imprisonedHero.Name} in response to us breaking the alliance!\n\n",
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { imprisoningFactionHead, imprisonedHero});
} else {
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Ambassador Imprisoned",
textTemplate:
$"{currentModel.FactionName(offeringFactionId)} broke their alliance with {currentModel.FactionName(imprisoningFactionId)}, and {currentModel.FactionName(imprisoningFactionId)} imprisoned their ambassador {{ImprisonedAmbassador}}.\n\n",
heroPlaceholders: heroPlaceholders,
prefixedText:
$"{currentModel.FactionName(offeringFactionId)} broke their alliance with {currentModel.FactionName(imprisoningFactionId)}, and {currentModel.FactionName(imprisoningFactionId)} imprisoned their ambassador {imprisonedHero.Name}.\n\n",
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { imprisoningFactionHead, offeringFactionHead, imprisonedHero });
@@ -24,56 +24,29 @@ namespace eagle.Notifications.ARNNotifications {
fid && fid == currentModel.PlayerId;
string noteTitle = "Hero Executed";
string textTemplate;
string noteText;
HeroView firstHero = currentModel.Heroes[actingFaction.FactionHeadId];
HeroView secondHero = executedHero;
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)>();
if (isMyHero) {
if (executedHero.IsFactionLeader) {
textTemplate =
$"{DisplayNames.ResolvedFactionString(actingFaction, currentModel)} has executed our sworn {DisplayNames.SiblingDescription(executedHero.PronounGender)} {{ExecutedHero}} in {province.Name}! They must pay for this atrocity!\n\n";
heroPlaceholders["ExecutedHero"] = (executedHero.NameTextId, "our leader");
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has executed our sworn {DisplayNames.SiblingDescription(executedHero.PronounGender)} {executedHero.Name} in {province.Name}! They must pay for this atrocity!\n\n";
} else {
textTemplate =
$"{DisplayNames.ResolvedFactionString(actingFaction, currentModel)} has executed our loyal vassal, {{ExecutedHero}} in {province.Name}!\n\n";
heroPlaceholders["ExecutedHero"] = (executedHero.NameTextId, "our vassal");
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has executed our loyal vassal, {executedHero.Name} in {province.Name}!\n\n";
}
} else if (isMyAction) {
string victimDescription;
if (victimFaction.FactionHeadId == executedHero.Id) {
victimDescription = "faction leader {HeroName}";
} else if (victimFaction.Leaders.Contains(executedHero.Id)) {
victimDescription =
$"{DisplayNames.ResolvedFactionString(victimFaction, currentModel)}'s sworn {DisplayNames.SiblingDescription(executedHero.PronounGender)} {{HeroName}}";
} else {
victimDescription =
$"{DisplayNames.ResolvedFactionString(victimFaction, currentModel)}'s hero {{HeroName}}";
}
textTemplate = $"We executed {victimDescription} in {province.Name}.\n\n";
heroPlaceholders["HeroName"] = (executedHero.NameTextId, "the hero");
noteText =
$"We executed {DisplayNames.MaybeFactionLeaderDescription(victimFaction, executedHero, currentModel)} in {province.Name}.\n\n";
} else {
string victimDescription;
if (victimFaction.FactionHeadId == executedHero.Id) {
victimDescription = "faction leader {HeroName}";
} else if (victimFaction.Leaders.Contains(executedHero.Id)) {
victimDescription =
$"{DisplayNames.ResolvedFactionString(victimFaction, currentModel)}'s sworn {DisplayNames.SiblingDescription(executedHero.PronounGender)} {{HeroName}}";
} else {
victimDescription =
$"{DisplayNames.ResolvedFactionString(victimFaction, currentModel)}'s hero {{HeroName}}";
}
textTemplate =
$"{DisplayNames.ResolvedFactionString(actingFaction, currentModel)} executed {victimDescription} in {province.Name}.\n\n";
heroPlaceholders["HeroName"] = (executedHero.NameTextId, "the hero");
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} executed {DisplayNames.MaybeFactionLeaderDescription(victimFaction, executedHero, currentModel)} in {province.Name}.\n\n";
}
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: noteTitle,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
prefixedText: noteText,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId> { details.ProvinceId },
displayedHeroes: new List<HeroView> { firstHero, secondHero });
@@ -25,56 +25,29 @@ namespace eagle.Notifications.ARNNotifications {
fid && fid == currentModel.PlayerId;
string noteTitle = "Hero Exiled";
string textTemplate;
string noteText = "";
HeroView firstHero = currentModel.Heroes[actingFaction.FactionHeadId];
HeroView secondHero = exiledHero;
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)>();
if (isMyHero) {
if (exiledHero.IsFactionLeader) {
textTemplate =
$"{{ActingFactionLeader}} has exiled our sworn {DisplayNames.SiblingDescription(exiledHero.PronounGender)} {{ExiledHero}} from {province.Name}!\n\n";
heroPlaceholders["ExiledHero"] = (exiledHero.NameTextId, "our leader");
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has exiled our sworn {DisplayNames.SiblingDescription(exiledHero.PronounGender)} {exiledHero.Name} from {province.Name}!\n\n";
} else {
textTemplate =
$"{{ActingFactionLeader}} has exiled our loyal vassal, {{ExiledHero}} from {province.Name}!\n\n";
heroPlaceholders["ExiledHero"] = (exiledHero.NameTextId, "our vassal");
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has exiled our loyal vassal, {exiledHero.Name} from {province.Name}!\n\n";
}
heroPlaceholders["ActingFactionLeader"] =
(currentModel.Heroes[actingFaction.FactionHeadId].NameTextId,
actingFaction.Name);
} else if (isMyAction) {
textTemplate = $"We exiled {{ExiledHero}} from {province.Name}.\n\n";
heroPlaceholders["ExiledHero"] = (exiledHero.NameTextId, "the hero");
noteText = $"We exiled {exiledHero.Name} from {province.Name}.\n\n";
} else {
string victimDescription;
if (victimFaction.FactionHeadId == exiledHero.Id) {
victimDescription = "faction leader {HeroName}";
} else if (victimFaction.Leaders.Contains(exiledHero.Id)) {
victimDescription =
$"{{VictimFactionLeader}}'s sworn {DisplayNames.SiblingDescription(exiledHero.PronounGender)} {{HeroName}}";
} else {
victimDescription = $"{{VictimFactionLeader}}'s hero {{HeroName}}";
}
textTemplate =
$"{{ActingFactionLeader}} has exiled {victimDescription} from {province.Name}.\n\n";
heroPlaceholders["HeroName"] = (exiledHero.NameTextId, "the hero");
heroPlaceholders["ActingFactionLeader"] =
(currentModel.Heroes[actingFaction.FactionHeadId].NameTextId,
actingFaction.Name);
if (!victimFaction.FactionHeadId.Equals(exiledHero.Id)) {
heroPlaceholders["VictimFactionLeader"] =
(currentModel.Heroes[victimFaction.FactionHeadId].NameTextId,
victimFaction.Name);
}
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has exiled {DisplayNames.MaybeFactionLeaderDescription(victimFaction, exiledHero, currentModel)} from {province.Name}.\n\n";
}
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: noteTitle,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
prefixedText: noteText,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId> { details.ProvinceId },
displayedHeroes: new List<HeroView> { firstHero, secondHero });
@@ -24,70 +24,30 @@ namespace eagle.Notifications.ARNNotifications {
fid && fid == currentModel.PlayerId;
string noteTitle = "Hero Imprisoned";
string textTemplate;
string noteText;
HeroView firstHero = currentModel.Heroes[actingFaction.FactionHeadId];
HeroView secondHero = imprisonedHero;
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)>();
if (isMyHero) {
if (imprisonedHero.IsFactionLeader) {
textTemplate =
$"{{ActingFactionLeader}} has imprisoned our sworn {DisplayNames.SiblingDescription(imprisonedHero.PronounGender)} {{ImprisonedHero}} in {province.Name}!\n\n";
heroPlaceholders["ImprisonedHero"] = (imprisonedHero.NameTextId, "our leader");
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has imprisoned our sworn {DisplayNames.SiblingDescription(imprisonedHero.PronounGender)} {imprisonedHero.Name} in {province.Name}!\n\n";
} else {
textTemplate =
$"{{ActingFactionLeader}} has imprisoned our loyal vassal, {{ImprisonedHero}} in {province.Name}!\n\n";
heroPlaceholders["ImprisonedHero"] = (imprisonedHero.NameTextId, "our vassal");
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has imprisoned our loyal vassal, {imprisonedHero.Name} in {province.Name}!\n\n";
}
heroPlaceholders["ActingFactionLeader"] =
(currentModel.Heroes[actingFaction.FactionHeadId].NameTextId,
actingFaction.Name);
} else if (isMyAction) {
string victimDescription;
if (victimFaction.FactionHeadId == imprisonedHero.Id) {
victimDescription = "faction leader {HeroName}";
} else if (victimFaction.Leaders.Contains(imprisonedHero.Id)) {
victimDescription =
$"{{VictimFactionLeader}}'s sworn {DisplayNames.SiblingDescription(imprisonedHero.PronounGender)} {{HeroName}}";
} else {
victimDescription = $"{{VictimFactionLeader}}'s hero {{HeroName}}";
}
textTemplate = $"We imprisoned {victimDescription} in {province.Name}.\n\n";
heroPlaceholders["HeroName"] = (imprisonedHero.NameTextId, "the hero");
if (!victimFaction.FactionHeadId.Equals(imprisonedHero.Id)) {
heroPlaceholders["VictimFactionLeader"] =
(currentModel.Heroes[victimFaction.FactionHeadId].NameTextId,
victimFaction.Name);
}
noteText =
$"We imprisoned {DisplayNames.MaybeFactionLeaderDescription(victimFaction, imprisonedHero, currentModel)} in {province.Name}.\n\n";
} else {
string victimDescription;
if (victimFaction.FactionHeadId == imprisonedHero.Id) {
victimDescription = "faction leader {HeroName}";
} else if (victimFaction.Leaders.Contains(imprisonedHero.Id)) {
victimDescription =
$"{{VictimFactionLeader}}'s sworn {DisplayNames.SiblingDescription(imprisonedHero.PronounGender)} {{HeroName}}";
} else {
victimDescription = $"{{VictimFactionLeader}}'s hero {{HeroName}}";
}
textTemplate =
$"{{ActingFactionLeader}} has imprisoned {victimDescription} in {province.Name}.\n\n";
heroPlaceholders["HeroName"] = (imprisonedHero.NameTextId, "the hero");
heroPlaceholders["ActingFactionLeader"] =
(currentModel.Heroes[actingFaction.FactionHeadId].NameTextId,
actingFaction.Name);
if (!victimFaction.FactionHeadId.Equals(imprisonedHero.Id)) {
heroPlaceholders["VictimFactionLeader"] =
(currentModel.Heroes[victimFaction.FactionHeadId].NameTextId,
victimFaction.Name);
}
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has imprisoned {DisplayNames.MaybeFactionLeaderDescription(victimFaction, imprisonedHero, currentModel)} in {province.Name}.\n\n";
}
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: noteTitle,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
prefixedText: noteText,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId> { details.ProvinceId },
displayedHeroes: new List<HeroView> { firstHero, secondHero });
@@ -21,70 +21,30 @@ namespace eagle.Notifications.ARNNotifications {
fid && fid == currentModel.PlayerId;
string noteTitle = "Hero Returned";
string textTemplate;
string noteText = "";
HeroView firstHero = currentModel.Heroes[actingFaction.FactionHeadId];
HeroView secondHero = imprisonedHero;
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)>();
if (isMyHero) {
if (imprisonedHero.IsFactionLeader) {
textTemplate =
$"{{ActingFactionLeader}} has peacefully returned our sworn {DisplayNames.SiblingDescription(imprisonedHero.PronounGender)} {{ReturnedHero}} from {province.Name}!";
heroPlaceholders["ReturnedHero"] = (imprisonedHero.NameTextId, "our leader");
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has peacefully returned our sworn {DisplayNames.SiblingDescription(imprisonedHero.PronounGender)} {imprisonedHero.Name} from {province.Name}!";
} else {
textTemplate =
$"{{ActingFactionLeader}} has peacefully returned our loyal vassal, {{ReturnedHero}} from {province.Name}!";
heroPlaceholders["ReturnedHero"] = (imprisonedHero.NameTextId, "our vassal");
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has peacefully returned our loyal vassal, {imprisonedHero.Name} from {province.Name}!";
}
heroPlaceholders["ActingFactionLeader"] =
(currentModel.Heroes[actingFaction.FactionHeadId].NameTextId,
actingFaction.Name);
} else if (isMyAction) {
string victimDescription;
if (victimFaction.FactionHeadId == imprisonedHero.Id) {
victimDescription = "faction leader {HeroName}";
} else if (victimFaction.Leaders.Contains(imprisonedHero.Id)) {
victimDescription =
$"{{VictimFactionLeader}}'s sworn {DisplayNames.SiblingDescription(imprisonedHero.PronounGender)} {{HeroName}}";
} else {
victimDescription = $"{{VictimFactionLeader}}'s hero {{HeroName}}";
}
textTemplate = $"We returned {victimDescription} from {province.Name}.";
heroPlaceholders["HeroName"] = (imprisonedHero.NameTextId, "the hero");
if (!victimFaction.FactionHeadId.Equals(imprisonedHero.Id)) {
heroPlaceholders["VictimFactionLeader"] =
(currentModel.Heroes[victimFaction.FactionHeadId].NameTextId,
victimFaction.Name);
}
noteText =
$"We returned {DisplayNames.MaybeFactionLeaderDescription(victimFaction, imprisonedHero, currentModel)} from {province.Name}.";
} else {
string victimDescription;
if (victimFaction.FactionHeadId == imprisonedHero.Id) {
victimDescription = "faction leader {HeroName}";
} else if (victimFaction.Leaders.Contains(imprisonedHero.Id)) {
victimDescription =
$"{{VictimFactionLeader}}'s sworn {DisplayNames.SiblingDescription(imprisonedHero.PronounGender)} {{HeroName}}";
} else {
victimDescription = $"{{VictimFactionLeader}}'s hero {{HeroName}}";
}
textTemplate =
$"{{ActingFactionLeader}} has peacefully returned {victimDescription} from {province.Name}.";
heroPlaceholders["HeroName"] = (imprisonedHero.NameTextId, "the hero");
heroPlaceholders["ActingFactionLeader"] =
(currentModel.Heroes[actingFaction.FactionHeadId].NameTextId,
actingFaction.Name);
if (!victimFaction.FactionHeadId.Equals(imprisonedHero.Id)) {
heroPlaceholders["VictimFactionLeader"] =
(currentModel.Heroes[victimFaction.FactionHeadId].NameTextId,
victimFaction.Name);
}
noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has peacefully returned {DisplayNames.MaybeFactionLeaderDescription(victimFaction, imprisonedHero, currentModel)} from {province.Name}.";
}
yield return new DynamicTextNotification(
yield return new Notification(
title: noteTitle,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
text: noteText,
singleProvinceId: details.ProvinceId,
displayedHeroes: new List<HeroView> { firstHero, secondHero });
}
@@ -18,34 +18,19 @@ namespace eagle.Notifications.ARNNotifications {
var provinceIds = new List<ProvinceId> { notif.ProvinceId };
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "DepartedHero", (departedHero.NameTextId, "A hero") }
};
if (notif.FromFactionId == currentModel.PlayerId) {
string textTemplate =
$"{{DepartedHero}} has left your service in {province.Name}.\n\n{notification.LlmMessage}";
yield return new DynamicTextNotification(
yield return new Notification(
title: "Hero Departed",
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
text: $"{departedHero.Name} has left your service in {province.Name}.\n\n{notification.LlmMessage}",
singleProvinceId: province.Id,
displayedHeroes: new List<HeroView> { departedHero });
singleDisplayedHero: departedHero);
} else {
string textTemplate =
$"{{DepartedHero}} has left {{FactionLeader}}'s service in {province.Name}.\n\n";
heroPlaceholders["FactionLeader"] =
(currentModel.Heroes[faction.FactionHeadId].NameTextId, faction.Name);
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Hero Departed",
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
prefixedText: $"{departedHero.Name} has left {DisplayNames.FactionString(faction, currentModel)}'s service in {province.Name}.\n\n",
llmId: notification.LlmId,
provinceIds: provinceIds,
displayedHeroes: new List<HeroView> {
departedHero,
currentModel.Heroes[faction.FactionHeadId]
});
displayedHeroes: new List<HeroView> { departedHero, currentModel.Heroes[faction.FactionHeadId] });
}
}
}
@@ -19,19 +19,14 @@ namespace eagle.Notifications.ARNNotifications {
var affectedProvinces = currentModel.ProvincesForFaction(imprisoningFactionId);
affectedProvinces.AddRange(currentModel.ProvincesForFaction(offeringFactionId));
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "ImprisonedAmbassador", (imprisonedHero.NameTextId, "the ambassador") }
};
if (playerId.HasValue && playerId.Value == imprisoningFactionId) {
var factionHeadId =
currentModel.MaybeDestroyedFaction(offeringFactionId).FactionHeadId;
var factionHead = currentModel.Heroes[factionHeadId];
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Ambassador Imprisoned",
textTemplate:
$"You have rejected an invitation from {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {{ImprisonedAmbassador}}.\n\n",
heroPlaceholders: heroPlaceholders,
prefixedText:
$"You have rejected an invitation from {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {imprisonedHero.Name}.\n\n",
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { factionHead });
@@ -39,11 +34,10 @@ namespace eagle.Notifications.ARNNotifications {
var factionHeadId =
currentModel.MaybeDestroyedFaction(imprisoningFactionId).FactionHeadId;
var factionHead = currentModel.Heroes[factionHeadId];
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Ambassador Imprisoned",
textTemplate:
$"{currentModel.FactionName(imprisoningFactionId)} has imprisoned our ambassador {{ImprisonedAmbassador}}!\n\n",
heroPlaceholders: heroPlaceholders,
prefixedText:
$"{currentModel.FactionName(imprisoningFactionId)} has imprisoned our ambassador {imprisonedHero.Name}!\n\n",
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { factionHead });
@@ -55,11 +49,10 @@ namespace eagle.Notifications.ARNNotifications {
currentModel.MaybeDestroyedFaction(offeringFactionId).FactionHeadId;
var factionHead2 = currentModel.Heroes[factionHeadId2];
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Ambassador Imprisoned",
textTemplate:
$"{currentModel.FactionName(imprisoningFactionId)} has rejected an invitation from {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {{ImprisonedAmbassador}}.\n\n",
heroPlaceholders: heroPlaceholders,
prefixedText:
$"{currentModel.FactionName(imprisoningFactionId)} has rejected an invitation from {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {imprisonedHero.Name}.\n\n",
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { factionHead1,
@@ -17,17 +17,12 @@ namespace eagle.Notifications.ARNNotifications {
var apprehended = currentModel.Heroes[details.ApprehendedHeroId];
string noteTitle = "Outlaw Apprehended";
string textTemplate =
$"{factionName} apprehended the outlaw {{ApprehendedHero}} in {province.Name}.";
string noteText =
$"{factionName} apprehended the outlaw {apprehended.Name} in {province.Name}.";
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "ApprehendedHero", (apprehended.NameTextId, "an outlaw") }
};
yield return new DynamicTextNotification(
yield return new Notification(
title: noteTitle,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
text: noteText,
singleProvinceId: details.ProvinceId,
displayedHeroes: new List<HeroView> { factionHead, apprehended });
}
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using Net.Eagle0.Eagle.Views;
namespace eagle.Notifications.ARNNotifications {
using FactionId = Int32;
@@ -16,26 +15,21 @@ namespace eagle.Notifications.ARNNotifications {
var spottedOutlaw = currentModel.Heroes[details.OutlawHeroId];
string noteTitle = "";
string textTemplate = "";
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "SpottedOutlaw", (spottedOutlaw.NameTextId, "An outlaw") }
};
string noteText = "";
if (province.RulingFactionId is FactionId fid && fid == currentModel.PlayerId) {
noteTitle = "Outlaw Spotted";
textTemplate = $"{{SpottedOutlaw}} has been spotted in {province.Name}!";
noteText = $"{spottedOutlaw.Name} has been spotted in {province.Name}!";
} else {
noteTitle = "Outlaw Departed";
textTemplate = $"{{SpottedOutlaw}} has left our territory for {province.Name}.";
noteText = $"{spottedOutlaw.Name} has left our territory for {province.Name}.";
}
yield return new DynamicTextNotification(
yield return new Notification(
title: noteTitle,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
text: noteText,
singleProvinceId: province.Id,
displayedHeroes: new List<HeroView> { spottedOutlaw });
singleDisplayedHero: spottedOutlaw);
}
}
}
@@ -18,18 +18,12 @@ namespace eagle.Notifications.ARNNotifications {
var hero2Faction = currentModel.FactionName(note.Hero2FactionId);
string noteTitle = "Prisoners Exchanged";
string textTemplate =
$"{{Hero1}} ({hero1Faction}) and {{Hero2}} ({hero2Faction}) have agreed to a prisoner exchange.";
string noteText =
$"{hero1.Name} ({hero1Faction}) and {hero2.Name} ({hero2Faction}) have agreed to a prisoner exchange.";
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "Hero1", (hero1.NameTextId, "A hero") },
{ "Hero2", (hero2.NameTextId, "A hero") }
};
yield return new DynamicTextNotification(
yield return new Notification(
title: noteTitle,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
text: noteText,
provinceIds: new List<
ProvinceId> { note.Hero1ProvinceId, note.Hero2ProvinceId },
displayedHeroes: new List<HeroView> { hero1, hero2 });
@@ -18,35 +18,20 @@ namespace eagle.Notifications.ARNNotifications {
.FactionHeadId];
string noteTitle = "Prisoner Executed";
string textTemplate;
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)>();
string noteText;
if (note.LastFactionId is {} lastFactionId) {
var lastFaction = currentModel.MaybeDestroyedFaction(lastFactionId);
string victimDescription;
if (lastFaction.FactionHeadId == hero.Id) {
victimDescription = "faction leader {HeroName}";
} else if (lastFaction.Leaders.Contains(hero.Id)) {
victimDescription =
$"{DisplayNames.ResolvedFactionString(lastFaction, currentModel)}'s sworn {DisplayNames.SiblingDescription(hero.PronounGender)} {{HeroName}}";
} else {
victimDescription =
$"{DisplayNames.ResolvedFactionString(lastFaction, currentModel)}'s hero {{HeroName}}";
}
textTemplate = $"{executingFactionName} has executed {victimDescription}!";
heroPlaceholders["HeroName"] = (hero.NameTextId, "the hero");
noteText =
$"{executingFactionName} has executed {DisplayNames.MaybeFactionLeaderDescription(lastFaction, hero, currentModel)}!";
} else {
textTemplate = $"{executingFactionName} has executed {{ExecutedHero}}!";
heroPlaceholders["ExecutedHero"] = (hero.NameTextId, "the prisoner");
noteText = $"{executingFactionName} has executed {hero.Name}!";
}
yield return new DynamicTextNotification(
yield return new Notification(
title: noteTitle,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
text: noteText,
singleProvinceId: note.ProvinceId,
displayedHeroes: new List<HeroView> { factionLeader, hero });
}
@@ -17,17 +17,12 @@ namespace eagle.Notifications.ARNNotifications {
var hero = currentModel.Heroes[details.HeroId];
var province = currentModel.Provinces[details.ProvinceId];
string textTemplate =
$"You failed to complete a {DisplayNames.QuestTypeString(details.FailedQuest)} quest for {{QuestHero}} in {province.Name}.\n\n";
string noteText =
$"You failed to complete a {DisplayNames.QuestTypeString(details.FailedQuest)} quest for {hero.Name} in {province.Name}.\n\n";
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "QuestHero", (hero.NameTextId, "the hero") }
};
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: noteTitle,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
prefixedText: noteText,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId> { province.Id },
displayedHeroes: new List<HeroView> { hero });
@@ -17,17 +17,12 @@ namespace eagle.Notifications.ARNNotifications {
var hero = currentModel.Heroes[details.HeroId];
var province = currentModel.Provinces[details.ProvinceId];
string textTemplate =
$"You completed a {DisplayNames.QuestTypeString(details.FulfilledQuest)} quest for {{QuestHero}} in {province.Name}.\n\n";
string noteText =
$"You completed a {DisplayNames.QuestTypeString(details.FulfilledQuest)} quest for {hero.Name} in {province.Name}.\n\n";
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "QuestHero", (hero.NameTextId, "the hero") }
};
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: noteTitle,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
prefixedText: noteText,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId> { province.Id },
displayedHeroes: new List<HeroView> { hero });
@@ -26,25 +26,19 @@ namespace eagle.Notifications.ARNNotifications {
var ransomedHeroId = notif.RansomedHeroId;
var ransomedHero = currentModel.Heroes[ransomedHeroId];
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "RansomedHero", (ransomedHero.NameTextId, "the hero") }
};
if (playerId.HasValue && playerId.Value == paidToFactionId) {
// no notification
} else if (playerId.HasValue && playerId.Value == paidByFactionId) {
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Ransom Accepted",
textTemplate: $"{currentModel.FactionName(paidToFactionId)} has accepted our ransom for {{RansomedHero}}.\n\n",
heroPlaceholders: heroPlaceholders,
prefixedText: $"{currentModel.FactionName(paidToFactionId)} has accepted our ransom for {ransomedHero.Name}.\n\n",
llmId: notification.LlmId,
provinceIds: new List<ProvinceId>(),
displayedHeroes: new List<HeroView> { acceptingFactionHead, ransomedHero });
} else {
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Ransom Paid",
textTemplate: $"{currentModel.FactionName(paidByFactionId)} paid a ransom to {currentModel.FactionName(paidToFactionId)} for the return of {{RansomedHero}}.\n\n",
heroPlaceholders: heroPlaceholders,
prefixedText: $"{currentModel.FactionName(paidByFactionId)} paid a ransom to {currentModel.FactionName(paidToFactionId)} for the return of {ransomedHero.Name}.\n\n",
llmId: notification.LlmId,
provinceIds: new List<ProvinceId>(),
displayedHeroes: new List<HeroView> { acceptingFactionHead, offeringFactionHead, ransomedHero });
@@ -26,31 +26,24 @@ namespace eagle.Notifications.ARNNotifications {
var ransomedHeroId = notif.RansomedHeroId;
var ransomedHero = currentModel.Heroes[ransomedHeroId];
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "RansomedHero", (ransomedHero.NameTextId, "the hero") }
};
if (playerId is PlayerId pid && pid == refusingFactionId) {
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Ransom Rejected",
textTemplate: $"We have refused to accept the ransom from {currentModel.FactionName(offeringFactionId)} for {{RansomedHero}}.\n\n",
heroPlaceholders: heroPlaceholders,
prefixedText: $"We have refused to accept the ransom from {currentModel.FactionName(offeringFactionId)} for {ransomedHero.Name}.\n\n",
llmId: notification.LlmId,
provinceIds: new List<ProvinceId>(),
displayedHeroes: new List<HeroView> { ransomedHero, offeringFactionHead});
} else if (playerId.HasValue && playerId.Value == offeringFactionId) {
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Ransom Rejected",
textTemplate: $"{currentModel.FactionName(refusingFactionId)} has refused to accept our ransom for {{RansomedHero}}.\n\n",
heroPlaceholders: heroPlaceholders,
prefixedText: $"{currentModel.FactionName(refusingFactionId)} has refused to accept our ransom for {ransomedHero.Name}.\n\n",
llmId: notification.LlmId,
provinceIds: new List<ProvinceId>(),
displayedHeroes: new List<HeroView> { refusingFactionHead, ransomedHero});
} else {
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Ransom Rejected",
textTemplate: $"{currentModel.FactionName(refusingFactionId)} refused to accept a ransom to {currentModel.FactionName(offeringFactionId)} for the return of {{RansomedHero}}.\n\n",
heroPlaceholders: heroPlaceholders,
prefixedText: $"{currentModel.FactionName(refusingFactionId)} refused to accept a ransom to {currentModel.FactionName(offeringFactionId)} for the return of {ransomedHero.Name}.\n\n",
llmId: notification.LlmId,
provinceIds: new List<ProvinceId>(),
displayedHeroes: new List<HeroView> { refusingFactionHead, offeringFactionHead, ransomedHero });
@@ -17,21 +17,13 @@ namespace eagle.Notifications.ARNNotifications {
var affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
string textTemplate =
$"{{NewBrother}} has sworn brotherhood with {factionName}'s leader {{FactionHead}}.\n\n";
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "NewBrother", (newBrother.NameTextId, "A hero") },
{ "FactionHead", (factionHead.NameTextId, "the leader") }
};
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Swore Brotherhood",
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
prefixedText:
$"{newBrother.Name} has sworn brotherhood with {factionName}'s leader {factionHead.Name}.\n\n",
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { newBrother, factionHead });
displayedHeroes: new List<HeroView> { newBrother, factionHead});
}
}
}
@@ -27,34 +27,27 @@ namespace eagle.Notifications.ARNNotifications {
currentModel.MaybeDestroyedFaction(imprisoningFactionId).FactionHeadId;
var imprisoningFactionHead = currentModel.Heroes[imprisoningFactionHeadId];
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "ImprisonedAmbassador", (imprisonedHero.NameTextId, "the ambassador") }
};
if (playerId.HasValue && playerId.Value == imprisoningFactionId) {
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Ambassador Imprisoned",
textTemplate:
$"You have rejected a treaty with {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {{ImprisonedAmbassador}}.\n\n",
heroPlaceholders: heroPlaceholders,
prefixedText:
$"You have rejected a treaty with {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {imprisonedHero.Name}.\n\n",
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { offeringFactionHead, imprisonedHero});
} else if (playerId.HasValue && playerId.Value == offeringFactionId) {
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Ambassador Imprisoned",
textTemplate:
$"{currentModel.FactionName(imprisoningFactionId)} has imprisoned our ambassador {{ImprisonedAmbassador}}!\n\n",
heroPlaceholders: heroPlaceholders,
prefixedText:
$"{currentModel.FactionName(imprisoningFactionId)} has imprisoned our ambassador {imprisonedHero.Name}!\n\n",
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { imprisoningFactionHead, imprisonedHero});
} else {
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: "Ambassador Imprisoned",
textTemplate:
$"{currentModel.FactionName(imprisoningFactionId)} has rejected a truce offer from {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {{ImprisonedAmbassador}}.\n\n",
heroPlaceholders: heroPlaceholders,
prefixedText:
$"{currentModel.FactionName(imprisoningFactionId)} has rejected a truce offer from {currentModel.FactionName(offeringFactionId)} and imprisoned their ambassador {imprisonedHero.Name}.\n\n",
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: new List<HeroView> { imprisoningFactionHead, offeringFactionHead, imprisonedHero });
@@ -20,17 +20,12 @@ namespace eagle.Notifications.ARNNotifications {
.FactionHeadId];
string noteTitle = "Vassal Exiled";
string textTemplate =
$"{factionName} sent {{ExiledHero}} into exile from {province.Name}\n\n";
string noteText =
$"{factionName} sent {exiledHero.Name} into exile from {province.Name}\n\n";
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "ExiledHero", (exiledHero.NameTextId, "the vassal") }
};
yield return DynamicTextNotification.StreamingDynamicNotification(
yield return Notification.StreamingNotification(
title: noteTitle,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
prefixedText: noteText,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId> { details.ProvinceId },
displayedHeroes: new List<HeroView> { factionHead, exiledHero });
@@ -1,131 +0,0 @@
using System;
using System.Collections.Generic;
using Net.Eagle0.Eagle.Views;
namespace eagle.Notifications {
using ProvinceId = Int32;
public class DynamicTextNotification : Notification, IDisposable {
private List<GeneratedTextListener> textListeners = new();
private string textTemplate;
private Dictionary<string, string> placeholderValues = new();
public DynamicTextNotification(
string title,
string textTemplate,
Dictionary<string, (string nameTextId, string fallback)> heroPlaceholders,
List<ProvinceId> provinceIds,
List<HeroView> displayedHeroes,
bool shouldAppend = true,
string llmId = null)
: base(title,
BuildInitialText(textTemplate, heroPlaceholders),
provinceIds,
displayedHeroes,
shouldAppend,
llmId) {
this.textTemplate = textTemplate;
SetupTextListeners(heroPlaceholders);
}
public DynamicTextNotification(
string title,
string textTemplate,
Dictionary<string, (string nameTextId, string fallback)> heroPlaceholders,
ProvinceId singleProvinceId,
List<HeroView> displayedHeroes,
bool shouldAppend = true)
: this(title,
textTemplate,
heroPlaceholders,
new List<ProvinceId> { singleProvinceId },
displayedHeroes,
shouldAppend) {}
private static string BuildInitialText(
string template,
Dictionary<string, (string nameTextId, string fallback)> heroPlaceholders) {
string result = template;
foreach (var kvp in heroPlaceholders) {
string placeholder = kvp.Key;
string fallback = kvp.Value.fallback;
// Try to get text immediately, fall back if not available
string currentText = fallback;
if (!string.IsNullOrEmpty(kvp.Value.nameTextId)) {
var textEntry = ClientTextProvider.Provider.GetTextEntry(kvp.Value.nameTextId);
if (textEntry != null && !string.IsNullOrEmpty(textEntry.Text)) {
currentText = textEntry.Text;
}
}
result = result.Replace($"{{{placeholder}}}", currentText);
}
return result;
}
private void SetupTextListeners(
Dictionary<string, (string nameTextId, string fallback)> heroPlaceholders) {
int index = 0;
foreach (var kvp in heroPlaceholders) {
string placeholder = kvp.Key;
string nameTextId = kvp.Value.nameTextId;
string fallback = kvp.Value.fallback;
if (!string.IsNullOrEmpty(nameTextId)) {
var listener = new GeneratedTextListener(
nameTextId,
"",
"",
index,
(listenerIndex, text) => UpdatePlaceholder(placeholder, text));
textListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
index++;
}
}
private void UpdatePlaceholder(string placeholder, string newText) {
placeholderValues[placeholder] = newText;
UpdateText();
}
private void UpdateText() {
string result = textTemplate;
foreach (var kvp in placeholderValues) {
result = result.Replace($"{{{kvp.Key}}}", kvp.Value);
}
// Use reflection to update the private setter
var textProperty = typeof(Notification).GetProperty("Text");
textProperty?.SetValue(this, result);
}
public void Dispose() {
foreach (var listener in textListeners) {
ClientTextProvider.Provider.RemoveListener(listener);
}
textListeners.Clear();
}
public static DynamicTextNotification StreamingDynamicNotification(
string title,
string textTemplate,
Dictionary<string, (string nameTextId, string fallback)> heroPlaceholders,
string llmId,
List<ProvinceId> provinceIds,
List<HeroView> displayedHeroes) {
return new DynamicTextNotification(
title: title,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
provinceIds: provinceIds,
displayedHeroes: displayedHeroes,
shouldAppend: false,
llmId: llmId);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 025d7dcb002bb4e5ca00edcc77689b6d
@@ -8,20 +8,12 @@ namespace eagle.Notifications {
private static IEnumerable<Notification> GenerateNotification(
ActionResultView actionResultView,
IGameModel currentModel) {
var hero = currentModel.Heroes[actionResultView.Leader.Value];
string textTemplate =
"{DecliningHero} feels you are too different to swear eternal brotherhood.";
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "DecliningHero", (hero.NameTextId, "The hero") }
};
yield return new DynamicTextNotification(
yield return new Notification(
title: "Oath Declined",
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
singleProvinceId: actionResultView.GameStateDiff.ChangedProvinces[0].Id,
displayedHeroes: new List<HeroView> { hero });
text:
$"{currentModel.Heroes[actionResultView.Leader.Value].Name} feels you are too different to swear eternal brotherhood.",
singleProvinceId: actionResultView.GameStateDiff
.ChangedProvinces[0].Id);
}
}
}
@@ -22,19 +22,13 @@ namespace eagle.Notifications {
var province = currentModel.Provinces[cp.Id];
string noteTitle = "Prisoner Escaped";
string textTemplate = $"{{EscapedHero}} has escaped from {province.Name}!";
string noteText = $"{hero.Name} has escaped from {province.Name}!";
var heroPlaceholders =
new Dictionary<string, (string nameTextId, string fallback)> {
{ "EscapedHero", (hero.NameTextId, "A prisoner") }
};
yield return new DynamicTextNotification(
yield return new Notification(
title: noteTitle,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
text: noteText,
singleProvinceId: cp.Id,
displayedHeroes: new List<HeroView> { hero });
singleDisplayedHero: hero);
}
}
}
@@ -19,21 +19,12 @@ namespace eagle.Notifications {
var hero = currentModel.Heroes[ch.Id];
const string noteTitle = "Prisoner Returned";
string textTemplate =
$"{{ActingFactionLeader}} has returned {{ReturnedHero}} to us!";
string noteText =
$"{DisplayNames.FactionString(actingFaction, currentModel)} has returned {hero.Name} to us!";
var heroPlaceholders =
new Dictionary<string, (string nameTextId, string fallback)> {
{ "ReturnedHero", (hero.NameTextId, "our hero") },
{ "ActingFactionLeader",
(currentModel.Heroes[actingFaction.FactionHeadId].NameTextId,
actingFaction.Name) }
};
yield return new DynamicTextNotification(
yield return new Notification(
title: noteTitle,
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
text: noteText,
provinceIds: new List<ProvinceId>(),
displayedHeroes: new List<HeroView> { actingFactionLeader, hero });
}
@@ -19,17 +19,15 @@ namespace eagle.Notifications {
leader = currentModel.Heroes[rulingHid];
}
string noteTitle = "Riot";
string baseText = $"A riot seems to have occurred in {province.Name}.";
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)>();
string noteText = $"A riot seems to have occurred in {province.Name}.";
HeroView killedHero = null;
if (province.RulingFactionId.HasValue && province.RulingFactionId.Value == playerId) {
baseText = $"A terrible riot has occurred in {province.Name}.";
noteText = $"A terrible riot has occurred in {province.Name}.";
foreach (var removedHeroId in actionResultView.GameStateDiff.RemovedHeroIds) {
var hero = currentModel.Heroes[removedHeroId];
baseText += "\n";
baseText += $"{{KilledHero}} was killed in the riot!";
heroPlaceholders["KilledHero"] = (hero.NameTextId, "A hero");
noteText += "\n";
noteText += $"{hero.Name} was killed in the riot!";
killedHero = hero;
if (killedHero.Id == leader.Id) killedHero = null;
}
@@ -38,10 +36,9 @@ namespace eagle.Notifications {
var displayedHeroes =
new List<HeroView> { leader, killedHero }.Where(x => x != null).ToList();
yield return new DynamicTextNotification(
yield return new Notification(
title: noteTitle,
textTemplate: baseText,
heroPlaceholders: heroPlaceholders,
text: noteText,
singleProvinceId: province.Id,
displayedHeroes: displayedHeroes);
}
@@ -28,7 +28,7 @@ namespace eagle.Notifications {
string noteTitle = "Army Turned Back";
string noteText =
$"{DisplayNames.ResolvedFactionString(faction, currentModel)}'s army approaching {province.Name} was forced to turn back due to harsh weather.";
$"{DisplayNames.FactionString(faction, currentModel)}'s army approaching {province.Name} was forced to turn back due to harsh weather.";
yield return new Notification(
title: noteTitle,
@@ -230,20 +230,6 @@ namespace eagle {
}
}
public async Task PostError(EagleGameId gameId, string errorMessage, string stackTrace) {
await DoWithStreamingCall(async (streamingCall) => {
await streamingCall.RequestStream.WriteAsync(new UpdateStreamRequest {
ErrorRequest =
new ErrorRequest {
ErrorMessage = errorMessage,
GameId = gameId,
StackTrace = stackTrace
}
});
return true;
});
}
public void Unsubscribe(IClientConnectionSubscriber subscriber) {
lock (this) { _subscribers.Remove(subscriber.GameId); }
}
@@ -372,6 +358,20 @@ namespace eagle {
Eagle.EagleClient client);
private Timer _retryTimer = null;
public async Task<TResponseType> DoWithGrpcClientAsync<TResponseType>(
WithGrpcClientDelegateAsync<TResponseType> action)
where TResponseType : class ? {
try {
return await action(_grpcClient);
} catch (RpcException e) {
if (e.StatusCode == StatusCode.Cancelled) {
// This is expected when the connection is closed.
return null;
} else {
throw;
}
}
}
private void HandleGameUpdate(GameUpdate gameUpdate, DateTime receivedTime) {
switch (gameUpdate.GameUpdateDetailsCase) {
@@ -492,6 +492,11 @@ namespace eagle {
}
break;
case UpdateStreamResponse.ResponseDetailsOneofCase.HeadshotResponse:
AWSResourceFetcher.headshotFetcher.ReceiveHeadshotResponse(
current.HeadshotResponse);
break;
}
sc = _streamingCall;
@@ -92,7 +92,7 @@ namespace eagle {
if (imagesAndNamesContainer) imagesAndNamesContainer.gameObject.SetActive(true);
var firstHero = PopupInfos[DisplayedIndex].displayedHeroes[0];
ResourceFetcher.headshotFetcher.LoadIntoRawImage(
AWSResourceFetcher.headshotFetcher.LoadIntoRawImage(
firstImage,
firstHero.ImagePath);
firstImage.transform.parent.gameObject.SetActive(true);
@@ -100,7 +100,7 @@ namespace eagle {
if (imageCount > 1) {
var secondHero = PopupInfos[DisplayedIndex].displayedHeroes[1];
ResourceFetcher.headshotFetcher.LoadIntoRawImage(
AWSResourceFetcher.headshotFetcher.LoadIntoRawImage(
secondImage,
secondHero.ImagePath);
secondImage.transform.parent.gameObject.SetActive(true);
@@ -108,7 +108,7 @@ namespace eagle {
if (imageCount > 2) {
var thirdHero = PopupInfos[DisplayedIndex].displayedHeroes[2];
ResourceFetcher.headshotFetcher.LoadIntoRawImage(
AWSResourceFetcher.headshotFetcher.LoadIntoRawImage(
thirdImage,
thirdHero.ImagePath);
thirdImage.transform.parent.gameObject.SetActive(true);
@@ -94,14 +94,13 @@ namespace eagle {
public DisclosureTriangle dominionViewToggle;
private ProvinceView _provinceView;
private DynamicFactionTextUpdater factionTextUpdater = new DynamicFactionTextUpdater();
private void SetHeadshotToHeroId(HeroId hid) {
var rulingHero = _model.Heroes[hid];
if (rulingHero.ImagePath is String path) {
HeadshotImage.gameObject.SetActive(true);
ResourceFetcher.headshotFetcher.LoadIntoRawImage(HeadshotImage, path);
AWSResourceFetcher.headshotFetcher.LoadIntoRawImage(HeadshotImage, path);
} else {
HeadshotImage.gameObject.SetActive(false);
}
@@ -315,10 +314,12 @@ namespace eagle {
rulingHeroPrefix = "UNKNOWN";
}
var factionLabel = DisplayNames.FactionString(faction, Model);
ProvinceNameField.text = Province.Name;
provinceLeaderField.TextId = rulingHeroNameTextId;
provinceLeaderField.PrefixedText = rulingHeroPrefix;
factionTextUpdater.SetFactionText(provinceFactionField, faction, Model);
provinceFactionField.text = factionLabel;
var diploStrings = new List<string>();
if (!hasFullInfo) {
@@ -399,7 +400,5 @@ namespace eagle {
dominionViewToggle.isOn = !dominionViewToggle.isOn;
}
}
private void OnDestroy() { factionTextUpdater?.Dispose(); }
}
}
@@ -4,7 +4,7 @@ using UnityEngine;
using UnityEngine.UI;
namespace eagle {
public class CombatUnitRowController : TableRowController, System.IDisposable {
public class CombatUnitRowController : TableRowController {
public RawImage professionImage;
public TMP_Text heroNameLabel;
public RawImage battalionImage;
@@ -12,13 +12,11 @@ namespace eagle {
public TMP_Text trainingLabel;
public TMP_Text armamentLabel;
private DynamicHeroTextUpdater textUpdater = new DynamicHeroTextUpdater();
public HeroView Hero {
set {
professionImage.texture = CommonTextures.Profession(value.Profession);
professionImage.color = professionImage.texture == null ? Color.clear : Color.white;
textUpdater.SetHeroText(heroNameLabel, value.NameTextId, "Hero");
heroNameLabel.text = value.Name;
}
}
@@ -39,9 +37,5 @@ namespace eagle {
battalionImage.color = battalionImage.texture == null ? Color.clear : Color.white;
}
}
public void Dispose() { textUpdater?.Dispose(); }
private void OnDestroy() { Dispose(); }
}
}
@@ -67,7 +67,7 @@ namespace eagle {
public CommonUnit Unit {
get {
var hero = new CommonHero {
NameTextId = heroName.text,
Name = heroName.text,
Profession = SelectedProfession,
Strength = int.Parse(strength.text),
Agility = int.Parse(agility.text),
@@ -102,7 +102,7 @@ namespace eagle {
};
}
set {
heroName.text = value.Hero.NameTextId;
heroName.text = value.Hero.Name;
professionDropdown.value = value.Hero.Profession == CommonProfession.NoProfession
? 0
: (int)value.Hero.Profession;
@@ -12,8 +12,7 @@ namespace eagle {
public class DominionTableRowController : TableRowController,
IPointerEnterHandler,
IPointerExitHandler,
IPointerClickHandler,
System.IDisposable {
IPointerClickHandler {
public TMP_Text provinceName;
public TMP_Text ruler;
public TMP_Text support;
@@ -35,7 +34,6 @@ namespace eagle {
public GameObject flood;
private ProvinceId ProvinceId;
private DynamicHeroTextUpdater textUpdater = new DynamicHeroTextUpdater();
private void SetDevastatedValue(TMP_Text textField, double baseValue, double devastation) {
if (devastation == 0.0) {
@@ -63,8 +61,7 @@ namespace eagle {
provinceName.color = Color.black;
}
var rulingHero = model.Heroes[province.FullInfo.RulingHeroId.Value];
textUpdater.SetHeroText(ruler, rulingHero.NameTextId, "Ruler");
ruler.text = model.Heroes[province.FullInfo.RulingHeroId.Value].Name;
support.text = GUIUtils.ConditionString(province.FullInfo.Support);
commanderCount.text = province.FullInfo.RulingFactionHeroIds.Count.ToString();
battalionCount.text = province.FullInfo.Battalions.Count.ToString();
@@ -159,9 +156,5 @@ namespace eagle {
public void OnPointerClick(PointerEventData eventData) {
GetComponentInParent<DominionPanelController>().RowClicked(ProvinceId);
}
public void Dispose() { textUpdater?.Dispose(); }
private void OnDestroy() { Dispose(); }
}
}
@@ -1,175 +0,0 @@
using System;
using System.Collections.Generic;
using Net.Eagle0.Eagle.Views;
using TMPro;
using UnityEngine;
namespace eagle {
public class DynamicFactionTextUpdater : IDisposable {
private List<GeneratedTextListener> textListeners = new();
private Dictionary<string, TMP_Text> textComponents = new();
private Dictionary<string, FactionTextConfig> factionConfigs = new();
private class FactionTextConfig {
public FactionView Faction { get; }
public IGameModel GameModel { get; }
public string Template { get; }
public string FallbackText { get; }
public FactionTextConfig(
FactionView faction,
IGameModel gameModel,
string template,
string fallbackText) {
Faction = faction;
GameModel = gameModel;
Template = template;
FallbackText = fallbackText;
}
}
public void SetFactionText(
TMP_Text textComponent,
FactionView faction,
IGameModel gameModel,
string template = null,
string fallbackText = null) {
if (!textComponent || faction == null || gameModel == null) return;
string componentKey = textComponent.GetInstanceID().ToString();
textComponents[componentKey] = textComponent;
// Use the provided template or get the default faction string template
string actualTemplate =
template ?? DisplayNames.FactionStringTemplate(faction, gameModel);
string actualFallback = fallbackText ?? faction.Name;
factionConfigs[componentKey] =
new FactionTextConfig(faction, gameModel, actualTemplate, actualFallback);
// Set initial text
UpdateFactionText(componentKey);
// If the template contains {FactionLeaderName}, subscribe to leader name updates
if (actualTemplate.Contains("{FactionLeaderName}")) {
var leader = gameModel.Heroes[faction.FactionHeadId];
if (!string.IsNullOrEmpty(leader.NameTextId)) {
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
if (textEntry == null || string.IsNullOrEmpty(textEntry.Text)) {
// Create listener for dynamic updates
var listener = new GeneratedTextListener(
leader.NameTextId,
"",
"",
0,
(index, text) => UpdateFactionText(componentKey));
textListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
}
}
}
public void SetFactionTextWithCustomFormat(
TMP_Text textComponent,
FactionView faction,
IGameModel gameModel,
string customFormat,
string fallbackText = null) {
if (!textComponent || faction == null || gameModel == null ||
string.IsNullOrEmpty(customFormat))
return;
string componentKey = textComponent.GetInstanceID().ToString();
textComponents[componentKey] = textComponent;
string actualFallback = fallbackText ?? faction.Name;
factionConfigs[componentKey] =
new FactionTextConfig(faction, gameModel, customFormat, actualFallback);
// Set initial text
UpdateFactionTextWithCustomFormat(componentKey, customFormat);
// If the custom format contains {FactionLeaderName}, subscribe to leader name updates
if (customFormat.Contains("{FactionLeaderName}")) {
var leader = gameModel.Heroes[faction.FactionHeadId];
if (!string.IsNullOrEmpty(leader.NameTextId)) {
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
if (textEntry == null || string.IsNullOrEmpty(textEntry.Text)) {
// Create listener for dynamic updates
var listener = new GeneratedTextListener(
leader.NameTextId,
"",
"",
0,
(index, text) => UpdateFactionTextWithCustomFormat(
componentKey,
customFormat));
textListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
}
}
}
private void UpdateFactionText(string componentKey) {
if (!textComponents.TryGetValue(componentKey, out var component) ||
!factionConfigs.TryGetValue(componentKey, out var config) || component == null) {
return;
}
var template = config.Template;
if (template.Contains("{FactionLeaderName}")) {
var leader = config.GameModel.Heroes[config.Faction.FactionHeadId];
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
if (textEntry != null && !string.IsNullOrEmpty(textEntry.Text)) {
component.text = template.Replace("{FactionLeaderName}", textEntry.Text);
} else {
component.text = template.Replace("{FactionLeaderName}", "Leader");
}
} else {
component.text = template;
}
}
private void UpdateFactionTextWithCustomFormat(string componentKey, string customFormat) {
if (!textComponents.TryGetValue(componentKey, out var component) ||
!factionConfigs.TryGetValue(componentKey, out var config) || component == null) {
return;
}
string resolvedText = customFormat;
if (customFormat.Contains("{FactionLeaderName}")) {
var leader = config.GameModel.Heroes[config.Faction.FactionHeadId];
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
if (textEntry != null && !string.IsNullOrEmpty(textEntry.Text)) {
resolvedText = resolvedText.Replace("{FactionLeaderName}", textEntry.Text);
} else {
resolvedText = resolvedText.Replace("{FactionLeaderName}", "Leader");
}
}
if (customFormat.Contains("{FactionName}")) {
resolvedText = resolvedText.Replace("{FactionName}", config.Faction.Name);
}
if (customFormat.Contains("{Prestige}")) {
resolvedText =
resolvedText.Replace("{Prestige}", config.Faction.Prestige.ToString());
}
component.text = resolvedText;
}
public void Dispose() {
foreach (var listener in textListeners) {
ClientTextProvider.Provider.RemoveListener(listener);
}
textListeners.Clear();
textComponents.Clear();
factionConfigs.Clear();
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 73a4160390d2f45938cfcd0c2cc05b21

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