Compare commits

..
Author SHA1 Message Date
adminandClaude 47481c2de6 Fix occupants cache performance by sharing cache across copies
Changes:
- Changed occupantsCache_ from unique_ptr to shared_ptr in GameStateW
- Modified copy constructors to share cache instead of rebuilding
- Updated RebuildOccupantsCache() and InitializeCache() to use make_shared
- Removed unnecessary RebuildOccupantsCache() call in GameStateGuesser.cpp

This fixes the performance regression where cache copies were expensive
due to full cache rebuilds on every GameStateW copy operation.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 20:42:41 -07:00
admin 4810e8e9a5 replaced some more caches 2025-07-20 18:04:39 -07:00
admin d46423bea8 fix the crasher 2025-07-20 18:04:39 -07:00
admin 3a2f9c0c8c a couple more 2025-07-20 18:04:39 -07:00
admin c0accd9d6b more use of new occupants cache 2025-07-20 18:04:38 -07:00
admin c3c80e3c41 put the occupants cache in GameStateW 2025-07-20 18:04:38 -07:00
52 changed files with 765 additions and 1624 deletions
+3 -1
View File
@@ -74,7 +74,7 @@ bazel run gazelle # Update Go build files
### Code Formatting
```bash
# ALWAYS run clang-format after making any C++ or C# code changes
# MANDATORY: ALWAYS run clang-format after making any C++ or C# code changes
clang-format -i <modified_files>
# Format all C++ files in a directory:
@@ -84,6 +84,8 @@ find . -name "*.cpp" -o -name "*.hpp" | xargs clang-format -i
find . -name "*.cs" | xargs clang-format -i
```
**IMPORTANT FOR CLAUDE CODE**: After making any changes to C++ (.cpp, .hpp) or C# (.cs) files, you MUST immediately run clang-format on those files using the Bash tool. This is not optional - it's a mandatory step that must be done every time you modify code files. Use the exact command: `clang-format -i <file_path>` for each modified file.
## Language-Specific Patterns
**Scala (Strategic Layer):**
-206
View File
@@ -1,206 +0,0 @@
# Occupants Vector Optimization - Conversion Report
## Overview
This document details the implementation of an embedded occupants vector in the GameState flatbuffer to replace O(n)
unit iteration with O(1) position lookups. It also catalogs all Occupant() and KnownEnemyOccupant() calls that could not
be converted to use the new optimized methods.
## Completed Conversions
### Successfully Converted Occupant() Calls (16 total)
#### Commands Directory (11 conversions)
1. **HideCommand.cpp**:
- Line 43: `Occupant(currentState->units(), target)``currentState.GetOccupant(target)`
- Line 59: `Occupant(currentState->units(), adjCoords)``currentState.GetOccupant(adjCoords)`
2. **ScoutCommand.cpp**:
- Line 63: `Occupant(currentState->units(), target)``currentState.GetOccupant(target)`
- Line 73: `Occupant(currentState->units(), adjacentCoords)``currentState.GetOccupant(adjacentCoords)`
3. **ReduceCommand.cpp**:
- Line 66: `Occupant(currentState->units(), target)``currentState.GetOccupant(target)`
4. **RaiseDeadCommand.cpp**:
- Line 53: `Occupant(currentState->units(), target)``currentState.GetOccupant(target)`
5. **HolyWaveCommand.cpp**:
- Line 233: `Occupant(runningState->units(), coords)``runningState.GetOccupant(coords)`
6. **MoveCommand.cpp**:
- Line 66: `Occupant(allUnits, destination)``currentState.GetOccupant(destination)`
- Line 98: `Occupant(allUnits, adj)``currentState.GetOccupant(adj)`
- Line 114: `Occupant(allUnits, adj)``currentState.GetOccupant(adj)`
#### Actions Directory (4 conversions)
1. **UpdateGameStatusAction.cpp**:
- Line 232: `Occupant(gameState->units(), criticalTile)``currentState.GetOccupant(criticalTile)`
2. **MeteorCastAction.cpp**:
- Line 186: `Occupant(runningGameState->units(), target)``runningGameState.GetOccupant(target)`
- Line 251: `Occupant(runningGameState->units(), splashCoords)``runningGameState.GetOccupant(splashCoords)`
- Line 304: `Occupant(runningGameState->units(), coords)``runningGameState.GetOccupant(coords)`
3. **UpdateOpponentKnowledgeAction.cpp**:
- Line 42: `Occupant(currentState->units(), adjCoords)``currentState.GetOccupant(adjCoords)`
#### Engine Directory (1 conversion)
1. **ShardokEngine.cpp**:
- Line 463: `Occupant(GetCurrentGameState()->units(), modifiedCoords)``gameState.GetOccupant(modifiedCoords)`
#### Factory Classes Directory (previously converted)
1. **PlayerSetupCommandFactory.cpp**:
- Line 31: `Occupant(gameState->units(), *possiblePosition)``gameState.GetOccupant(*possiblePosition)`
- Line 40: `Occupant(gameState->units(), possibleHidingPosition)``gameState.GetOccupant(possibleHidingPosition)`
2. **FallIntoWaterAction.cpp**:
- Line 154: `Occupant(currentState->units(), adjWithTerrain.adjacentCoords)`
`currentState.GetOccupant(adjWithTerrain.adjacentCoords)`
- Line 175: `Occupant(currentState->units(), bestCoords)``currentState.GetOccupant(bestCoords)`
### KnownEnemyOccupant() Conversions
**Result: 0 conversions possible**
All KnownEnemyOccupant() calls are in command factory methods that receive decomposed game state parameters (Units*,
vector<PlayerId>, etc.) rather than complete GameStateW objects.
## Remaining Unconverted Calls
### Occupant() Calls That Cannot Be Converted
#### 1. PerformUndeadCommandsAction.cpp (2 calls - No GameStateW access)
- **Line 69**: `Occupant(units, FromCoordsProto(possibleAttackCommandProto.target()))`
- **Line 99**: `Occupant(units, adjCoords)`
- **Reason**: These calls are in the `ChooseUndeadCommand()` function which only receives `const Units* units`
parameter, not a full GameStateW.
- **Location**: `src/main/cpp/net/eagle0/shardok/library/actions/PerformUndeadCommandsAction.cpp`
#### 2. AICommandFilter.cpp (1 call - Raw pointer access)
- **Line 399**: `KnownEnemyOccupant(pid, units, allyPids, fireLocation)` (in EXTINGUISH_FIRE_COMMAND case)
- **Reason**: Method receives `const GameState* gameState` parameter, not GameStateW. Has TODO comment noting this
limitation.
- **Location**: `src/main/cpp/net/eagle0/shardok/ai/AICommandFilter.cpp`
#### 3. UpdateGameStatusAction.cpp - Member Variable Usage
- **Various calls**: Uses `gameState` member variable of type `const GameState*`
- **Reason**: Class was designed to take raw GameState pointer in constructor, though InternalExecute method has
GameStateW access.
- **Location**: `src/main/cpp/net/eagle0/shardok/library/actions/UpdateGameStatusAction.cpp`
#### 4. IceAndSnowAdjustmentActionFactory.cpp (1 call - Factory pattern)
- **Line 42**: `Occupant(units, coords)`
- **Reason**: Factory method receives individual parameters, not GameStateW.
- **Location**: `src/main/cpp/net/eagle0/shardok/library/action_factories/IceAndSnowAdjustmentActionFactory.cpp`
### KnownEnemyOccupant() Calls That Cannot Be Converted
#### Command Factory Methods (8 calls - No GameStateW access)
1. **RepairCommandFactory.cpp** - Line 44
2. **FearCommandFactory.cpp** - Line 35
3. **LightningBoltCommandFactory.cpp** - Line 54
4. **ReduceCommandFactory.cpp** - Line 48
5. **ChallengeDuelCommandFactory.cpp** - Line 35
6. **HideCommandFactory.cpp** - Line 45
7. **MeleeCommandFactory.cpp** - Line 58
8. **ArcheryCommandFactory.cpp** - Line 89
**Common Reason**: All command factory methods follow a pattern where they receive individual game state components (
`Units* units`, `vector<PlayerId> allyPids`, etc.) rather than a complete GameStateW object.
#### Utility Functions (3 calls - Utility function parameters)
1. **HexMapUtils.cpp** - Lines 81, 670
2. **ZoneOfControlCalculator.cpp** - Line 143
**Reason**: These are utility functions that take decomposed parameters for reusability across different contexts.
## Performance Impact
### Achieved Improvements
- **16 Occupant() calls** converted from O(n) iteration to O(1) lookup
- Eliminated cache invalidation issues with thread-local approach
- Automatic copying of occupants vector with GameState copies
- **Estimated Performance Gain**: 2-5% reduction in AI search time for typical game states
### Trade-offs
- **Memory Overhead**: 168 bytes per GameState (14×12 map = 168 int16 values)
- **Incremental Updates**: ActionResultApplier now maintains occupants vector via UpdateOccupant() calls
- **Copy Cost**: Slightly higher GameState copy overhead offset by O(1) lookup benefits
## Architectural Patterns Identified
### Convertible Patterns
1. **Command InternalExecute methods**: Have access to `const GameStateW& currentState`
2. **Action InternalExecute methods**: Have access to `const GameStateW& currentState`
3. **Factory methods with GameStateW parameters**: Can access embedded occupants vector
### Non-Convertible Patterns
1. **Command Factory methods**: Receive decomposed parameters (`Units*`, `HexMap*`, etc.)
2. **Utility functions**: Take individual components for reusability
3. **Engine methods**: Often work with raw `GameState*` pointers
4. **Legacy member variables**: Classes storing `const GameState*` instead of `GameStateW`
## Recommendations for Future Work
### Potential Additional Conversions
1. **Refactor command factories** to accept GameStateW instead of decomposed parameters
2. **Update ShardokEngine** to use GameStateW internally where possible
3. **Create GameStateW constructors** from raw GameState* to enable more conversions
4. **Modernize legacy classes** to use GameStateW member variables
### Copy-on-Write Consideration
The user suggested implementing copy-on-write (COW) for GameStateW to reduce memory allocation overhead during AI
search. This could provide additional performance benefits by eliminating unnecessary copying of the occupants vector.
## Technical Implementation Details
### Core Changes Made
1. **game_state.fbs**: Added `occupants:[int16];` field
2. **GameStateW.cpp**: Implemented GetOccupant() and UpdateOccupant() methods
3. **GameStateCopier.cpp**: Populates occupants vector during GameState creation
4. **ActionResultApplier.cpp**: Maintains occupants vector during unit movement
### Key Method Signatures
```cpp
// O(1) occupant lookup
auto GameStateW::GetOccupant(const Coords& coords) const -> const Unit*;
// O(1) enemy occupant lookup
auto GameStateW::GetKnownEnemyOccupant(
PlayerId playerId,
const std::vector<PlayerId>& allyPids,
const Coords& coords) const -> const Unit*;
// Incremental occupants vector maintenance
void GameStateW::UpdateOccupant(
UnitId unitId,
const Coords& oldCoords,
const Coords& newCoords);
```
## Conclusion
The occupants vector optimization successfully converted 12 high-frequency Occupant() calls to O(1) lookups while
maintaining correctness through automatic copying and incremental updates. The remaining 15+ unconverted calls are
primarily in architectural layers (command factories, utilities) that would require broader refactoring to convert. The
performance improvement achieved represents a solid foundation that could be extended with future architectural
modernization.
@@ -95,13 +95,6 @@ cc_library(
],
)
cc_library(
name = "thread_pool",
hdrs = ["ThreadPool.hpp"],
copts = COPTS,
visibility = ["//visibility:public"],
)
cc_library(
name = "time_utils",
hdrs = ["TimeUtils.hpp"],
@@ -1,14 +0,0 @@
//
// ThreadPool.cpp - Implementation of priority-based thread pool
//
#include "ThreadPool.hpp"
namespace eagle0 {
namespace common {
// Implementation is header-only to support templates
// This file exists for potential future non-template implementations
} // namespace common
} // namespace eagle0
@@ -1,200 +0,0 @@
//
// ThreadPool.hpp - Priority-based thread pool with deadline support
//
#ifndef EAGLE0_THREADPOOL_HPP
#define EAGLE0_THREADPOOL_HPP
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <functional>
#include <future>
#include <memory>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>
namespace eagle0::common {
enum class TaskStatus { SUCCESS = 0, DEADLINE_EXCEEDED = 1, CANCELLED = 2 };
template<typename T>
struct TaskResult {
T value;
TaskStatus status;
TaskResult() : value{}, status(TaskStatus::SUCCESS) {}
TaskResult(T val) : value(std::move(val)), status(TaskStatus::SUCCESS) {}
TaskResult(T val, TaskStatus stat) : value(std::move(val)), status(stat) {}
// NO implicit conversion - this was causing infinite recursion
// Use .value or .get() instead
T get() const { return value; }
bool succeeded() const { return status == TaskStatus::SUCCESS; }
bool deadlineExceeded() const { return status == TaskStatus::DEADLINE_EXCEEDED; }
};
class ThreadPool {
public:
using Clock = std::chrono::steady_clock;
using TimePoint = Clock::time_point;
private:
struct Task {
std::function<void()> function;
int priority;
TimePoint deadline;
bool has_deadline;
Task(std::function<void()> f, int p, TimePoint d, bool has_d)
: function(std::move(f)),
priority(p),
deadline(d),
has_deadline(has_d) {}
// Higher priority values and earlier deadlines have higher priority
bool operator<(const Task& other) const {
if (priority != other.priority) {
return priority < other.priority; // Lower priority values have lower priority in
// priority_queue
}
if (has_deadline && other.has_deadline) {
return deadline > other.deadline; // Later deadlines have lower priority
}
if (has_deadline && !other.has_deadline) {
return false; // Tasks with deadlines have higher priority
}
if (!has_deadline && other.has_deadline) {
return true; // Tasks without deadlines have lower priority
}
return false; // Equal priority, no preference
}
};
std::vector<std::thread> workers;
std::priority_queue<Task> tasks;
std::mutex queue_mutex;
std::condition_variable condition;
std::atomic<bool> stop{false};
public:
explicit ThreadPool(size_t num_threads = std::thread::hardware_concurrency()) {
for (size_t i = 0; i < num_threads; ++i) {
workers.emplace_back([this] {
while (true) {
Task task{nullptr, 0, TimePoint{}, false};
{
std::unique_lock<std::mutex> lock(queue_mutex);
condition.wait(lock, [this] { return stop.load() || !tasks.empty(); });
if (stop.load() && tasks.empty()) { return; }
if (!tasks.empty()) {
task = std::move(const_cast<Task&>(tasks.top()));
tasks.pop();
} else {
continue;
}
}
// Execute the task (deadline checking is now handled inside the task)
if (task.function) { task.function(); }
}
});
}
}
// Enqueue a task with priority only
template<class F, class... Args>
auto enqueue(F&& f, Args&&... args, int priority = 0)
-> std::future<TaskResult<std::invoke_result_t<F, Args...>>> {
using return_type = std::invoke_result_t<F, Args...>;
using result_type = TaskResult<return_type>;
auto actualTask = std::bind(std::forward<F>(f), std::forward<Args>(args)...);
auto task = std::make_shared<std::packaged_task<result_type()>>(
[actualTask = std::move(actualTask)]() mutable -> result_type {
return result_type(actualTask());
});
std::future<result_type> result = task->get_future();
{
std::unique_lock<std::mutex> lock(queue_mutex);
if (stop.load()) { throw std::runtime_error("enqueue on stopped ThreadPool"); }
tasks.emplace([task]() { (*task)(); }, priority, TimePoint{}, false);
}
condition.notify_one();
return result;
}
// Enqueue a task with priority and deadline
template<class F, class... Args>
auto enqueue_with_deadline(F&& f, Args&&... args, int priority, TimePoint deadline)
-> std::future<TaskResult<std::invoke_result_t<F, Args...>>> {
using return_type = std::invoke_result_t<F, Args...>;
using result_type = TaskResult<return_type>;
auto actualTask = std::bind(std::forward<F>(f), std::forward<Args>(args)...);
auto task = std::make_shared<std::packaged_task<result_type()>>(
[actualTask = std::move(actualTask), deadline]() mutable -> result_type {
if (Clock::now() > deadline) {
return result_type(return_type{}, TaskStatus::DEADLINE_EXCEEDED);
}
return result_type(actualTask());
});
std::future<result_type> result = task->get_future();
{
std::unique_lock<std::mutex> lock(queue_mutex);
if (stop.load()) { throw std::runtime_error("enqueue on stopped ThreadPool"); }
tasks.emplace([task]() { (*task)(); }, priority, deadline, true);
}
condition.notify_one();
return result;
}
// Get current queue size (approximate, for monitoring)
size_t queue_size() const {
std::unique_lock<std::mutex> lock(const_cast<std::mutex&>(queue_mutex));
return tasks.size();
}
// Get detailed queue information for debugging
void debug_queue_state() const {
std::unique_lock<std::mutex> lock(const_cast<std::mutex&>(queue_mutex));
printf("ThreadPool: Queue size: %zu\n", tasks.size());
if (!tasks.empty()) {
// Create a copy to inspect priorities without modifying queue
auto queue_copy = tasks;
std::vector<int> priorities;
while (!queue_copy.empty()) {
priorities.push_back(queue_copy.top().priority);
queue_copy.pop();
}
printf("ThreadPool: Priorities in queue: ");
for (int p : priorities) { printf("%d ", p); }
printf("\n");
}
}
~ThreadPool() {
stop.store(true);
condition.notify_all();
for (std::thread& worker : workers) {
if (worker.joinable()) { worker.join(); }
}
}
};
} // namespace eagle0::common
#endif // EAGLE0_THREADPOOL_HPP
@@ -70,14 +70,7 @@ auto ConvertUnit(
Unit shardokUnit{};
shardokUnit.mutate_player_id(shardokPlayerId);
// Range check eagle_player_id for int8 conversion
int32_t eagle_id = unit.eagle_player_id();
if (eagle_id < -128 || eagle_id > 127) {
throw std::runtime_error(
"eagle_player_id " + std::to_string(eagle_id) + " out of int8 range");
}
shardokUnit.mutate_eagle_player_id(static_cast<int8_t>(eagle_id));
shardokUnit.mutate_eagle_player_id(unit.eagle_player_id());
shardokUnit.mutate_hidden(false);
shardokUnit.mutate_fortified(false);
if (unit.has_hero()) {
@@ -5,6 +5,7 @@
#include "AIAttackerStrategySelector.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
namespace shardok {
@@ -15,7 +16,7 @@ constexpr double MAXIMUM_RATIO_FOR_ATTACKER_TO_FLEE = 0.50;
auto AIAttackerStrategySelector::BestAttackerStrategy(
const PlayerId attackerPid,
const net::eagle0::shardok::storage::fb::GameState* gameState,
const GameStateW& gameState,
const CoordsSet& criticalTileCoords,
const APDCache& apdCache,
const ALCache& alCache,
@@ -69,10 +70,7 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
chosenStrategy = CrossRiversStrategy(startCrossingLocations);
} else if (attackerUnitCount < criticalTileCoords.size()) {
chosenStrategy = AttackUnitsStrategy(GenerateTargetPriorities(
Occupants(
*gameState->units(),
gameState->hex_map()->row_count(),
gameState->hex_map()->column_count()),
gameState.GetOccupantsVector(),
gameState->hex_map(),
defenderPositions,
attackerPid,
@@ -86,10 +84,7 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
// Otherwise, try to hold the castles.
else if (defenderOccupiedCriticalTileCount > 0) {
chosenStrategy = AttackCastlesStrategy(GenerateTargetPriorities(
Occupants(
*gameState->units(),
gameState->hex_map()->row_count(),
gameState->hex_map()->column_count()),
gameState.GetOccupantsVector(),
gameState->hex_map(),
criticalTileCoords,
attackerPid,
@@ -8,6 +8,7 @@
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCommandChooser.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
@@ -19,7 +20,7 @@ class AIAttackerStrategySelector {
public:
static auto BestAttackerStrategy(
PlayerId attackerPid,
const GameState* gameState,
const GameStateW& gameState,
const CoordsSet& criticalTileCoords,
const APDCache& apdCache,
const ALCache& alCache,
@@ -5,6 +5,7 @@
#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"
@@ -13,10 +14,10 @@
namespace shardok {
using fb::Unit;
using net::eagle0::shardok::common::CommandType;
using net::eagle0::shardok::storage::fb::Unit;
CoordsSet AICommandFilter::BuildEnemyLocations(const GameStateW& gameState, PlayerId pid) {
CoordsSet AICommandFilter::BuildEnemyLocations(const GameState* gameState, PlayerId pid) {
CoordsSet enemyLocations(gameState->hex_map());
const auto* units = gameState->units();
@@ -35,7 +36,7 @@ std::vector<size_t> AICommandFilter::FilterCommands(
const CommandListSPtr& commands,
PlayerId pid,
bool isDefender,
const GameStateW& gameState,
const GameState* gameState,
const SettingsGetter& settings,
const APDCache& apdCache) {
std::vector<size_t> filteredIndices;
@@ -103,14 +104,16 @@ bool AICommandFilter::IsWastefulAction(
const ShardokCommand& cmd,
PlayerId pid,
bool isDefender,
const GameStateW& gameState,
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 (cmd.GetCommandType()) {
switch (cmdType) {
case CommandType::METEOR_START_COMMAND: {
// Meteor preparation filtering
// Meteor takes 3 rounds (start -> target -> cast) and locks the mage in place
@@ -209,8 +212,8 @@ bool AICommandFilter::IsWastefulAction(
bool nearObjective = false;
for (const auto& enemyCoords : enemyLocations) {
const Cube enemyCube = OffsetToCube(enemyCoords);
if (const int hexDistance = CubeDistance(unitCube, enemyCube);
hexDistance <= 3) {
const int hexDistance = CubeDistance(unitCube, enemyCube);
if (hexDistance <= 3) {
nearObjective = true;
break;
}
@@ -389,8 +392,9 @@ bool AICommandFilter::IsWastefulAction(
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 (gameState.GetKnownEnemyOccupant(pid, allyPids, fireLocation)) {
if (KnownEnemyOccupant(pid, units, allyPids, fireLocation)) {
return true; // Don't extinguish fires under enemies
}
break;
@@ -406,7 +410,7 @@ bool AICommandFilter::IsWastefulMovement(
const ShardokCommand& cmd,
PlayerId pid,
bool isDefender,
const GameStateW& gameState,
const GameState* gameState,
const SettingsGetter& settings,
const APDCache& apdCache,
const CoordsSet& enemyLocations,
@@ -489,7 +493,7 @@ bool AICommandFilter::IsStrategicBlunder(
const ShardokCommand& cmd,
PlayerId pid,
bool isDefender,
const GameStateW& gameState,
const GameState* gameState,
const SettingsGetter& settings,
double minDistToEnemies) {
// Simplified strategic blunder detection for now
@@ -499,7 +503,7 @@ bool AICommandFilter::IsStrategicBlunder(
}
double AICommandFilter::MinDistanceToEnemyUnits(
const GameStateW& gameState,
const GameState* gameState,
PlayerId pid,
const CoordsSet& enemyLocations) {
// Calculate minimum distance from any player unit to any enemy unit
@@ -525,7 +529,7 @@ double AICommandFilter::MinDistanceToEnemyUnits(
}
double AICommandFilter::MinDistanceToCastles(
const GameStateW& gameState,
const GameState* gameState,
PlayerId pid,
const CoordsSet& castleLocations) {
// Calculate minimum distance from any player unit to any castle
@@ -556,7 +560,7 @@ double AICommandFilter::MinDistanceToCastles(
}
bool AICommandFilter::IsPlayerOutnumbered(
const GameStateW& gameState,
const GameState* gameState,
PlayerId pid,
double threshold) {
const int playerUnitCount = CountPlayerUnits(gameState, pid);
@@ -568,7 +572,7 @@ bool AICommandFilter::IsPlayerOutnumbered(
return ratio < threshold;
}
int AICommandFilter::CountPlayerUnits(const GameStateW& gameState, PlayerId pid) {
int AICommandFilter::CountPlayerUnits(const GameState* gameState, PlayerId pid) {
int count = 0;
const auto* units = gameState->units();
@@ -586,7 +590,7 @@ int AICommandFilter::CountPlayerUnits(const GameStateW& gameState, PlayerId pid)
bool AICommandFilter::WouldAbandonCriticalCastle(
const ShardokCommand& cmd,
PlayerId pid,
const GameStateW& gameState) {
const GameState* gameState) {
// Simplified implementation - return false for now
// TODO: Implement proper castle abandonment detection when API is available
return false;
@@ -40,20 +40,20 @@ public:
const CommandListSPtr& commands,
PlayerId pid,
bool isDefender,
const GameStateW& gameState,
const GameState* gameState,
const SettingsGetter& settings,
const APDCache& apdCache);
private:
// Helper to build enemy locations once for efficiency
static CoordsSet BuildEnemyLocations(const GameStateW& gameState, PlayerId pid);
static CoordsSet BuildEnemyLocations(const GameState* gameState, PlayerId pid);
// Spell preparation filters
static bool IsWastefulAction(
const ShardokCommand& cmd,
PlayerId pid,
bool isDefender,
const GameStateW& gameState,
const GameState* gameState,
const SettingsGetter& settings,
const APDCache& apdCache,
const CoordsSet& enemyLocations,
@@ -65,7 +65,7 @@ private:
const ShardokCommand& cmd,
PlayerId pid,
bool isDefender,
const GameStateW& gameState,
const GameState* gameState,
const SettingsGetter& settings,
const APDCache& apdCache,
const CoordsSet& enemyLocations,
@@ -76,29 +76,27 @@ private:
const ShardokCommand& cmd,
PlayerId pid,
bool isDefender,
const GameStateW& gameState,
const GameState* gameState,
const SettingsGetter& settings,
double minDistToEnemies);
// Helper functions for distance and position analysis
static double MinDistanceToEnemyUnits(
const GameStateW& gameState,
const GameState* gameState,
PlayerId pid,
const CoordsSet& enemyLocations);
static double MinDistanceToCastles(
const GameStateW& gameState,
const GameState* gameState,
PlayerId pid,
const CoordsSet& castleLocations);
static bool IsPlayerOutnumbered(const GameStateW& gameState, PlayerId pid, double threshold);
static bool IsPlayerOutnumbered(const GameState* gameState, PlayerId pid, double threshold);
static int CountPlayerUnits(const GameStateW& gameState, PlayerId pid);
static int CountPlayerUnits(const GameState* gameState, PlayerId pid);
static bool WouldAbandonCriticalCastle(
const ShardokCommand& cmd,
PlayerId pid,
const GameStateW& gameState);
static bool
WouldAbandonCriticalCastle(const ShardokCommand& cmd, PlayerId pid, const GameState* gameState);
};
} // namespace shardok
@@ -17,6 +17,7 @@
#include "src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIVictoryConditionScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCommandChooser.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.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"
@@ -137,6 +138,8 @@ using Unit = fb::Unit;
static const std::vector _averageSequence = {0.5};
static const auto _averageGenerator = std::make_shared<SequenceRandomGenerator>(_averageSequence);
static auto IsLateGame(const GameState *gs) { return gs->current_round() > 18; }
static auto CommandSorter(
const AIScoreCalculator::IndexAndScore &l,
const AIScoreCalculator::IndexAndScore &r) -> bool {
@@ -260,15 +263,7 @@ auto AttackerUnitsScore(
const ALCache &alCache,
const APDCache &apdCache,
const MapId &mapId) -> ScoreValue {
// Cache frequently accessed FlatBuffer fields to avoid repeated offset calculations
const auto *cachedGameState = gameState.Get();
const auto *cachedUnits = cachedGameState->units();
const auto *cachedHexMap = cachedGameState->hex_map();
const int16_t cachedRowCount = cachedHexMap->row_count();
const int16_t cachedColumnCount = cachedHexMap->column_count();
const int cachedCurrentRound = cachedGameState->current_round();
bool isLateGame = cachedCurrentRound > 18; // Inline IsLateGame for efficiency
bool isLateGame = IsLateGame(gameState);
// APDCache now has built-in thread-local caching - no need for PreCachedAPDs
ActionPoints braveWaterCost = settings.Backing().brave_water_action_point_cost();
@@ -278,18 +273,14 @@ auto AttackerUnitsScore(
std::vector<const Unit *> attackerUnits{};
std::vector<const Unit *> defenderUnits{};
// Pre-allocate vectors based on estimated unit ratios to avoid reallocations
const size_t estimatedUnitCount = cachedUnits->size();
attackerUnits.reserve(estimatedUnitCount - 1);
defenderUnits.reserve(estimatedUnitCount - 1);
double attackerUnitsValue = 0;
double defenderUnitsValue = 0;
auto occupants = Occupants(*cachedUnits, cachedRowCount, cachedColumnCount);
const auto &occupants = gameState.GetOccupantsVector();
for (const Unit *unit : *cachedUnits) {
const auto *pi = PlayerInfoForPid(cachedGameState, unit->player_id());
for (const Unit *unit : *gameState->units()) {
const auto *pi = PlayerInfoForPid(gameState, unit->player_id());
if (pi == nullptr) continue;
switch (unit->status()) {
@@ -326,7 +317,7 @@ auto AttackerUnitsScore(
}
}
double defenderAdvantage = 1.0 + static_cast<double>(cachedCurrentRound) / 31.0;
double defenderAdvantage = 1.0 + static_cast<double>(gameState->current_round()) / 31.0;
// Can we cache this somehow, it won't usually change within your turn
auto attackLocationsForAttacker = alCache->CachedLocations(defenderUnits, isLateGame);
@@ -351,11 +342,11 @@ auto AttackerUnitsScore(
unit,
priorityList->priorityOrder,
occupants,
cachedHexMap,
gameState->hex_map(),
settings.GetBattalionType(
static_cast<BattalionTypeId>(battTypeId)),
apdCache->GetRaw(
cachedHexMap,
gameState->hex_map(),
mapId,
settings.GetBattalionType(
static_cast<BattalionTypeId>(battTypeId)),
@@ -364,7 +355,7 @@ auto AttackerUnitsScore(
static_cast<BattalionTypeId>(battTypeId))
->allowsBraveWater
? apdCache->GetRaw(
cachedHexMap,
gameState->hex_map(),
mapId,
settings.GetBattalionType(
static_cast<BattalionTypeId>(
@@ -377,16 +368,14 @@ auto AttackerUnitsScore(
auto uv = UnitValue(
unit,
true,
attackerUnits,
gameState,
attackerWantsCastles,
/* includeCastleBonus=*/true,
defenderUnits,
cachedHexMap,
roundsRemaining,
attackLocationsForAttacker,
locationsCausingDanger,
apdCache->GetRaw(
cachedHexMap,
gameState->hex_map(),
mapId,
settings.GetBattalionType(static_cast<BattalionTypeId>(battTypeId)),
false),
@@ -405,16 +394,14 @@ auto AttackerUnitsScore(
auto dv = UnitValue(
unit,
false,
attackerUnits,
gameState,
attackerWantsCastles,
/* includeCastleBonus=*/!defenderShouldScatter,
defenderUnits,
cachedHexMap,
roundsRemaining,
attackLocationsForDefender,
locationsCausingDangerForAttacker,
apdCache->GetRaw(
cachedHexMap,
gameState->hex_map(),
mapId,
settings.GetBattalionType(static_cast<BattalionTypeId>(battTypeId)),
false),
@@ -425,7 +412,7 @@ auto AttackerUnitsScore(
// If the defender is trying to scatter, than we want to be as far away from the nearest
// attacker as possible, AND as far away from the nearest friendly as possible
if (unit->location().row() > -1 && defenderShouldScatter) {
CoordsSet myLocationSet(cachedHexMap);
CoordsSet myLocationSet(gameState->hex_map());
myLocationSet.Add(unit->location());
DIST_T closestDistanceToEnemy = 999;
@@ -435,7 +422,7 @@ auto AttackerUnitsScore(
attackerUnit,
unit->location(),
apdCache->GetRaw(
cachedHexMap,
gameState->hex_map(),
mapId,
settings.GetBattalionType(
static_cast<BattalionTypeId>(attackerBattTypeId)),
@@ -443,14 +430,14 @@ auto AttackerUnitsScore(
settings.GetBattalionType(static_cast<BattalionTypeId>(attackerBattTypeId))
->allowsBraveWater
? apdCache->GetRaw(
cachedHexMap,
gameState->hex_map(),
mapId,
settings.GetBattalionType(
static_cast<BattalionTypeId>(attackerBattTypeId)),
true,
braveWaterCost)
: nullptr,
cachedHexMap);
gameState->hex_map());
if (thisDistance < closestDistanceToEnemy) {
closestDistanceToEnemy = thisDistance;
}
@@ -472,7 +459,7 @@ auto AttackerUnitsScore(
defenderUnit,
unit->location(),
apdCache->GetRaw(
cachedHexMap,
gameState->hex_map(),
mapId,
settings.GetBattalionType(static_cast<BattalionTypeId>(
defenderBattTypeId)),
@@ -481,7 +468,7 @@ auto AttackerUnitsScore(
defenderBattTypeId))
->allowsBraveWater
? apdCache->GetRaw(
cachedHexMap,
gameState->hex_map(),
mapId,
settings.GetBattalionType(
static_cast<BattalionTypeId>(
@@ -489,7 +476,7 @@ auto AttackerUnitsScore(
true,
braveWaterCost)
: nullptr,
cachedHexMap);
gameState->hex_map());
if (thisDistance < closestDistanceToEnemy) {
closestDistanceToFriendly = thisDistance;
}
@@ -511,7 +498,7 @@ auto AttackerUnitsScore(
}
auto AIScoreCalculator::FleeStrategyScoreForState(
const GameStateW &gameState,
const GameState *gameState,
const PlayerId playerId) -> ScoreValue {
ScoreValue scoreValue = 0.0;
@@ -779,7 +766,7 @@ auto AIScoreCalculator::AttackerScoreForState(
void PrintCommand(
const uint32_t index,
const CommandProto &cmd,
const GameStateW &gs,
const GameState *gs,
const ScoreValue utility) {
printf("i%d %s\n ", index, net::eagle0::shardok::common::CommandType_Name(cmd.type()).c_str());
@@ -803,8 +790,7 @@ auto AIScoreCalculator::BasicLookaheadCalculator(
const SettingsGetter &settingsGetter,
const CoordsSet &allCastleCoords,
const APDCache &apdCache,
const ALCache &alCache,
const AITimeBudget *timeBudget) -> ScoreValue {
const ALCache &alCache) -> ScoreValue {
const auto nextUtility = currentUtility;
if (const CommandListSPtr nextCommands = innerEngine->GetAvailableCommandsForAIPlayer(pid);
@@ -820,8 +806,7 @@ auto AIScoreCalculator::BasicLookaheadCalculator(
settingsGetter,
allCastleCoords,
apdCache,
alCache,
timeBudget);
alCache);
if (auto &nextCommand = innerEngine->GetAvailableCommandsForAIPlayer(pid)->at(index);
nextCommand->GetCommandType() != net::eagle0::shardok::common::END_TURN_COMMAND) {
@@ -844,8 +829,7 @@ auto AIScoreCalculator::CalcOne(
const SettingsGetter &settingsGetter,
const CoordsSet &allCastleCoords,
const APDCache &apdCache,
const ALCache &alCache,
const AITimeBudget *timeBudget) -> ImmediateAndLookaheadScore {
const ALCache &alCache) -> ImmediateAndLookaheadScore {
ImmediateAndLookaheadScore returnValue{};
auto innerEngine = std::make_shared<ShardokEngine>(guessedEngine, false);
@@ -853,7 +837,7 @@ auto AIScoreCalculator::CalcOne(
auto innerUtility = GuessedStateScore(
isDefender,
innerEngine->GetCurrentGameState(),
innerEngine->GetCurrentGameStateW(),
attackerStrategy,
allCastleCoords,
settingsGetter,
@@ -863,10 +847,9 @@ auto AIScoreCalculator::CalcOne(
returnValue.immediateScore = innerUtility;
if (remainingLookahead <= 0) {
// Create a promise that will be moved, not destroyed
auto p = std::make_shared<std::promise<eagle0::common::TaskResult<ScoreValue>>>();
returnValue.lookaheadScore = p->get_future();
p->set_value(eagle0::common::TaskResult<ScoreValue>(innerUtility));
std::promise<ScoreValue> p;
returnValue.lookaheadScore = p.get_future();
p.set_value(innerUtility);
} else {
auto lookaheadLambda = [pid,
isDefender,
@@ -878,8 +861,7 @@ auto AIScoreCalculator::CalcOne(
&settingsGetter,
&allCastleCoords,
&apdCache,
&alCache,
timeBudget]() -> ScoreValue {
&alCache]() -> ScoreValue {
return BasicLookaheadCalculator(
pid,
isDefender,
@@ -891,29 +873,16 @@ auto AIScoreCalculator::CalcOne(
settingsGetter,
allCastleCoords,
apdCache,
alCache,
timeBudget);
alCache);
};
#if MULTITHREAD
// Priority = remainingLookahead (higher depth = higher priority)
const int priority = remainingLookahead;
if (timeBudget && timeBudget->remainingBudget.count() > 0) {
const auto now = eagle0::common::ThreadPool::Clock::now();
const auto timeForThisDepth = timeBudget->remainingBudget / (remainingLookahead + 1);
const auto deadline = now + timeForThisDepth;
returnValue.lookaheadScore =
threadPool.enqueue_with_deadline(lookaheadLambda, priority, deadline);
} else {
returnValue.lookaheadScore = threadPool.enqueue(lookaheadLambda, priority);
}
returnValue.lookaheadScore = std::async(std::launch::async, lookaheadLambda);
#else
auto p = std::make_shared<std::promise<eagle0::common::TaskResult<ScoreValue>>>();
returnValue.lookaheadScore = p->get_future();
std::promise<ScoreValue> p;
returnValue.lookaheadScore = p.get_future();
auto lambdaResult = lookaheadLambda();
p->set_value(eagle0::common::TaskResult<ScoreValue>(lambdaResult));
p.set_value(lambdaResult);
#endif
}
@@ -931,8 +900,7 @@ auto AIScoreCalculator::CalcOne(
const SettingsGetter &settingsGetter,
const CoordsSet &allCastleCoords,
const APDCache &apdCache,
const ALCache &alCache,
const AITimeBudget *timeBudget) -> IndexAndScore {
const ALCache &alCache) -> IndexAndScore {
const CommandListSPtr guessedDescriptors = guessedEngine.GetAvailableCommandsForAIPlayer(pid);
// Filter out obviously bad commands to reduce search space
@@ -944,7 +912,7 @@ auto AIScoreCalculator::CalcOne(
settingsGetter,
apdCache);
const auto &gameState = guessedEngine.GetCurrentGameState();
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();
@@ -994,7 +962,7 @@ auto AIScoreCalculator::CalcOne(
vector<IndexAndScore> allIndices(commandCount);
// Primary index is the command index; vector may contain repeated attempts
vector<vector<future<eagle0::common::TaskResult<ScoreValue>>>> scoreFutures(commandCount);
vector<vector<future<ScoreValue>>> scoreFutures(commandCount);
for (uint32_t index = 0; index < commandCount; index++) {
const auto originalIndex = filteredIndices[index];
@@ -1005,9 +973,9 @@ auto AIScoreCalculator::CalcOne(
allIndices[index].type = guessedCommandType;
if (guessedCommandType == net::eagle0::shardok::common::END_TURN_COMMAND) {
std::promise<eagle0::common::TaskResult<ScoreValue>> p;
std::promise<ScoreValue> p;
scoreFutures[index].push_back(p.get_future());
p.set_value(eagle0::common::TaskResult<ScoreValue>(currentUtility));
p.set_value(currentUtility);
allIndices[index].immediateScore = currentUtility;
} else if (IsDeterministic(guessedCommandType)) {
auto [immediateScore, lookaheadScore] =
@@ -1022,8 +990,7 @@ auto AIScoreCalculator::CalcOne(
settingsGetter,
allCastleCoords,
apdCache,
alCache,
timeBudget);
alCache);
allIndices[index].immediateScore = immediateScore;
scoreFutures[index].push_back(std::move(lookaheadScore));
@@ -1046,8 +1013,7 @@ auto AIScoreCalculator::CalcOne(
settingsGetter,
allCastleCoords,
apdCache,
alCache,
timeBudget);
alCache);
// second attempt uses the average of (1 - successChance) and 0 as the roll (so 30%
// chance -> rolling 15)
@@ -1064,8 +1030,7 @@ auto AIScoreCalculator::CalcOne(
settingsGetter,
allCastleCoords,
apdCache,
alCache,
timeBudget);
alCache);
allIndices[index].immediateScore =
std::lerp(failureImmediateScore, successImmediateScore, successChance);
@@ -1074,12 +1039,8 @@ auto AIScoreCalculator::CalcOne(
auto failureSF = failureLookaheadScore.share();
scoreFutures[index].push_back(std::async(
std::launch::deferred,
[successSF, failureSF, successChance]() -> eagle0::common::TaskResult<double> {
auto successResult = successSF.get();
auto failureResult = failureSF.get();
double finalScore =
std::lerp(failureResult.value, successResult.value, successChance);
return eagle0::common::TaskResult<double>(finalScore);
[successSF, failureSF, successChance]() -> double {
return std::lerp(failureSF.get(), successSF.get(), successChance);
}));
} else {
ScoreValue sum = 0.0;
@@ -1100,8 +1061,7 @@ auto AIScoreCalculator::CalcOne(
settingsGetter,
allCastleCoords,
apdCache,
alCache,
timeBudget);
alCache);
sum += immediateScore;
scoreFutures[index].push_back(std::move(lookaheadScore));
@@ -1110,51 +1070,11 @@ auto AIScoreCalculator::CalcOne(
}
}
printf("AIScoreCalculator: BestCommandIndex starting with %zu commands, budget: %lldms\n",
(size_t)commandCount,
timeBudget ? timeBudget->remainingBudget.count() : -1LL);
for (uint32_t i = 0; i < commandCount; i++) {
const auto count = static_cast<ScoreValue>(scoreFutures[i].size());
ScoreValue total = 0.0;
size_t validResults = 0;
for (size_t j = 0; j < scoreFutures[i].size(); j++) {
// Calculate timeout based on remaining time budget with small buffer
auto timeout = std::chrono::steady_clock::now() +
std::chrono::milliseconds(100); // Default fallback
if (timeBudget && timeBudget->remainingBudget.count() > 0) {
timeout = std::chrono::steady_clock::now() + timeBudget->remainingBudget +
std::chrono::milliseconds(100);
}
auto future_status = scoreFutures[i][j].wait_until(timeout);
if (future_status == std::future_status::timeout) {
printf("AIScoreCalculator: TIMEOUT on command %u, future %zu (budget: %lldms)\n",
i,
j,
timeBudget ? timeBudget->remainingBudget.count() : -1LL);
// Future didn't complete within timeout - treat as deadline exceeded
// Don't increment validResults so we fall back to immediate score if needed
continue; // Skip this result
}
auto result = scoreFutures[i][j].get();
if (result.succeeded()) {
total += result.value;
validResults++;
} else if (result.deadlineExceeded()) {
// For deadline exceeded, we skip the result and rely on other futures
// or fallback to immediate score if all tasks failed
}
}
if (validResults > 0) {
allIndices[i].lookaheadScore = total / static_cast<ScoreValue>(validResults);
} else {
// All tasks exceeded deadline, fall back to immediate score
allIndices[i].lookaheadScore = allIndices[i].immediateScore;
}
for (auto &oneFuture : scoreFutures[i]) { total += oneFuture.get(); }
allIndices[i].lookaheadScore = total / count;
}
return *std::ranges::max_element(allIndices, CommandSorter);
@@ -1172,8 +1092,7 @@ auto AIScoreCalculator::EvaluateCommand(
const SettingsGetter &settingsGetter,
const CoordsSet &allCastleCoords,
const APDCache &apdCache,
const ALCache &alCache,
const AITimeBudget *timeBudget) -> CommandEvaluationResult {
const ALCache &alCache) -> CommandEvaluationResult {
const CommandListSPtr guessedDescriptors = guessedEngine.GetAvailableCommandsForAIPlayer(pid);
if (commandIndex >= guessedDescriptors->size()) { return {currentUtility, currentUtility}; }
@@ -1196,16 +1115,8 @@ auto AIScoreCalculator::EvaluateCommand(
settingsGetter,
allCastleCoords,
apdCache,
alCache,
timeBudget);
// Use timeout to avoid hanging on future (conservative timeout for EvaluateCommand)
auto timeout = std::chrono::steady_clock::now() + std::chrono::seconds(2);
if (lookaheadScore.wait_until(timeout) == std::future_status::timeout) {
return {immediateScore, immediateScore}; // Fall back to immediate score
}
auto lookaheadResult = lookaheadScore.get();
return {immediateScore,
lookaheadResult.succeeded() ? lookaheadResult.value : immediateScore};
alCache);
return {immediateScore, lookaheadScore.get()};
} else if (guessedDescriptor->HasOdds()) {
const auto successChancePercentile = guessedDescriptor->GetOddsPercentile();
const double successChance = static_cast<double>(successChancePercentile) / 100.0;
@@ -1223,8 +1134,7 @@ auto AIScoreCalculator::EvaluateCommand(
settingsGetter,
allCastleCoords,
apdCache,
alCache,
timeBudget);
alCache);
// Failure attempt
auto [failureImmediateScore, failureLookaheadScore] = CalcOne(
@@ -1239,28 +1149,11 @@ auto AIScoreCalculator::EvaluateCommand(
settingsGetter,
allCastleCoords,
apdCache,
alCache,
timeBudget);
alCache);
// Return weighted average of success and failure
// Use timeout to avoid hanging on futures (conservative timeout for EvaluateCommand)
auto timeout = std::chrono::steady_clock::now() + std::chrono::seconds(2);
ScoreValue failureLookahead = failureImmediateScore; // Default fallback
if (failureLookaheadScore.wait_until(timeout) != std::future_status::timeout) {
auto failureResult = failureLookaheadScore.get();
failureLookahead =
failureResult.succeeded() ? failureResult.value : failureImmediateScore;
}
ScoreValue successLookahead = successImmediateScore; // Default fallback
if (successLookaheadScore.wait_until(timeout) != std::future_status::timeout) {
auto successResult = successLookaheadScore.get();
successLookahead =
successResult.succeeded() ? successResult.value : successImmediateScore;
}
return {std::lerp(failureImmediateScore, successImmediateScore, successChance),
std::lerp(failureLookahead, successLookahead, successChance)};
std::lerp(failureLookaheadScore.get(), successLookaheadScore.get(), successChance)};
} else {
// For non-deterministic commands without odds, use multiple attempts
ScoreValue totalImmediateScore = 0.0;
@@ -1280,20 +1173,10 @@ auto AIScoreCalculator::EvaluateCommand(
settingsGetter,
allCastleCoords,
apdCache,
alCache,
timeBudget);
alCache);
totalImmediateScore += immediateScore;
// Use timeout to avoid hanging on future (conservative timeout for EvaluateCommand)
auto timeout = std::chrono::steady_clock::now() + std::chrono::seconds(2);
if (lookaheadScore.wait_until(timeout) == std::future_status::timeout) {
totalLookaheadScore += immediateScore; // Fall back to immediate score
} else {
auto lookaheadResult = lookaheadScore.get();
totalLookaheadScore +=
lookaheadResult.succeeded() ? lookaheadResult.value : immediateScore;
}
totalLookaheadScore += lookaheadScore.get();
}
return {totalImmediateScore / maxRepeatCount, totalLookaheadScore / maxRepeatCount};
}
@@ -1311,8 +1194,7 @@ auto AIScoreCalculator::EvaluateCommand(
const CoordsSet &allCastleCoords,
const APDCache &apdCache,
const ALCache &alCache,
const size_t commandIndex,
const AITimeBudget *timeBudget) -> ScoreValue {
const size_t commandIndex) -> ScoreValue {
const auto result = EvaluateCommand(
pid,
isDefender,
@@ -1325,8 +1207,7 @@ auto AIScoreCalculator::EvaluateCommand(
settingsGetter,
allCastleCoords,
apdCache,
alCache,
timeBudget);
alCache);
return result.lookaheadScore;
}
@@ -7,10 +7,9 @@
#include <future>
#include "src/main/cpp/net/eagle0/common/ThreadPool.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AITimeBudget.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
@@ -31,10 +30,6 @@ using ScoreValue = double;
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
class AIScoreCalculator {
private:
// Static thread pool with 32 threads for parallel AI calculations
static inline eagle0::common::ThreadPool threadPool{32};
public:
struct IndexAndScore {
size_t index;
@@ -60,7 +55,7 @@ private:
const APDCache &apdCache) -> ScoreValue;
[[nodiscard]] static auto FleeStrategyScoreForState(
const GameStateW &gameState,
const GameState *gameState,
PlayerId playerId) -> ScoreValue;
[[nodiscard]] static auto DefenderScoreForState(
@@ -83,7 +78,7 @@ private:
struct ImmediateAndLookaheadScore {
ScoreValue immediateScore;
future<eagle0::common::TaskResult<ScoreValue>> lookaheadScore;
future<ScoreValue> lookaheadScore;
};
static auto BasicLookaheadCalculator(
@@ -97,8 +92,7 @@ private:
const SettingsGetter &settingsGetter,
const CoordsSet &allCastleCoords,
const APDCache &apdCache,
const ALCache &alCache,
const AITimeBudget *timeBudget = nullptr) -> ScoreValue;
const ALCache &alCache) -> ScoreValue;
static auto CalcOne(
PlayerId pid,
@@ -112,8 +106,7 @@ private:
const SettingsGetter &settingsGetter,
const CoordsSet &allCastleCoords,
const APDCache &apdCache,
const ALCache &alCache,
const AITimeBudget *timeBudget = nullptr) -> ImmediateAndLookaheadScore;
const ALCache &alCache) -> ImmediateAndLookaheadScore;
struct CommandEvaluationResult {
ScoreValue immediateScore;
@@ -132,8 +125,7 @@ private:
const SettingsGetter &settingsGetter,
const CoordsSet &allCastleCoords,
const APDCache &apdCache,
const ALCache &alCache,
const AITimeBudget *timeBudget = nullptr) -> CommandEvaluationResult;
const ALCache &alCache) -> CommandEvaluationResult;
public:
[[nodiscard]] static auto GuessedStateScore(
@@ -156,8 +148,7 @@ public:
const SettingsGetter &settingsGetter,
const CoordsSet &allCastleCoords,
const APDCache &apdCache,
const ALCache &alCache,
const AITimeBudget *timeBudget = nullptr) -> IndexAndScore;
const ALCache &alCache) -> IndexAndScore;
[[nodiscard]] static auto CommandScore(
PlayerId pid,
@@ -171,8 +162,7 @@ public:
const CoordsSet &allCastleCoords,
const APDCache &apdCache,
const ALCache &alCache,
size_t commandIndex,
const AITimeBudget *timeBudget = nullptr) -> ScoreValue;
size_t commandIndex) -> ScoreValue;
};
} // namespace shardok
@@ -7,6 +7,8 @@
#include <algorithm>
#include "AIAttackLocations.hpp"
#include "AIScoreUtilities.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
namespace shardok {
@@ -131,8 +133,8 @@ auto lightningValue(const Unit *unit) -> double {
auto meteorDropRawValue(
const Coords &targetCoords,
const HexMap *map,
const vector<const Unit *> &enemyOccupants,
const vector<const Unit *> &friendlyOccupants) -> double {
const vector<const Unit *> &occupants,
const Unit *castingUnit) -> double {
double rawMeteorDropValue = 0.0;
if (targetCoords.row() >= 0) {
const double castleMultiplier = GetTerrain(map, targetCoords)->modifier().castle().present()
@@ -140,13 +142,16 @@ auto meteorDropRawValue(
: 1.0;
const int index = targetCoords.row() * map->column_count() + targetCoords.column();
if (const Unit *directOccupant = enemyOccupants[index]) {
rawMeteorDropValue += directOccupant->battalion().size() * kMeteorDirectTargetingEnemy *
castleMultiplier;
}
if (const Unit *directFriendly = friendlyOccupants[index]) {
rawMeteorDropValue += directFriendly->battalion().size() *
kMeteorDirectTargetingFriendly * castleMultiplier;
if (const Unit *directOccupant = occupants[index]) {
if (directOccupant->player_id() == castingUnit->player_id()) {
// Friendly unit
rawMeteorDropValue += directOccupant->battalion().size() *
kMeteorDirectTargetingFriendly * castleMultiplier;
} else {
// Enemy unit
rawMeteorDropValue += directOccupant->battalion().size() *
kMeteorDirectTargetingEnemy * castleMultiplier;
}
}
for (const auto &splashCoords : HexMapUtils::GetAdjacentCoords(map, targetCoords)) {
@@ -157,14 +162,16 @@ auto meteorDropRawValue(
const auto splashIndex =
splashCoords.row() * map->column_count() + splashCoords.column();
if (const Unit *splashOccupant = enemyOccupants[splashIndex]) {
rawMeteorDropValue += splashOccupant->battalion().size() *
kMeteorSplashTargetingEnemy * splashCastleMultiplier;
}
if (const Unit *splashFriendly = friendlyOccupants[splashIndex]) {
rawMeteorDropValue += splashFriendly->battalion().size() *
kMeteorSplashTargetingFriendly * splashCastleMultiplier;
if (const Unit *splashOccupant = occupants[splashIndex]) {
if (splashOccupant->player_id() == castingUnit->player_id()) {
// Friendly unit
rawMeteorDropValue += splashOccupant->battalion().size() *
kMeteorSplashTargetingFriendly * splashCastleMultiplier;
} else {
// Enemy unit
rawMeteorDropValue += splashOccupant->battalion().size() *
kMeteorSplashTargetingEnemy * splashCastleMultiplier;
}
}
}
} else {
@@ -177,14 +184,12 @@ auto meteorDropRawValue(
auto bestTargetValue(
const Unit *unit,
const HexMap *map,
const vector<const Unit *> &enemyOccupants,
const vector<const Unit *> &friendlyOccupants,
const vector<const Unit *> &occupants,
const int meteorRange) -> double {
double maxValue = 0.0;
for (const Coords &target : TilesWithinDistance(map, unit->location(), meteorRange)) {
if (const double thisTargetValue =
meteorDropRawValue(target, map, enemyOccupants, friendlyOccupants);
if (const double thisTargetValue = meteorDropRawValue(target, map, occupants, unit);
thisTargetValue > maxValue) {
maxValue = thisTargetValue;
}
@@ -197,20 +202,15 @@ auto meteorValue(
const Unit *unit,
const int roundsRemaining,
const HexMap *map,
const vector<const Unit *> &enemyUnits,
const vector<const Unit *> &friendlyUnits,
const vector<const Unit *> &occupants,
const int meteorRange,
const double minVigorToCast) -> double {
const auto enemyOccupants = Occupants(enemyUnits, map->row_count(), map->column_count());
const auto friendlyOccupants = Occupants(friendlyUnits, map->row_count(), map->column_count());
switch (unit->attached_hero().profession_info().meteor_cast_state()) {
case net::eagle0::shardok::storage::fb::MultiroundMagicState_NONE:
case net::eagle0::shardok::storage::fb::MultiroundMagicState_START: {
if ((roundsRemaining > 8 ||
(unit->attached_hero().vigor() > 50 && roundsRemaining > 4))) {
const double maxValue =
bestTargetValue(unit, map, enemyOccupants, friendlyOccupants, meteorRange);
const double maxValue = bestTargetValue(unit, map, occupants, meteorRange);
double multiplier = kMeteorStartRoundMultiplier;
if (unit->attached_hero().profession_info().meteor_cast_state() ==
@@ -233,18 +233,18 @@ auto meteorValue(
return meteorDropRawValue(
unit->attached_hero().profession_info().cast_target(),
map,
enemyOccupants,
friendlyOccupants);
occupants,
unit);
} else {
return bestTargetValue(unit, map, enemyOccupants, friendlyOccupants, meteorRange);
return bestTargetValue(unit, map, occupants, meteorRange);
}
case net::eagle0::shardok::storage::fb::MultiroundMagicState_CAST:
return kMeteorCastRoundMultiplier *
meteorDropRawValue(
unit->attached_hero().profession_info().cast_target(),
map,
enemyOccupants,
friendlyOccupants);
occupants,
unit);
default: break;
}
@@ -252,6 +252,7 @@ auto meteorValue(
return 0.0;
}
// Original version that accepts unit vectors for backward compatibility
auto GetRangedAttackBonus(
const Unit *unit,
const bool isAttacker,
@@ -263,10 +264,35 @@ auto GetRangedAttackBonus(
const vector<const Unit *> &defenderUnits,
const int meteorRange,
const double minVigorToCast) -> double {
vector<double> rangedAttackValues{};
// Create occupants vector for meteor calculations
vector<const Unit *> allUnits;
allUnits.insert(allUnits.end(), attackerUnits.begin(), attackerUnits.end());
allUnits.insert(allUnits.end(), defenderUnits.begin(), defenderUnits.end());
const auto occupants = Occupants(allUnits, map->row_count(), map->column_count());
return GetRangedAttackBonus(
unit,
isAttacker,
map,
terrain,
attackLocations,
roundsRemaining,
occupants,
meteorRange,
minVigorToCast);
}
const auto &enemyUnits = isAttacker ? defenderUnits : attackerUnits;
const auto &friendlyUnits = isAttacker ? attackerUnits : defenderUnits;
// Optimized version that accepts occupants vector directly
auto GetRangedAttackBonus(
const Unit *unit,
const bool isAttacker,
const HexMap *map,
const Terrain *terrain,
const AttackLocations &attackLocations,
const int roundsRemaining,
const vector<const Unit *> &occupants,
const int meteorRange,
const double minVigorToCast) -> double {
vector<double> rangedAttackValues{};
const auto &unitLocation = unit->location();
@@ -280,14 +306,8 @@ auto GetRangedAttackBonus(
net::eagle0::shardok::storage::fb::MultiroundMagicState_NONE) {
rangedAttackValues.push_back(lightningValue(unit));
} else {
const double mv = meteorValue(
unit,
roundsRemaining,
map,
enemyUnits,
friendlyUnits,
meteorRange,
minVigorToCast);
const double mv =
meteorValue(unit, roundsRemaining, map, occupants, meteorRange, minVigorToCast);
rangedAttackValues.push_back(mv);
}
}
@@ -325,16 +345,29 @@ auto GetRangedAttackBonus(
auto UnitValue(
const Unit *unit,
const bool isAttacker,
const vector<const Unit *> &attackerUnits,
const GameStateW &gameState,
const bool attackerWantsCastles,
const bool includeCastleBonus,
const vector<const Unit *> &defenderUnits,
const HexMap *map,
const int roundsRemaining,
const AttackLocations &locationsThisSideCanAttackFrom,
const CoordsSet &locationsInDangerFromEnemy,
const ActionPointDistances *distances,
const SettingsGetter &settings) -> ScoreValue {
const HexMap *map = gameState->hex_map();
// Get attacker and defender units from the game state
vector<const Unit *> attackerUnits{};
vector<const Unit *> defenderUnits{};
for (const Unit *u : *gameState->units()) {
if (u->status() != net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) continue;
const auto *pi = PlayerInfoForPid(gameState, u->player_id());
if (pi == nullptr) continue;
if (pi->is_defender()) {
defenderUnits.push_back(u);
} else {
attackerUnits.push_back(u);
}
}
const auto &location = unit->location();
if (location.row() < 0) return 0; // unplaced unit
@@ -376,8 +409,7 @@ auto UnitValue(
terrain,
locationsThisSideCanAttackFrom,
roundsRemaining,
attackerUnits,
defenderUnits,
gameState.GetOccupantsVector(),
settings.Backing().meteor_range(),
settings.Backing().meteor_cast_vigor_cost());
@@ -5,6 +5,7 @@
#ifndef EAGLE0_AIUNITSCORECALCULATOR_HPP
#define EAGLE0_AIUNITSCORECALCULATOR_HPP
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistances.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
@@ -34,14 +35,24 @@ auto GetRangedAttackBonus(
int meteorRange,
double minVigorToCast) -> double;
// Overload that accepts occupants vector for cached performance
auto GetRangedAttackBonus(
const Unit *unit,
bool isAttacker,
const HexMap *map,
const Terrain *terrain,
const AttackLocations &attackLocations,
int roundsRemaining,
const vector<const Unit *> &occupants,
int meteorRange,
double minVigorToCast) -> double;
auto UnitValue(
const Unit *unit,
bool isAttacker,
const vector<const Unit *> &attackerUnits,
const GameStateW &gameState,
bool attackerWantsCastles,
bool includeCastleBonus,
const vector<const Unit *> &defenderUnits,
const HexMap *map,
int roundsRemaining,
const AttackLocations &locationsThisSideCanAttackFrom,
const CoordsSet &locationsInDangerFromEnemy,
@@ -122,7 +122,7 @@ auto AttackerDebufForDefenderOccupiedCriticalTile(
}
auto DefenderHoldsCriticalTilesVictoryScore(
const net::eagle0::shardok::storage::fb::GameState* gameState,
const GameStateW& gameState,
const CoordsSet& criticalTileLocations,
const PlayerInfo* player,
const APDCache& apdCache,
@@ -130,9 +130,8 @@ auto DefenderHoldsCriticalTilesVictoryScore(
const SettingsGetter& settings) -> ScoreValue {
ScoreValue total = 0.0;
const auto rc = gameState->hex_map()->row_count();
const auto cc = gameState->hex_map()->column_count();
const auto occupants = Occupants(*gameState->units(), rc, cc);
const auto& occupants = gameState.GetOccupantsVector();
for (const Coords& criticalTileLocation : criticalTileLocations) {
const auto index = criticalTileLocation.row() * cc + criticalTileLocation.column();
@@ -152,7 +151,7 @@ auto DefenderHoldsCriticalTilesVictoryScore(
}
auto AttackerHoldsCriticalTilesVictoryScore(
const net::eagle0::shardok::storage::fb::GameState* gameState,
const GameStateW& gameState,
const CoordsSet& criticalTileLocations,
const PlayerInfo* player,
const APDCache& apdCache,
@@ -178,9 +177,8 @@ auto AttackerHoldsCriticalTilesVictoryScore(
ScoreValue total = 0.0;
const auto rc = gameState->hex_map()->row_count();
const auto cc = gameState->hex_map()->column_count();
const auto occupants = Occupants(*gameState->units(), rc, cc);
const auto& occupants = gameState.GetOccupantsVector();
for (const Coords& criticalTileLocation : criticalTileLocations) {
const auto index = criticalTileLocation.row() * cc + criticalTileLocation.column();
@@ -246,7 +244,7 @@ auto AttackerHoldsCriticalTilesVictoryScore(
}
auto LastPlayerStandingVictoryScore(
const GameState* gameState,
const GameStateW& gameState,
const PlayerInfo* player,
const APDCache& apdCache,
const ALCache& alCache,
@@ -9,6 +9,7 @@
#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/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
@@ -23,7 +24,7 @@ using std::vector;
using ScoreValue = double;
auto AttackerHoldsCriticalTilesVictoryScore(
const net::eagle0::shardok::storage::fb::GameState* gameState,
const GameStateW& gameState,
const CoordsSet& criticalTileLocations,
const PlayerInfo* player,
const APDCache& apdCache,
@@ -31,7 +32,7 @@ auto AttackerHoldsCriticalTilesVictoryScore(
const SettingsGetter& settings) -> ScoreValue;
auto DefenderHoldsCriticalTilesVictoryScore(
const net::eagle0::shardok::storage::fb::GameState* gameState,
const GameStateW& gameState,
const CoordsSet& criticalTileLocations,
const PlayerInfo* player,
const APDCache& apdCache,
@@ -39,7 +40,7 @@ auto DefenderHoldsCriticalTilesVictoryScore(
const SettingsGetter& settings) -> ScoreValue;
auto LastPlayerStandingVictoryScore(
const GameState* gameState,
const GameStateW& gameState,
const PlayerInfo* player,
const APDCache& apdCache,
const ALCache& alCache,
@@ -14,6 +14,7 @@ cc_library(
":ai_score_utilities",
":ai_strategy",
":ai_water_crossing_command_chooser",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/map:coords_set",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
@@ -158,12 +159,11 @@ cc_library(
deps = [
":ai_attacker_strategy_selector",
":ai_command_filter",
":ai_time_budget",
":ai_unit_score_calculator",
":ai_victory_condition_score_calculator",
"//src/main/cpp/net/eagle0/common:sequence_random_generator",
"//src/main/cpp/net/eagle0/common:thread_pool",
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/view_filters:game_state_guesser",
],
)
@@ -193,6 +193,8 @@ cc_library(
],
deps = [
":ai_attack_locations",
":ai_score_utilities",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
],
@@ -212,6 +214,7 @@ cc_library(
":ai_attack_locations",
":ai_distance_debuf",
":ai_score_utilities",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
@@ -245,10 +245,93 @@ auto IterativeDeepeningAI::IterativeSearch(
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;
result.availableCommandCount = bestResult.availableCommandCount;
result.commandCountEvaluated = bestResult.commandCountEvaluated;
// 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,
@@ -82,8 +82,20 @@ private:
mutable std::vector<int> highestDepthCompleted;
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,
@@ -42,29 +42,7 @@ ShardokAIClient::ShardokAIClient(
: playerId(playerId),
isDefender(isDefender),
alCache(std::make_unique<AttackLocationsCache>(hexMap, settings)),
waterCrossingCommandChooser(playerId, apdCache) {
// Pre-generate the most common cache entries for better performance
const auto mapId = ActionPointDistancesCache::GetMapId(hexMap);
// Pre-fetch for all battalion types, both with and without brave water
using BattalionTypeId = net::eagle0::shardok::storage::fb::BattalionTypeId;
for (int typeId = BattalionTypeId::BattalionTypeId_MIN;
typeId <= BattalionTypeId::BattalionTypeId_MAX;
typeId++) {
const auto battalionTypeId = static_cast<BattalionTypeId>(typeId);
const auto battalionType = settings.GetBattalionType(battalionTypeId);
// Pre-fetch without brave water (braveWaterActionPointCost = -1)
apdCache->GetRaw(hexMap, mapId, battalionType, false, -1);
// Pre-fetch with brave water (includeBravingWater = true, braveWaterActionPointCost = 0)
apdCache->GetRaw(hexMap, mapId, battalionType, true, 0);
}
// Consolidate all the pre-fetched entries into the persistent cache
apdCache->ConsolidateThreadLocalCache_Racy();
}
waterCrossingCommandChooser(playerId, apdCache) {}
void CheckCommand(const CommandProto &realDescriptor, const CommandProto &guessedDescriptor) {
string diff;
@@ -253,7 +231,6 @@ auto ShardokAIClient::ChooseCommandIndex(const ShardokEngine &engine) const
const auto &gsv = engine.GetGameStateView(GetPlayerId());
const auto results = ChooseCommandIndex(settings, gsv, availableCommands);
apdCache->ConsolidateThreadLocalCache_Racy();
return results;
}
}
@@ -1,304 +0,0 @@
# AIScoreCalculator ThreadPool Migration Plan
## Overview
This document outlines the plan to migrate AIScoreCalculator from using std::async to using the new ThreadPool class with priority-based scheduling and deadline support.
## Current State Analysis
### std::async Usage
1. **CalcOne method (line 893)**: Uses `std::async(std::launch::async, lookaheadLambda)` to asynchronously calculate lookahead scores
2. **BestCommandIndex method (line 1053)**: Uses `std::async(std::launch::deferred, ...)` for deferred calculation of weighted scores
3. **Future resolution (lines 1086-1091)**: Waits on all futures using `.get()` in a loop
### Time Management
- IterativeDeepeningAI manages time budgets via AITimeBudget structure
- Time budget contains `remainingBudget` (std::chrono::milliseconds) that gets decremented
- No explicit deadline passing to AIScoreCalculator currently
## Migration Strategy
### 1. ThreadPool Integration
- Create a static thread pool instance with 32 threads in AIScoreCalculator
- Thread pool will be shared across all AIScoreCalculator operations
### 2. Priority Assignment
- Priority = current lookahead depth (passed via `remainingLookahead` parameter)
- Higher depth = higher priority (deeper searches are more valuable)
- This ensures shallow searches complete first, allowing iterative deepening to work effectively
### 3. Deadline Support
- Calculate deadline based on remaining time budget from IterativeDeepeningAI
- Pass deadline to thread pool using `enqueue_with_deadline` for time-critical tasks
- Tasks that exceed deadline will be automatically skipped by the thread pool
### 4. Implementation Details
#### Changes to CalcOne:
```cpp
// OLD:
returnValue.lookaheadScore = std::async(std::launch::async, lookaheadLambda);
// NEW:
// Priority = remainingLookahead (higher depth = higher priority)
// Deadline = current_time + remaining_budget_fraction
returnValue.lookaheadScore = threadPool.enqueue_with_deadline(
lookaheadLambda,
remainingLookahead, // priority
deadline // calculated from time budget
);
```
#### Changes to BestCommandIndex:
```cpp
// Deferred calculations stay as-is (no change needed)
// They're already using std::launch::deferred which is appropriate
// The .get() loop (lines 1086-1091) can be moved to a lower priority task:
auto futureResolutionTask = [&]() {
for (uint32_t i = 0; i < commandCount; i++) {
const auto count = static_cast<ScoreValue>(scoreFutures[i].size());
ScoreValue total = 0.0;
for (auto &oneFuture : scoreFutures[i]) {
total += oneFuture.get();
}
allIndices[i].lookaheadScore = total / count;
}
};
// Enqueue with lower priority (0 or negative) to ensure all calculations complete first
threadPool.enqueue(futureResolutionTask, 0);
```
### 5. Thread Pool Lifecycle
- Initialize as static member: `static inline ThreadPool threadPool{32};`
- Destruction handled automatically by ThreadPool destructor
- No explicit cleanup needed
### 6. Deadline Calculation
- Need to pass time budget information down from IterativeDeepeningAI
- Add optional `AITimeBudget*` parameter to CalcOne and BestCommandIndex
- Calculate deadline as: `now() + (remainingBudget * depth_fraction)`
- Deeper searches get proportionally less time
### 7. Benefits
1. **Better CPU utilization**: 32 threads vs unbounded std::async
2. **Priority scheduling**: Deeper searches get higher priority
3. **Deadline enforcement**: Automatic timeout handling
4. **Resource control**: Fixed thread pool prevents thread explosion
5. **Performance**: Thread reuse avoids creation/destruction overhead
### 8. Testing Plan
1. Verify thread pool initialization
2. Test priority ordering (shallow searches complete first)
3. Test deadline enforcement (expired tasks are skipped)
4. Compare performance with existing implementation
5. Stress test with multiple concurrent AI calculations
### 9. Rollback Plan
- Keep MULTITHREAD macro to allow switching between implementations
- Add THREAD_POOL macro to conditionally compile new implementation
- Allows A/B testing and gradual migration
## Implementation Status
### ✅ Completed
1. **ThreadPool Integration** - Added static 32-thread pool to AIScoreCalculator
2. **Priority Assignment** - Priority = current lookahead depth (higher depth = higher priority)
3. **Deadline Support** - Calculate deadline based on remaining time budget from IterativeDeepeningAI
4. **Method Signatures Updated** - Added `AITimeBudget* timeBudget` parameter to CalcOne and BestCommandIndex
5. **std::async Replacement** - Replaced `std::async(std::launch::async)` with `threadPool.enqueue_with_deadline()`
6. **Time Budget Integration** - IterativeDeepeningAI now passes timeBudget to AIScoreCalculator methods
7. **Build System Updates** - Added thread_pool dependency to BUILD.bazel files
8. **Build Verification** - Successfully builds with `bazel build //src/main/cpp/net/eagle0/shardok/ai:ai_score_calculator`
### Implementation Details
- **Priority Calculation**: `priority = remainingLookahead` (deeper searches get higher priority)
- **Deadline Calculation**: `deadline = now + (remainingBudget / (remainingLookahead + 1))`
- **Thread Pool**: Static 32-thread pool shared across all AIScoreCalculator operations
- **Backward Compatibility**: MULTITHREAD macro preserved for rollback capability
- **Deferred Tasks**: std::launch::deferred calls remain unchanged as planned
### 🔧 Fixed Issues
#### Build Issues
- **SearchAtDepth method**: Fixed missing timeBudget parameter - now passes nullptr since this method doesn't have access to time budget
- **AI Performance Runner**: Successfully builds and runs with `bazel build //src/main/cpp/net/eagle0/shardok/ai_performance_runner:ai_performance_runner`
#### Runtime Issues
- **Hanging AI Performance Test**: ✅ FIXED WITH PROPER DEADLINE SUPPORT
- **Root Cause**: Deadline expiration in ThreadPool was skipping task execution, leaving futures unresolved
- **Symptom**: `./scripts/ai_perf_test.sh` would hang indefinitely when calling `oneFuture.get()` in BestCommandIndex
- **Solution**: Implemented proper status code system with `TaskResult<T>` wrapper
- Created `TaskStatus` enum (SUCCESS, DEADLINE_EXCEEDED, CANCELLED)
- Updated ThreadPool to return `TaskResult<T>` instead of raw `T`
- Tasks that exceed deadline return `TaskResult` with `DEADLINE_EXCEEDED` status
- AIScoreCalculator checks status codes and handles expired tasks gracefully
- **Additional Fix**: Lambda capture issue in `enqueue_with_deadline`
- **Problem**: Complex lambda capture syntax `[f = std::forward<F>(f), args..., deadline]` was causing compilation/runtime issues
- **Solution**: Used `std::bind` to create callable object: `auto actualTask = std::bind(std::forward<F>(f), std::forward<Args>(args)...);`
- **Result**: Cleaner, more reliable task capture and execution
- **Final Result**: Full deadline functionality restored, AI performance test runs correctly without hanging
### Implementation Details - TaskResult System
```cpp
// TaskResult wrapper with status code
template<typename T>
struct TaskResult {
T value;
TaskStatus status;
bool succeeded() const { return status == TaskStatus::SUCCESS; }
bool deadlineExceeded() const { return status == TaskStatus::DEADLINE_EXCEEDED; }
operator T() const { return value; } // Implicit conversion for compatibility
};
// Usage in AIScoreCalculator
for (auto &oneFuture : scoreFutures[i]) {
auto result = oneFuture.get();
if (result.succeeded()) {
total += result.value;
validResults++;
} else if (result.deadlineExceeded()) {
// Skip deadline-exceeded results, fall back to immediate score
}
}
```
### Status: ✅ COMPLETE AND FULLY FUNCTIONAL
All ThreadPool migration work is complete with proper deadline support. The implementation:
- ✅ Builds successfully
- ✅ Runs correctly with full deadline functionality
- ✅ Handles deadline expiration gracefully without hanging
- ✅ AI performance test works perfectly
- ✅ Fixed infinite loop issue caused by TaskResult implicit conversion
### 🔧 Final Issue Resolution - Infinite Loop Bug
#### Problem: TaskResult Implicit Conversion Causing Infinite Recursion
- **Root Cause**: TaskResult<T> had an implicit conversion operator that was interfering with AI search control flow
- **Symptom**: AI performance test hanging in infinite loop, processing same set of futures repeatedly
- **Evidence**: Debug output showed endless cycle processing futures 0-45
#### Solution: Remove Implicit Conversion Operator
- **Change**: Removed `operator T() const { return value; }` from TaskResult<T>
- **Replacement**: Added explicit `.get()` method: `T get() const { return value; }`
- **Code Updates**: Updated all AIScoreCalculator usage to explicitly call `.value` or handle TaskResult properly
#### Updated TaskResult Implementation
```cpp
template<typename T>
struct TaskResult {
T value;
TaskStatus status;
// NO implicit conversion - this was causing infinite recursion
T get() const { return value; }
bool succeeded() const { return status == TaskStatus::SUCCESS; }
bool deadlineExceeded() const { return status == TaskStatus::DEADLINE_EXCEEDED; }
};
```
#### Final Result
- **AI Performance Test**: ✅ Now runs successfully, no more hanging
- **ThreadPool Usage**: ✅ Correctly using 32-thread pool with priority-based scheduling
- **Deadline Support**: ✅ Full deadline functionality with proper status codes
- **Performance**: ✅ AI achieves depth 2 searches consistently across turns
### 🔧 Critical Bug Fix - Promise Lifetime Issue
#### Problem: Stack-Allocated Promise Destruction
The final and most critical bug was in the immediate path (no lookahead) promise handling:
**Broken Code:**
```cpp
if (remainingLookahead <= 0) {
std::promise<TaskResult<ScoreValue>> p; // Stack allocated!
returnValue.lookaheadScore = p.get_future();
p.set_value(TaskResult<ScoreValue>(innerUtility));
// BUG: 'p' destructor runs here, invalidating the future!
}
```
**Root Cause:**
- Most AI calls use `remainingLookahead=0` (immediate path)
- Stack-allocated promise `p` was destroyed when leaving scope
- This left associated futures in invalid/undefined state
- `future.get()` calls on invalid futures hang indefinitely
- ThreadPool tasks (priority 1,2) would queue up waiting for invalid futures
**Fixed Code:**
```cpp
if (remainingLookahead <= 0) {
auto p = std::make_shared<std::promise<TaskResult<ScoreValue>>>(); // Heap allocated!
returnValue.lookaheadScore = p->get_future();
p->set_value(TaskResult<ScoreValue>(innerUtility));
// Promise stays alive through shared_ptr reference counting
}
```
#### Impact of Fix
- **Before**: AI hanging indefinitely on invalid futures, ThreadPool backing up with 271+ queued tasks
- **After**: AI runs smoothly with proper ThreadPool execution, normal performance restored
- **Key Insight**: The ThreadPool itself was working correctly - the hang was caused by invalid futures from destroyed promises
### 🔧 Additional Defensive Improvement - Future Timeout Protection
Added `wait_until()` timeout protection before all `future.get()` calls with budget-aware timeouts:
```cpp
// Before: Direct .get() call could hang indefinitely
auto result = future.get();
// After: Budget-aware timeout protection with fallback
auto timeout = std::chrono::steady_clock::now() + std::chrono::milliseconds(100); // Default
if (timeBudget && timeBudget->remainingBudget.count() > 0) {
timeout = std::chrono::steady_clock::now() + timeBudget->remainingBudget + std::chrono::milliseconds(100);
}
if (future.wait_until(timeout) == std::future_status::timeout) {
// Fall back to immediate score or skip result
return immediateScore;
}
auto result = future.get();
```
**Benefits:**
- **Prevents hanging**: Even if ThreadPool has issues, system won't freeze
- **Budget-aware**: Uses actual time budget + 100ms buffer instead of arbitrary timeouts
- **Graceful degradation**: Falls back to immediate scores when tasks timeout
- **User experience driven**: Respects the original deadline constraints for responsiveness
- **Multi-layered protection**: ThreadPool deadline + budget-aware future timeout
- **Conservative fallback**: Uses 2-second timeout in contexts without time budget
### Status: ✅ FULLY WORKING THREADPOOL MIGRATION - COMPLETE
ThreadPool migration is complete and production-ready:
- ✅ 32-thread pool with priority-based scheduling (higher depth = higher priority)
- ✅ Deadline support with graceful timeout handling
- ✅ TaskResult wrapper with explicit status checking (no implicit conversion)
- ✅ Proper promise/future lifetime management
- ✅ Defensive timeout protection on all future.get() calls
- ✅ AI performance test completes successfully without hanging
-**PERFORMANCE RESTORED**: timeBudget properly propagated through entire call chain
- ✅ Multi-layered robustness against threading issues
### 🎯 Final Performance Fix - timeBudget Parameter Chain
**Issue Resolved**: The main execution path (CommandScore → EvaluateCommand → CalcOne) was not using the time budget, causing 100ms default timeouts and severe performance degradation.
**Root Cause**: Missing timeBudget parameter in key call sites:
- IterativeDeepeningAI → CommandScore (missing timeBudget parameter)
- EvaluateCommand → CalcOne (passing nullptr instead of timeBudget)
- BasicLookaheadCalculator → BestCommandIndex (missing timeBudget parameter)
**Solution Implemented**:
1. **Updated method signatures**: Added `const AITimeBudget *timeBudget = nullptr` to BasicLookaheadCalculator
2. **Fixed all CalcOne calls in EvaluateCommand**: Changed from `nullptr` to `timeBudget` (3 call sites)
3. **Updated lambda capture in CalcOne**: Added timeBudget to capture list and pass to BasicLookaheadCalculator
4. **Fixed IterativeDeepeningAI**: Added `&timeBudget` parameter to CommandScore call
5. **Verified build and performance**: AI now properly uses time budget, reaches depth 2, shows correct budget values
**Performance Test Results**:
- **Before**: Severe performance degradation, limited depth achievement
- **After**: Normal performance restored, proper depth 2 searches, budget-aware timeouts working
- **Evidence**: Logs show correct budget usage: `budget: 1500ms`, `budget: 1068ms`, `achieved depth 2`
The ThreadPool migration is now **FULLY COMPLETE** with all performance issues resolved.
@@ -13,7 +13,6 @@
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
using namespace shardok;
@@ -81,10 +80,6 @@ int main(int argc, char* argv[]) {
// Set exec path so FilesystemUtils can find resource files
FilesystemUtils::SetExecPath(argv[0]);
// Set cache directory for ActionPointDistances
FixedActionPointDistances::SetCacheDirectory(
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
try {
std::cout << "Shardok AI Performance Runner\n";
std::cout << "==============================\n";
@@ -1,58 +1,137 @@
//
// Created by Dan Crosby on 2025-01-21.
// Created by Dan Crosby on 2025-01-17.
//
#include "GameStateW.hpp"
#include <stdexcept>
#include <utility>
#include "src/main/cpp/net/eagle0/common/ContainerUtils.hpp"
namespace shardok {
auto GameStateW::GetOccupant(const net::eagle0::shardok::storage::fb::Coords& coords) const
-> const Unit* {
const auto* state = Get();
if (!state || !state->hex_map()) { return nullptr; }
/**
* @class OccupantsCache
* @brief Cache for efficient unit location lookups.
*
* This cache maintains a vector indexed by map position (row * columnCount + column)
* containing pointers to units at each position. This converts O(n) unit lookups
* into O(1) operations.
*/
class OccupantsCache {
public:
OccupantsCache(const GameStateW& gameState) { Regenerate(gameState); }
const int16_t rowCount = state->hex_map()->row_count();
const int16_t columnCount = state->hex_map()->column_count();
void Regenerate(const GameStateW& gameState) {
const auto* state = gameState.Get();
if (!state || !state->hex_map() || !state->units()) {
occupants_.clear();
rowCount_ = 0;
columnCount_ = 0;
return;
}
// Check bounds
if (coords.row() < 0 || coords.row() >= rowCount || coords.column() < 0 ||
coords.column() >= columnCount) {
return nullptr;
}
rowCount_ = state->hex_map()->row_count();
columnCount_ = state->hex_map()->column_count();
const size_t mapSize = rowCount_ * columnCount_;
// Fast path: use bitfield cache if available
if (state->occupied_tiles() && !state->occupied_tiles()->empty()) {
const size_t tileIndex = coords.row() * columnCount + coords.column();
const size_t expectedBitfieldSize = (rowCount * columnCount + 7) / 8; // Ceiling division
// Clear and resize the vector
occupants_.clear();
occupants_.resize(mapSize, nullptr);
if (state->occupied_tiles()->size() == expectedBitfieldSize) {
const size_t byteIndex = tileIndex / 8;
const size_t bitOffset = tileIndex % 8;
const uint8_t byte = state->occupied_tiles()->Get(byteIndex);
const bool isOccupied = (byte & (1 << bitOffset)) != 0;
if (!isOccupied) {
return nullptr; // Fast path: definitely no unit here (90% of cases)
// Populate the cache
for (const auto* unit : *state->units()) {
if (unit &&
unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) {
const auto& location = unit->location();
if (location.row() >= 0 && location.row() < rowCount_ && location.column() >= 0 &&
location.column() < columnCount_) {
const size_t index = location.row() * columnCount_ + location.column();
occupants_[index] = unit;
}
}
}
}
// Slow path: O(n) search through units
// Used when bitfield not available OR when bitfield indicates occupation
if (!state->units()) { return nullptr; }
for (int i = 0; i < state->units()->size(); ++i) {
const auto* unit = state->units()->Get(i);
if (unit && unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT &&
unit->location().row() == coords.row() &&
unit->location().column() == coords.column()) {
return unit;
[[nodiscard]] auto GetOccupant(const net::eagle0::shardok::storage::fb::Coords& coords) const
-> const GameStateW::Unit* {
if (coords.row() < 0 || coords.row() >= rowCount_ || coords.column() < 0 ||
coords.column() >= columnCount_) {
return nullptr;
}
const size_t index = coords.row() * columnCount_ + coords.column();
return occupants_[index];
}
return nullptr;
[[nodiscard]] auto GetOccupantsVector() const -> const std::vector<const GameStateW::Unit*>& {
return occupants_;
}
private:
std::vector<const GameStateW::Unit*> occupants_;
int16_t rowCount_ = 0;
int16_t columnCount_ = 0;
};
// GameStateW implementation
GameStateW::GameStateW() : BaseType() { InitializeCache(); }
GameStateW::GameStateW(const GameStateW& other)
: BaseType(other),
occupantsCache_(other.occupantsCache_) {
// Share the cache - no need to rebuild!
}
GameStateW::GameStateW(GameStateW&& other) noexcept
: BaseType(std::move(other)),
occupantsCache_(std::move(other.occupantsCache_)) {}
GameStateW& GameStateW::operator=(const GameStateW& other) {
if (this != &other) {
BaseType::operator=(other);
occupantsCache_ = other.occupantsCache_; // Share the cache
}
return *this;
}
GameStateW& GameStateW::operator=(GameStateW&& other) noexcept {
if (this != &other) {
BaseType::operator=(std::move(other));
occupantsCache_ = std::move(other.occupantsCache_);
}
return *this;
}
GameStateW::GameStateW(const BaseType& base) : BaseType(base) { InitializeCache(); }
GameStateW::GameStateW(BaseType&& base) : BaseType(std::move(base)) { InitializeCache(); }
GameStateW::GameStateW(flatbuffers::FlatBufferBuilder& fbb) : BaseType(fbb) { InitializeCache(); }
GameStateW::~GameStateW() = default;
auto GameStateW::GetOccupant(const net::eagle0::shardok::storage::fb::Coords& coords) const
-> const Unit* {
if (!occupantsCache_) {
throw std::runtime_error(
"GameStateW::GetOccupant() called with uninitialized occupants cache. "
"This indicates a bug where the cache was invalidated without being rebuilt. "
"Call RebuildOccupantsCache() or InitializeCache() after any mutations.");
}
return occupantsCache_->GetOccupant(coords);
}
auto GameStateW::GetOccupantsVector() const -> const std::vector<const Unit*>& {
if (!occupantsCache_) {
throw std::runtime_error(
"GameStateW::GetOccupantsVector() called with uninitialized occupants cache. "
"This indicates a bug where the cache was invalidated without being rebuilt. "
"Call RebuildOccupantsCache() or InitializeCache() after any mutations.");
}
return occupantsCache_->GetOccupantsVector();
}
auto GameStateW::GetKnownEnemyOccupant(
@@ -69,57 +148,13 @@ auto GameStateW::GetKnownEnemyOccupant(
return nullptr;
}
void GameStateW::UpdateOccupiedTile(
const net::eagle0::shardok::storage::fb::Coords& oldCoords,
const net::eagle0::shardok::storage::fb::Coords& newCoords) {
const auto* state = Get();
auto* mutableOccupiedTiles = (*this)->mutable_occupied_tiles();
if (!state || !state->hex_map() || !mutableOccupiedTiles) { return; }
const int16_t rowCount = state->hex_map()->row_count();
const int16_t columnCount = state->hex_map()->column_count();
// Clear old position in bitfield
if (oldCoords.row() >= 0 && oldCoords.row() < rowCount && oldCoords.column() >= 0 &&
oldCoords.column() < columnCount) {
const size_t tileIndex = oldCoords.row() * columnCount + oldCoords.column();
const size_t byteIndex = tileIndex / 8;
const size_t bitOffset = tileIndex % 8;
if (byteIndex < mutableOccupiedTiles->size()) {
uint8_t byte = mutableOccupiedTiles->Get(byteIndex);
byte &= ~(1 << bitOffset); // Clear the bit
mutableOccupiedTiles->Mutate(byteIndex, byte);
}
}
// Set new position in bitfield
if (newCoords.row() >= 0 && newCoords.row() < rowCount && newCoords.column() >= 0 &&
newCoords.column() < columnCount) {
const size_t tileIndex = newCoords.row() * columnCount + newCoords.column();
const size_t byteIndex = tileIndex / 8;
const size_t bitOffset = tileIndex % 8;
if (byteIndex < mutableOccupiedTiles->size()) {
uint8_t byte = mutableOccupiedTiles->Get(byteIndex);
byte |= (1 << bitOffset); // Set the bit
mutableOccupiedTiles->Mutate(byteIndex, byte);
}
}
void GameStateW::RebuildOccupantsCache() {
occupantsCache_.reset();
occupantsCache_ = std::make_shared<OccupantsCache>(*this);
}
auto GameStateW::GetOccupiedTilesBitfield() const -> const flatbuffers::Vector<uint8_t>* {
const auto* state = Get();
if (!state || !state->hex_map()) { return nullptr; }
void GameStateW::InvalidateOccupantsCache() { occupantsCache_.reset(); }
if (!state->occupied_tiles() || state->occupied_tiles()->empty()) { return nullptr; }
// Verify the bitfield size matches expected map size
const int16_t rowCount = state->hex_map()->row_count();
const int16_t columnCount = state->hex_map()->column_count();
const size_t expectedBitfieldSize = (rowCount * columnCount + 7) / 8;
if (state->occupied_tiles()->size() != expectedBitfieldSize) { return nullptr; }
return state->occupied_tiles();
}
void GameStateW::InitializeCache() { occupantsCache_ = std::make_shared<OccupantsCache>(*this); }
} // namespace shardok
@@ -5,6 +5,9 @@
#ifndef EAGLE0_GAMESTATEW_HPP
#define EAGLE0_GAMESTATEW_HPP
#include <memory>
#include <vector>
#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"
@@ -12,6 +15,9 @@
namespace shardok {
// Forward declaration for the cache
class OccupantsCache;
/**
* @class GameStateW
* @brief A wrapper class for the FlatBuffer-generated GameState type.
@@ -24,6 +30,10 @@ namespace shardok {
* This class is part of the shardok namespace and is designed to simplify
* interactions with the GameState FlatBuffer type while maintaining the
* flexibility and functionality of the Wrapper base class.
*
* The class includes an occupants cache for efficient unit location lookups,
* which significantly improves performance for operations that frequently
* query which units occupy specific map coordinates.
*/
class GameStateW : public Wrapper<net::eagle0::shardok::storage::fb::GameState> {
public:
@@ -34,50 +44,56 @@ public:
using BaseType::BaseType;
// Default constructor
GameStateW() : BaseType() {}
GameStateW();
// Copy constructor
GameStateW(const GameStateW& other) : BaseType(other) {}
GameStateW(const GameStateW& other);
// Move constructor
GameStateW(GameStateW&& other) noexcept : BaseType(std::move(other)) {}
GameStateW(GameStateW&& other) noexcept;
// Copy assignment
GameStateW& operator=(const GameStateW& other) {
BaseType::operator=(other);
return *this;
}
GameStateW& operator=(const GameStateW& other);
// Move assignment
GameStateW& operator=(GameStateW&& other) noexcept {
BaseType::operator=(std::move(other));
return *this;
}
GameStateW& operator=(GameStateW&& other) noexcept;
// Constructor from base type
GameStateW(const BaseType& base) : BaseType(base) {}
GameStateW(BaseType&& base) : BaseType(std::move(base)) {}
GameStateW(const BaseType& base);
GameStateW(BaseType&& base);
// Constructor from FlatBufferBuilder
explicit GameStateW(flatbuffers::FlatBufferBuilder& fbb);
// Destructor
~GameStateW();
/**
* @brief Get the unit occupying the specified coordinates using occupied tiles bitfield.
* @brief Get the unit occupying the specified coordinates.
* @param coords The coordinates to check.
* @return Pointer to the unit at the coordinates, or nullptr if none.
*
* Fast path: O(1) bitfield check for empty tiles (~90% of cases).
* Slow path: O(n) unit search only when bitfield indicates occupation (~10% of cases).
* This is an O(1) operation using the internal occupants cache.
*/
[[nodiscard]] auto GetOccupant(const net::eagle0::shardok::storage::fb::Coords& coords) const
-> const Unit*;
/**
* @brief Get the known enemy unit occupying the specified coordinates using occupied tiles
* bitfield.
* @brief Get all occupants indexed by their position.
* @return Vector where index = row * columnCount + column contains unit pointer or nullptr.
*
* This provides direct access to the occupants cache for bulk operations.
*/
[[nodiscard]] auto GetOccupantsVector() const -> const std::vector<const Unit*>&;
/**
* @brief Get the known enemy unit occupying the specified coordinates.
* @param playerId The player ID to check enemies for.
* @param allyPids Vector of allied player IDs.
* @param coords The coordinates to check.
* @return Pointer to the enemy unit at the coordinates, or nullptr if none.
*
* Uses the bitfield-optimized GetOccupant() internally.
* This is an O(1) operation using the internal occupants cache.
*/
[[nodiscard]] auto GetKnownEnemyOccupant(
PlayerId playerId,
@@ -85,23 +101,45 @@ public:
const net::eagle0::shardok::storage::fb::Coords& coords) const -> const Unit*;
/**
* @brief Update the occupied tiles bitfield when a unit changes position.
* @param oldCoords The previous coordinates (use {-1, -1} if unit was off-map).
* @param newCoords The new coordinates (use {-1, -1} if unit is now off-map).
* @brief Rebuild the occupants cache.
*
* This method invalidates the existing cache and immediately rebuilds it to reflect
* the current state of units. Call this method after any mutations to the game state
* that might affect unit positions.
*
* @warning This method is NOT thread-safe. It should only be called:
* - After mutations when the GameStateW instance is owned by a single thread
* - When transitioning ownership between threads (with proper synchronization)
*
* Concurrent calls to RebuildOccupantsCache() or calls concurrent with GetOccupant()
* will result in undefined behavior due to race conditions.
*/
void UpdateOccupiedTile(
const net::eagle0::shardok::storage::fb::Coords& oldCoords,
const net::eagle0::shardok::storage::fb::Coords& newCoords);
void RebuildOccupantsCache();
/**
* @brief Get the occupied tiles bitfield for efficient tile occupancy checking.
* @return Pointer to the bitfield data, or nullptr if not available.
* @brief Initialize the occupants cache.
* This rebuilds the cache from the current game state.
*
* Returns the raw bitfield where bit at index (row*column_count + col) indicates
* if that tile is occupied. Useful for caching the bitfield to avoid repeated
* GameStateW lookups in performance-critical code like MoveCommand.
* @warning This method is NOT thread-safe. It should only be called:
* - During GameStateW construction (automatically handled)
* - After mutations when the GameStateW instance is owned by a single thread
* - When transitioning ownership between threads (with proper synchronization)
*
* Concurrent calls to InitializeCache() or calls concurrent with GetOccupant()
* will result in undefined behavior due to race conditions.
*/
[[nodiscard]] auto GetOccupiedTilesBitfield() const -> const flatbuffers::Vector<uint8_t>*;
void InitializeCache();
/**
* @brief Force regeneration of the occupants cache.
* @deprecated Use RebuildOccupantsCache() instead. This method is kept for
* backward compatibility but should not be used in new code.
*/
void InvalidateOccupantsCache();
private:
// The occupants cache implementation - shared across copies for efficiency
std::shared_ptr<OccupantsCache> occupantsCache_;
};
} // namespace shardok
@@ -37,6 +37,15 @@ using net::eagle0::shardok::storage::ShardokActionWithResultingState;
using GameStatusProto = net::eagle0::shardok::common::GameStatus;
using TileModifierProto = net::eagle0::shardok::common::TileModifier;
[[nodiscard]] auto ShardokEngine::GetCurrentGameState() const
-> net::eagle0::shardok::storage::fb::GameState const * {
return gameState.Get();
}
[[nodiscard]] auto ShardokEngine::GetCurrentGameStateW() const -> const GameStateW & {
return gameState;
}
[[nodiscard]] auto ShardokEngine::GetCurrentGameStateBytes() const -> byte_vector {
return gameState.ToByteVector();
}
@@ -248,7 +257,7 @@ auto ShardokEngine::PostWhilePlayerHasOnlyOneOption(
}
auto updateAction = UpdateGameStatusAction(
GetCurrentGameState(),
GetCurrentGameStateW(),
criticalTileCoords,
settingsGetter,
false /* atEndOfRound */);
@@ -262,7 +271,7 @@ void ShardokEngine::HandlePlayerTurnEnd(const std::shared_ptr<RandomGenerator> &
if (static_cast<uint32_t>(GetCurrentGameState()->current_player()) >=
GetCurrentGameState()->player_infos()->size()) {
ApplyAndAddActionResults(UpdateGameStatusAction(
GetCurrentGameState(),
GetCurrentGameStateW(),
criticalTileCoords,
settingsGetter,
true /* atEndOfRound */)
@@ -286,7 +295,7 @@ void ShardokEngine::HandlePlayerTurnEnd(const std::shared_ptr<RandomGenerator> &
} else {
const auto updateAction = UpdateGameStatusAction(
GetCurrentGameState(),
GetCurrentGameStateW(),
criticalTileCoords,
settingsGetter,
false /* atEndOfRound */);
@@ -351,7 +360,7 @@ void ShardokEngine::PostPlacementCommands(
}
const auto updateAction = UpdateGameStatusAction(
GetCurrentGameState(),
GetCurrentGameStateW(),
criticalTileCoords,
settingsGetter,
false /* atEndOfRound */);
@@ -433,7 +442,7 @@ void ShardokEngine::PostActionUnchecked(
}
const auto updateAction = UpdateGameStatusAction(
GetCurrentGameState(),
GetCurrentGameStateW(),
criticalTileCoords,
settingsGetter,
false /* atEndOfRound */);
@@ -455,7 +464,7 @@ void ShardokEngine::HandleActionResult(
const Coords modifiedCoords = FromCoordsProto(modifierWithCoords.coords());
const TileModifierProto &modifier = modifierWithCoords.modifiers();
const Unit *occupant = gameState.GetOccupant(modifiedCoords);
const Unit *occupant = Occupant(GetCurrentGameState()->units(), modifiedCoords);
// Check for swept away hero
if (const Terrain *terrain = GetTerrain(GetCurrentGameState()->hex_map(), modifiedCoords);
occupant && IsWater(terrain->type()) && !IsTraversible(modifier) &&
@@ -600,7 +609,7 @@ auto ShardokEngine::EndGameUnits() const -> vector<net::eagle0::shardok::storage
"Trying to get the end game units before the game is over");
}
const auto &gs = GetCurrentGameState();
const auto *gs = GetCurrentGameState();
vector<net::eagle0::shardok::storage::ResolvedUnit> endgameUnits;
AddUnits(endgameUnits, *gs->units());
@@ -60,14 +60,15 @@ private:
[[nodiscard]] auto HandleUnitFallingIntoWater(
const Terrain *terrain,
const fb::Unit *unit,
const net::eagle0::shardok::storage::fb::Unit *unit,
std::shared_ptr<RandomGenerator> randomGenerator) const -> vector<ActionResult>;
void HandleActionResult(
const ActionResult &actionResult,
const std::shared_ptr<RandomGenerator> &randomGenerator);
[[nodiscard]] auto GetUnit(const UnitId uid) const -> const fb::Unit * {
[[nodiscard]] auto GetUnit(const UnitId uid) const
-> const net::eagle0::shardok::storage::fb::Unit * {
return GetCurrentGameState()->units()->Get(uid);
}
@@ -111,7 +112,8 @@ public:
[[nodiscard]] auto GetGameStateAtStartOfAction(ActionId startingActionId) const -> GameStateW;
[[nodiscard]] auto GetCurrentGameState() const -> const GameStateW & { return gameState; }
[[nodiscard]] auto GetCurrentGameState() const -> fb::GameState const *;
[[nodiscard]] auto GetCurrentGameStateW() const -> const GameStateW &;
[[nodiscard]] auto GetCurrentGameStateBytes() const -> byte_vector;
@@ -125,7 +127,7 @@ public:
// Controller API
[[nodiscard]] auto GetGameHistory(ActionId lastUpdatedActionId) const
-> vector<ShardokActionWithResultingState>;
-> vector<net::eagle0::shardok::storage::ShardokActionWithResultingState>;
[[nodiscard]] auto GetUnfilteredHistoryCount() const -> size_t {
return actionHistory.size() + startingHistoryCount;
@@ -141,7 +143,8 @@ public:
[[nodiscard]] auto GetFilteredGameHistory(PlayerId askingPlayer) const
-> vector<net::eagle0::shardok::api::ActionResultView>;
[[nodiscard]] auto GetUnitById(PlayerId askingPlayer, UnitId unitId) const -> UnitView;
[[nodiscard]] auto GetUnitById(PlayerId askingPlayer, UnitId unitId) const
-> net::eagle0::shardok::api::UnitView;
void PostPlacementCommands(
PlayerId player,
@@ -174,7 +177,7 @@ public:
[[nodiscard]] auto GetMonth() const -> int { return GetCurrentGameState()->month(); }
[[nodiscard]] auto GetPlayerInfos() const -> vector<PlayerInfoProto> {
const auto &currentGameState = GetCurrentGameState();
const auto *currentGameState = GetCurrentGameState();
vector<PlayerInfoProto> protos{};
for (const auto *const piFB : *currentGameState->player_infos()) {
protos.push_back(fb::ToPlayerInfoProto(piFB));
@@ -182,18 +185,18 @@ public:
return protos;
}
[[nodiscard]] auto GetGameStatus() const
-> const net::eagle0::shardok::storage::fb::GameStatus * {
auto GetGameStatus() const -> const net::eagle0::shardok::storage::fb::GameStatus * {
return GetCurrentGameState()->status();
}
[[nodiscard]] auto GetGameSettings() const -> GameSettingsSPtr { return gameSettings; }
auto GetGameSettings() const -> GameSettingsSPtr { return gameSettings; }
static inline auto GameIsOver(const fb::GameStatus *status) -> bool {
static inline auto GameIsOver(const net::eagle0::shardok::storage::fb::GameStatus *status)
-> bool {
return (status->state() == net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY);
}
[[nodiscard]] inline auto GameIsOver() const -> bool { return GameIsOver(GetGameStatus()); }
inline auto GameIsOver() const -> bool { return GameIsOver(GetGameStatus()); }
};
} // namespace shardok
@@ -5,15 +5,14 @@
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include <algorithm>
#include <chrono>
#include <unordered_map>
#include "src/main/cpp/net/eagle0/common/ByteHasher.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/HexMapHelpers.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/HexMapHasher.hpp"
#define CACHE_STATS_LOGGING_ false
#define CACHE_STATS_FREQUENCY_SECONDS_ 1
namespace shardok {
@@ -23,45 +22,22 @@ thread_local ActionPointDistancesCache::TLSCache ActionPointDistancesCache::tlsC
#if CACHE_STATS_LOGGING_
// Thread-local statistics for performance monitoring
thread_local struct {
int persistentHits = 0;
int persistentMisses = 0;
int localHits = 0;
int localMisses = 0;
int sharedAccesses = 0;
int evictionEvents = 0;
int apdLoadedFromFile = 0;
int apdGeneratedFresh = 0;
std::chrono::steady_clock::time_point lastReportTime = std::chrono::steady_clock::now();
} cacheStats;
// Helper function to print stats periodically
static void MaybePrintCacheStats() {
auto now = std::chrono::steady_clock::now();
if (std::chrono::duration_cast<std::chrono::seconds>(now - cacheStats.lastReportTime).count() >=
CACHE_STATS_FREQUENCY_SECONDS_) {
printf("Thread cache stats: %d persistent hits, %d persistent misses, %d local hits, "
"%d local misses, %d shared accesses, %d eviction events, "
"%d APD loaded from file, %d APD generated fresh\n",
cacheStats.persistentHits,
cacheStats.persistentMisses,
cacheStats.localHits,
cacheStats.localMisses,
cacheStats.sharedAccesses,
cacheStats.evictionEvents,
cacheStats.apdLoadedFromFile,
cacheStats.apdGeneratedFresh);
cacheStats.lastReportTime = now;
}
}
#endif
class BadHashException final : public std::exception {
class BadHashException : public std::exception {
public:
BadHashException() = default;
[[nodiscard]] auto what() const noexcept -> const char* override { return "Bad map hash!"; };
};
constexpr int kBattalionTypeCount = 6;
// Helper function to check if any ice is present on the map
static auto HasIceOnMap(const HexMap* map) -> bool {
return std::ranges::any_of(*map->terrain(), [](const auto* terrain) {
@@ -81,11 +57,13 @@ static auto CreateIceClearedMap(const HexMap* map) -> fb::HexMapW {
// Now modify the ice on the mutable copy
auto* mutableMap = mapCopy.Get();
const auto* terrainVec = mutableMap->mutable_terrain();
auto* terrainVec = mutableMap->mutable_terrain();
for (size_t i = 0; i < terrainVec->size(); i++) {
auto* terrain = terrainVec->GetMutableObject(i);
// Only process tiles with ice
if (auto* terrain = terrainVec->GetMutableObject(i); terrain->modifier().ice().present()) {
if (terrain->modifier().ice().present()) {
terrain->mutable_modifier().mutable_ice().mutate_present(false);
terrain->mutable_modifier().mutable_ice().mutate_integrity(0.0f);
}
@@ -98,11 +76,16 @@ static auto CreateIceClearedMap(const HexMap* map) -> fb::HexMapW {
return mapCopy;
}
ActionPointDistancesCache::ActionPointDistancesCache() {
bravingDistances.resize(kBattalionTypeCount);
noBravingDistances.resize(kBattalionTypeCount);
}
auto ActionPointDistancesCache::MakeCacheKey(
const MapId& mapId,
const BattalionTypeSPtr& battalionType,
const bool includeBravingWater,
const int braveWaterActionPointCost) -> FullCacheKey {
bool includeBravingWater,
int braveWaterActionPointCost) -> FullCacheKey {
return FullCacheKey{
mapId,
static_cast<int>(battalionType->typeId),
@@ -116,13 +99,6 @@ auto ActionPointDistancesCache::GetMapId(const HexMap* map) -> MapId {
return MapId{.terrainTypesId = map->base_hash(), .modifierId = modifierId};
}
void ActionPointDistancesCache::ConsolidateThreadLocalCache_Racy() {
persistentCache.insert(std::begin(sharedDistances), std::end(sharedDistances));
sharedDistances.clear();
// Clear the current thread's cache since persistent cache now has everything
tlsCache.clear();
}
auto ActionPointDistancesCache::GetRaw(
const HexMap* map,
@@ -134,26 +110,20 @@ auto ActionPointDistancesCache::GetRaw(
auto cacheKey =
MakeCacheKey(mapId, battalionType, includeBravingWater, braveWaterActionPointCost);
// Check the persistent map first
if (auto persistentIt = persistentCache.find(cacheKey); persistentIt != persistentCache.end()) {
#if CACHE_STATS_LOGGING_
cacheStats.persistentHits++;
MaybePrintCacheStats();
#endif
// Return directly from persistent cache without TLS insertion
// This avoids the overhead of thread-local storage operations on hot path
return persistentIt->second.rawPtr;
}
#if CACHE_STATS_LOGGING_
cacheStats.persistentMisses++;
#endif
// Check thread-local cache first (no locks needed!)
if (auto localIt = tlsCache.find(cacheKey); localIt != tlsCache.end()) {
auto localIt = tlsCache.find(cacheKey);
if (localIt != tlsCache.end()) {
#if CACHE_STATS_LOGGING_
cacheStats.localHits++;
MaybePrintCacheStats();
// 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.rawPtr; // Raw pointer - zero overhead access!
}
@@ -163,18 +133,15 @@ auto ActionPointDistancesCache::GetRaw(
#endif
// Check shared cache before expensive ice-clearing operation
auto& vec = includeBravingWater ? bravingDistances : noBravingDistances;
auto& distancesMap = vec[battalionType->typeId];
shared_ptr<ActionPointDistances> sharedResult;
if (sharedDistances.if_contains(cacheKey, [&sharedResult](const auto& kv) {
if (distancesMap.if_contains(mapId, [&sharedResult](const auto& kv) {
sharedResult = kv.second;
})) {
#if CACHE_STATS_LOGGING_
cacheStats.sharedAccesses++;
#endif
// Cache hit in shared cache - store in thread-local cache and return
tlsCache.emplace(cacheKey, CacheEntry(sharedResult));
#if CACHE_STATS_LOGGING_
MaybePrintCacheStats();
#endif
return sharedResult.get();
}
@@ -182,17 +149,18 @@ auto ActionPointDistancesCache::GetRaw(
const bool hasIce = HasIceOnMap(map);
// Declaring here to keep the copied map in scope
fb::HexMapW iceClearedMap;
const HexMap* mapToUse = map;
if (hasIce) {
// Create ice-cleared map for pathfinding
// This prevents AI from considering ice as a valid path toward enemies
fb::HexMapW iceClearedMap = CreateIceClearedMap(map);
iceClearedMap = CreateIceClearedMap(map);
mapToUse = iceClearedMap.Get();
}
// Create new pathfinding result using factory method
auto creationResult = FixedActionPointDistances::Create(
// Create new pathfinding result
auto result = std::make_shared<FixedActionPointDistances>(
mapToUse,
mapId.terrainTypesId,
mapId.modifierId,
@@ -200,22 +168,11 @@ auto ActionPointDistancesCache::GetRaw(
includeBravingWater,
braveWaterActionPointCost);
#if CACHE_STATS_LOGGING_
// Track whether this was loaded from file or generated fresh
if (creationResult.loadedFromFile) {
cacheStats.apdLoadedFromFile++;
} else {
cacheStats.apdGeneratedFresh++;
}
#endif
auto result = creationResult.apd;
// Store in shared cache
sharedDistances.lazy_emplace_l(
cacheKey,
distancesMap.lazy_emplace_l(
mapId,
[](const auto& kv) { /* already checked above */ },
[=](const auto& ctor) { ctor(cacheKey, result); });
[=](const auto& ctor) { ctor(mapId, result); });
// Cache result locally for future lookups by this thread
// Store both shared_ptr and raw pointer for hybrid access
@@ -23,12 +23,18 @@ struct MapId {
uint64_t terrainTypesId;
uint64_t modifierId;
friend size_t hash_value(const MapId& id) {
return gtl::HashState::combine(0, id.terrainTypesId, id.modifierId);
}
auto operator==(const MapId& other) const -> bool {
return terrainTypesId == other.terrainTypesId && modifierId == other.modifierId;
}
};
// Unified cache key for both thread-safe and thread-local caches
using APDKey = MapId;
// Extended key for thread-local cache that includes battalion type
struct FullCacheKey {
MapId mapId;
int battalionTypeId;
@@ -45,48 +51,41 @@ struct FullCacheKey {
// Hash function for FullCacheKey
struct FullCacheKeyHash {
size_t operator()(const FullCacheKey& key) const {
// Pack small fields into a single 64-bit value
uint64_t packed = (static_cast<uint64_t>(key.battalionTypeId) << 32) |
(static_cast<uint64_t>(key.braveWaterCost) << 1) |
(key.includeBravingWater ? 1 : 0);
// Hash MapId fields directly instead of going through hash_value(MapId)
return gtl::HashState::combine(0, key.mapId.terrainTypesId, key.mapId.modifierId, packed);
return gtl::HashState::combine(
hash_value(key.mapId),
key.battalionTypeId,
key.includeBravingWater,
key.braveWaterCost);
}
};
class ActionPointDistancesCache {
private:
using APDMap = gtl::parallel_flat_hash_map<
APDKey,
shared_ptr<ActionPointDistances>,
gtl::priv::hash_default_hash<APDKey>,
gtl::priv::hash_default_eq<APDKey>,
std::allocator<std::pair<const APDKey, shared_ptr<ActionPointDistances>>>,
6,
std::mutex>;
vector<APDMap> noBravingDistances;
vector<APDMap> bravingDistances;
// Thread-local cache storing both shared_ptr and raw pointer for hybrid access
// Lifetime guaranteed by shared cache ownership
struct CacheEntry {
shared_ptr<ActionPointDistances> sharedPtr;
const ActionPointDistances* rawPtr;
explicit CacheEntry(shared_ptr<ActionPointDistances> ptr)
CacheEntry(shared_ptr<ActionPointDistances> ptr)
: sharedPtr(std::move(ptr)),
rawPtr(sharedPtr.get()) {}
};
// Tier 1: persistent map. This is NOT safe to write to while reads may be happening.
using PersistentMap = gtl::flat_hash_map<FullCacheKey, CacheEntry, FullCacheKeyHash>;
PersistentMap persistentCache;
using APDMap = gtl::parallel_flat_hash_map<
FullCacheKey,
shared_ptr<ActionPointDistances>,
FullCacheKeyHash,
std::equal_to<FullCacheKey>,
std::allocator<std::pair<const FullCacheKey, shared_ptr<ActionPointDistances>>>,
6,
std::mutex>;
APDMap sharedDistances;
using TLSCache = gtl::flat_hash_map<FullCacheKey, CacheEntry, FullCacheKeyHash>;
static thread_local TLSCache tlsCache;
// Epoch system removed - TLS cache uses size-based eviction instead
// Helper to build cache key
static auto MakeCacheKey(
const MapId& mapId,
@@ -95,11 +94,7 @@ private:
int braveWaterActionPointCost) -> FullCacheKey;
public:
explicit ActionPointDistancesCache() {
// Pre-size persistent cache to reduce hash collisions
// Estimate: ~12 entries from pre-fetching + ~50-100 entries during gameplay
persistentCache.reserve(128);
}
explicit ActionPointDistancesCache();
// Returns raw pointer for zero overhead access
// Lifetime guaranteed by shared cache ownership
@@ -112,11 +107,6 @@ public:
static auto GetMapId(const HexMap* map) -> MapId;
// Consolidate the thread-safe cache into the persistent cache and clear
// the current thread's local cache. This is only safe if we know reads
// are not happening from other threads.
void ConsolidateThreadLocalCache_Racy();
// Cache management methods
static void ClearThreadLocalCache();
static size_t GetThreadLocalCacheSize();
@@ -26,24 +26,14 @@ void FixedActionPointDistances::SetCacheDirectory(const string& newDir) {
static thread_local byte_vector _scratch;
FixedActionPointDistances::FixedActionPointDistances(const HexMap* map, int columnCount)
: ActionPointDistances(columnCount) {}
auto FixedActionPointDistances::Create(
FixedActionPointDistances::FixedActionPointDistances(
const HexMap* map,
int64_t terrainTypesHash,
int64_t modifierHash,
const BattalionTypeSPtr& battalionType,
bool includeBravingWater,
int braveWaterActionPointCost) -> CreationResult {
// Create the object using private constructor
auto apd = std::shared_ptr<FixedActionPointDistances>(
new FixedActionPointDistances(map, map->column_count()));
CreationResult result;
result.apd = apd;
result.loadedFromFile = false;
int braveWaterActionPointCost)
: ActionPointDistances(map->column_count()) {
string path = "";
if (!cacheDirectory.empty()) {
@@ -65,26 +55,22 @@ auto FixedActionPointDistances::Create(
const int indexCount = map->row_count() * map->column_count();
if (!path.empty() && FilesystemUtils::FileExistsAtPath(path)) {
apd->distances.resize(indexCount);
distances.resize(indexCount);
// load from file
const auto& bytes = _scratch.ReplaceWithPath(path);
const auto* ptr = reinterpret_cast<const DIST_T*>(bytes.data());
for (int fromIndex = 0; fromIndex < indexCount; fromIndex++) {
apd->distances[fromIndex].insert(
apd->distances[fromIndex].end(),
&(ptr[0]),
&(ptr[indexCount]));
distances[fromIndex].insert(distances[fromIndex].end(), &(ptr[0]), &(ptr[indexCount]));
ptr += indexCount;
}
result.loadedFromFile = true;
} else {
_scratch.reserve(indexCount * indexCount * sizeof(DIST_T));
vector<std::future<vector<vector<DIST_T>>>> futures(indexCount);
auto braveWaterPossibleCoords =
includeBravingWater ? apd->BraveWaterPossibleCoords(map) : nullptr;
includeBravingWater ? BraveWaterPossibleCoords(map) : nullptr;
int chunkSize = (indexCount + ASYNC_COUNT - 1) / ASYNC_COUNT;
// Break into chunks for async
@@ -97,7 +83,7 @@ auto FixedActionPointDistances::Create(
for (int i = 0; i < chunkSize; i++) {
const auto fromIndex = chunkStartIndex + i;
if (fromIndex >= indexCount) { continue; }
chunkVec.push_back(ActionPointDistances::GenerateDistances(
chunkVec.push_back(GenerateDistances(
fromIndex,
map,
includeBravingWater,
@@ -109,20 +95,18 @@ auto FixedActionPointDistances::Create(
});
}
apd->distances.reserve(indexCount);
distances.reserve(indexCount);
_scratch.clear();
_scratch.reserve(indexCount * indexCount * sizeof(DIST_T));
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
auto resultsVec = futures[chunkIdx].get();
apd->distances.insert(apd->distances.end(), resultsVec.begin(), resultsVec.end());
distances.insert(distances.end(), resultsVec.begin(), resultsVec.end());
for (const auto& r : resultsVec) { _scratch.append(r); }
}
if (!path.empty()) { FilesystemUtils::AtomicallySaveToPath(path, _scratch); }
}
return result;
}
} // namespace shardok
} // namespace shardok
@@ -17,43 +17,31 @@ using std::vector;
using BattalionTypeSPtr = std::shared_ptr<const BattalionType>;
class FixedActionPointDistances final : public ActionPointDistances {
public:
struct CreationResult {
std::shared_ptr<FixedActionPointDistances> apd;
bool loadedFromFile;
};
private:
vector<vector<DIST_T>> distances;
inline static string cacheDirectory = "";
// Private constructor - use Create factory method instead
explicit FixedActionPointDistances(const HexMap *map, int columnCount);
public:
static void SetCacheDirectory(const string &newDir);
// Factory method to create FixedActionPointDistances with metadata
static auto Create(
explicit FixedActionPointDistances(
const HexMap *map,
int64_t terrainTypesHash,
int64_t modifierHash,
const BattalionTypeSPtr &battalionType,
bool includeBravingWater,
int braveWaterActionPointCost = -1) -> CreationResult;
int braveWaterActionPointCost = -1);
~FixedActionPointDistances() override = default;
[[nodiscard]] auto Distance(const int fromIndex, const int toIndex) const -> DIST_T override {
auto Distance(const int fromIndex, const int toIndex) const -> DIST_T override {
return distances[fromIndex][toIndex];
}
[[nodiscard]] auto Distance(const Coords &from, const Coords &to) const -> DIST_T override {
auto Distance(const Coords &from, const Coords &to) const -> DIST_T override {
return Distance(ToIndex(from), ToIndex(to));
}
friend struct CreationResult;
};
} // namespace shardok
@@ -338,18 +338,10 @@ void MutatingApplyResult(
settings);
}
// Capture old position before applying changes
auto *mutableUnit = mutatingGameState->units()->GetMutableObject(changedUnit->unit_id());
const auto oldLocation = mutableUnit->location();
fb::ApplyUnit(mutableUnit, changedUnit, status);
// Update occupied tiles bitfield if position changed
const auto &newLocation = changedUnit->location();
if (oldLocation.row() != newLocation.row() ||
oldLocation.column() != newLocation.column()) {
mutatingGameState.UpdateOccupiedTile(oldLocation, newLocation);
}
fb::ApplyUnit(
mutatingGameState->units()->GetMutableObject(changedUnit->unit_id()),
changedUnit,
status);
if (battalionSizeBefore != battalionSizeAfter) {
if (changedUnit->battalion().type() ==
@@ -400,6 +392,9 @@ void MutatingApplyResult(
mutatingGameState->mutable_possible_chargee_ids()->Mutate(i, -1);
}
// Rebuild the occupants cache since units may have moved or been destroyed
mutatingGameState.RebuildOccupantsCache();
return;
}
@@ -4,8 +4,6 @@
#include "src/main/cpp/net/eagle0/shardok/library/action_result_applier/GameStateCopier.hpp"
#include <cstring>
namespace shardok {
using Unit = net::eagle0::shardok::storage::fb::Unit;
@@ -34,39 +32,6 @@ auto CopyWithExtraUnits(const GameStateW& original, int additionalCount) -> Game
endGST.units.push_back(unit);
}
// Copy occupied tiles bitfield from original GameState (much faster than O(n) rebuild)
if (startGS->occupied_tiles() && startGS->hex_map()) {
const size_t originalBitfieldSize = startGS->occupied_tiles()->size();
endGST.occupied_tiles.resize(originalBitfieldSize);
// Fast O(bitfield_bytes) copy instead of O(units) rebuild
std::memcpy(
endGST.occupied_tiles.data(),
startGS->occupied_tiles()->data(),
originalBitfieldSize);
} else if (endGST.hex_map) {
// Fallback: create new bitfield only if original doesn't have one
const int16_t rowCount = endGST.hex_map->row_count;
const int16_t columnCount = endGST.hex_map->column_count;
const size_t mapSize = rowCount * columnCount;
const size_t bitfieldSize = (mapSize + 7) / 8; // Ceiling division
endGST.occupied_tiles.resize(bitfieldSize, 0); // Initialize all bits to 0 (empty)
// Populate bitfield based on unit positions (O(n) fallback)
for (const auto& unit : endGST.units) {
if (unit.status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) {
const auto& location = unit.location();
if (location.row() >= 0 && location.row() < rowCount && location.column() >= 0 &&
location.column() < columnCount) {
const size_t tileIndex = location.row() * columnCount + location.column();
const size_t byteIndex = tileIndex / 8;
const size_t bitOffset = tileIndex % 8;
endGST.occupied_tiles[byteIndex] |= (1 << bitOffset); // Set the bit
}
}
}
}
flatbuffers::FlatBufferBuilder newFbb;
newFbb.ForceDefaults(true);
newFbb.Finish(net::eagle0::shardok::storage::fb::GameState::Pack(newFbb, &endGST));
@@ -71,14 +71,15 @@ auto ToVector(const Units &units) -> vector<const Unit *> {
auto FallIntoWaterAction::AdjacentsWithTerrain(
const Coords &location,
const GameState *gameState,
const GameStateW &gameState,
const PlayerId playerId) -> vector<AdjacentWithTerrain> {
const CoordsSet adjacentCoords = HexMapUtils::GetAdjacentCoords(gameState->hex_map(), location);
vector<AdjacentWithTerrain> vec{};
for (const Coords &adjCoords : adjacentCoords) {
const auto *possibleOccupant = Occupant(gameState->units(), adjCoords);
// Note: This function takes GameState*, not GameStateW, so cannot use cache
const auto *possibleOccupant = gameState.GetOccupant(adjCoords);
if (possibleOccupant &&
(possibleOccupant->player_id() == playerId || !possibleOccupant->hidden())) {
continue;
@@ -35,7 +35,7 @@ private:
static auto AdjacentsWithTerrain(
const Coords& location,
const GameState* gameState,
const GameStateW& gameState,
PlayerId playerId) -> vector<AdjacentWithTerrain>;
[[nodiscard]] auto InternalExecute(
@@ -229,7 +229,7 @@ auto UpdateGameStatusAction::InternalExecute(
// Check for attacker occupying castles & towns
bool foundUncontrolledCriticalTile = false;
for (const auto& criticalTile : criticalTileLocations) {
const auto* possibleOccupant = currentState.GetOccupant(criticalTile);
const auto* possibleOccupant = gameState.GetOccupant(criticalTile);
if (!possibleOccupant) {
foundUncontrolledCriticalTile = true;
@@ -23,7 +23,7 @@ private:
const std::shared_ptr<RandomGenerator>& generator) const
-> vector<ActionResult> override;
const GameState* gameState;
const GameStateW& gameState;
const CoordsSet criticalTileLocations;
const SettingsGetter settingsGetter;
const bool atEndOfRound;
@@ -34,7 +34,7 @@ private:
public:
UpdateGameStatusAction(
const GameState* gameState,
const GameStateW& gameState,
const CoordsSet& criticalTileLocations,
const SettingsGetter& settingsGetter,
const bool atEndOfRound)
@@ -44,6 +44,8 @@ auto ReduceCommandFactory::AddAvailableReduceCommands(
const auto *const occupant = Occupant(units, reduceCoords);
if (occupant && occupant->player_id() == playerId) continue;
// This function takes a Units*, not a GameStateW, so we can't use the cache method
// yet
const auto *const enemyOccupant =
KnownEnemyOccupant(playerId, units, allyPids, reduceCoords);
@@ -63,7 +63,9 @@ auto shardok::MoveCommand::InternalExecute(
bool isEligibleCharger = false;
bool wasHidden = mover.hidden();
const auto* occ = currentState.GetOccupant(destination);
// Check for occupant using the original state (we don't want to see our own unit that just
// moved)
const auto* occ = Occupant(allUnits, destination);
if (occ) {
// Tile is occupied -- ambush!
// Reconstruct the current state by applying results generated so far
@@ -94,7 +96,7 @@ auto shardok::MoveCommand::InternalExecute(
}
for (const auto& adj : HexMapUtils::GetAdjacentCoords(map, destination)) {
const auto* occupant = currentState.GetOccupant(adj);
const auto* occupant = Occupant(allUnits, adj);
if (!occupant) continue;
if (occupant->player_id() == mover.player_id()) continue;
if (common::Contains(allyPids, occupant->player_id())) continue;
@@ -110,7 +112,7 @@ auto shardok::MoveCommand::InternalExecute(
mover.attached_hero().profession_info().profession() ==
net::eagle0::shardok::storage::fb::Profession_RANGER) {
for (const auto& adj : HexMapUtils::GetAdjacentCoords(map, destination)) {
auto occupant = currentState.GetOccupant(adj);
auto occupant = Occupant(allUnits, adj);
if (!occupant) continue;
if (occupant->player_id() == mover.player_id()) continue;
if (common::Contains(allyPids, occupant->player_id())) continue;
@@ -298,6 +298,7 @@ auto GameStateGuesser::GuessedState(
}
}
// Cache is already initialized from construction, no need to rebuild
return gsw;
}
@@ -16,29 +16,19 @@ include "weather.fbs";
namespace net.eagle0.shardok.storage.fb;
table GameState {
// 4-byte aligned scalar fields first
dead_count:int;
dead_armament:float;
// 2-byte aligned fields
eligible_charger_id:int16 = -1;
// 1-byte aligned fields grouped together
current_round:int8;
current_player:int8;
month:int8;
// Variable-size fields (vectors, tables, strings) last
units:[net.eagle0.shardok.storage.fb.Unit];
hex_map:net.eagle0.shardok.storage.fb.HexMap;
weather:net.eagle0.shardok.storage.fb.Weather;
dead_count:int;
dead_armament:float;
current_round:int8;
current_player:int8;
eligible_charger_id:int16 = -1;
possible_chargee_ids:[int16];
status:net.eagle0.shardok.storage.fb.GameStatus;
player_infos:[net.eagle0.shardok.storage.fb.PlayerInfo];
// Bitfield cache: 1 bit per tile indicating occupancy. Bit at index (row*col_count + col)
occupied_tiles:[uint8];
game_id:string;
player_infos:[net.eagle0.shardok.storage.fb.PlayerInfo];
month:int8;
}
root_type GameState;
@@ -11,35 +11,27 @@ struct ControlInfo {
}
struct Hero {
// 4-byte fields first for alignment
eagle_hero_id:int;
vigor:float;
starting_vigor:float;
spent_vigor:float;
// 2-byte fields next for alignment
unit_id:int16 (key);
strength_xp:int16;
agility_xp:int16;
constitution_xp:int16;
charisma_xp:int16;
wisdom_xp:int16;
// 1-byte fields next for alignment
is_vip:bool;
control_info:net.eagle0.shardok.storage.fb.ControlInfo (native_inline);
strength:int8;
strength_xp:int16;
agility:int8;
agility_xp:int16;
constitution:int8;
constitution_xp:int16;
charisma:int8;
charisma_xp:int16;
wisdom:int8;
wisdom_xp:int16;
integrity:int8;
ambition:int8;
gregariousness:int8;
bravery:int8;
integrity:int8;
// Nested structs
control_info:net.eagle0.shardok.storage.fb.ControlInfo (native_inline);
vigor:float;
starting_vigor:float;
spent_vigor:float;
profession_info:net.eagle0.shardok.storage.fb.ProfessionSpecificInfo (native_inline);
}
@@ -22,15 +22,9 @@ enum UnitStatus : int8 {
}
struct Unit {
// 4-byte aligned fields first
food_remaining:float;
// 2-byte aligned fields
eagle_player_id:int;
unit_id:int16(key);
commanding_unit_id:int16;
// 1-byte aligned fields grouped for better packing
eagle_player_id:int8;
player_id:int8;
remaining_action_points:int8;
stun_rounds_remaining:int8;
@@ -46,8 +40,7 @@ struct Unit {
can_archery:bool;
can_start_fire:bool;
opponent_knowledge:[int8:10];
// Complex nested types last
food_remaining:float;
attached_hero:net.eagle0.shardok.storage.fb.Hero;
location:net.eagle0.shardok.storage.fb.Coords (native_inline);
battalion:net.eagle0.shardok.storage.fb.Battalion (native_inline);
@@ -138,23 +138,6 @@ object RandomHeroGenerator {
.map { ip => hero.copy(imagePath = ip) }
}
private def createOneWithNoProfession(
functionalRandom: FunctionalRandom,
gender: Gender,
remainingImagePathsByProfession: (Profession, Gender) => Set[String]
): RandomState[HeroC] =
heroWithBaseStats(gender, functionalRandom)
.continue { withNoProfession }
.continue { case (hero, nextRandom) =>
imagePathForProfessionAndGender(
random = nextRandom,
profession = hero.profession,
gender = gender,
remainingImagePathsByProfession = remainingImagePathsByProfession
)
.map { ip => hero.copy(imagePath = ip) }
}
// Caller should update remainingImagePathsByProfession based on the result
def createHeroes(
hids: Vector[HeroId],
@@ -197,48 +180,6 @@ object RandomHeroGenerator {
}
._1
// Create heroes with NoProfession for faction leaders during new game setup
def createHeroesWithNoProfession(
hids: Vector[HeroId],
functionalRandom: FunctionalRandom,
remainingImagePathsByProfession: (Profession, Gender) => Set[String],
date: Date
): RandomState[Vector[HeroGenerationResponse]] =
hids
.foldLeft(
RandomState(Vector[HeroGenerationResponse](), functionalRandom),
remainingImagePathsByProfession
) { case ((RandomState(accHeroes, fr), accPaths), hid) =>
gender(fr) match {
case RandomState(g, newFr) =>
createOneWithNoProfession(newFr, g, accPaths) match {
case RandomState(newHero, innerFr) =>
val nameTextId = s"hn_$hid"
(
RandomState(
accHeroes :+ HeroGenerationResponse(
hero = newHero.copy(
id = hid,
nameTextId = nameTextId,
backstoryVersions = Vector(
BackstoryVersion(
textId = s"hero_${hid}_backstory_0",
date = date
)
)
),
nameInfo = NameRequest(nameId = nameTextId, gender = g)
),
innerFr
),
(p: Profession, g: Gender) =>
accPaths(p, g) - newHero.imagePath
)
}
}
}
._1
private def imagePathForProfessionAndGender(
random: FunctionalRandom,
profession: Profession,
@@ -193,7 +193,7 @@ object FixedNewGameCreation extends NewGameCreation {
(value.newValue.nextHeroId until value.newValue.nextHeroId + count).toVector
RandomHeroGenerator
.createHeroesWithNoProfession(
.createHeroes(
hids = hids,
functionalRandom = fr,
remainingImagePathsByProfession =
@@ -157,7 +157,7 @@ TEST_F(AICommandFilterTest, EndTurnCommandAlwaysAllowed) {
commands,
0,
false,
gameState,
gameState.operator->(),
GetGameSettingsGetter(),
apdCache);
@@ -181,7 +181,7 @@ TEST_F(AICommandFilterTest, BasicCommandFiltering) {
commands,
0,
false,
gameState,
gameState.operator->(),
GetGameSettingsGetter(),
apdCache);
@@ -204,7 +204,7 @@ TEST_F(AICommandFilterTest, DefenderVsAttackerFiltering) {
commands,
0,
false, // isDefender = false
gameState,
gameState.operator->(),
GetGameSettingsGetter(),
apdCache);
@@ -213,7 +213,7 @@ TEST_F(AICommandFilterTest, DefenderVsAttackerFiltering) {
commands,
1,
true, // isDefender = true
gameState,
gameState.operator->(),
GetGameSettingsGetter(),
apdCache);
@@ -240,7 +240,7 @@ TEST_F(AICommandFilterTest, WrongPlayerCommands) {
commands,
0, // Player 0's turn
false,
gameState,
gameState.operator->(),
GetGameSettingsGetter(),
apdCache);
@@ -262,7 +262,7 @@ TEST_F(AICommandFilterTest, DifferentPlayerPerspectives) {
commands,
0,
false,
gameState,
gameState.operator->(),
GetGameSettingsGetter(),
apdCache);
@@ -271,7 +271,7 @@ TEST_F(AICommandFilterTest, DifferentPlayerPerspectives) {
commands,
1,
true,
gameState,
gameState.operator->(),
GetGameSettingsGetter(),
apdCache);
@@ -298,7 +298,7 @@ TEST_F(AICommandFilterTest, FilteringPreservesOrder) {
commands,
0,
false,
gameState,
gameState.operator->(),
GetGameSettingsGetter(),
apdCache);
@@ -341,7 +341,7 @@ TEST_F(AICommandFilterTest, LargeCommandListPerformance) {
commands,
0,
false,
gameState,
gameState.operator->(),
GetGameSettingsGetter(),
apdCache);
@@ -369,7 +369,7 @@ TEST_F(AICommandFilterTest, AllCommandTypesBasicFiltering) {
commands,
0,
false,
gameState,
gameState.operator->(),
GetGameSettingsGetter(),
apdCache);
@@ -397,7 +397,7 @@ TEST_F(AICommandFilterTest, BoundaryPositionHandling) {
commands,
0,
false,
gameState,
gameState.operator->(),
GetGameSettingsGetter(),
apdCache);
@@ -415,7 +415,7 @@ TEST_F(AICommandFilterTest, EmptyCommandList) {
commands,
0,
false,
gameState,
gameState.operator->(),
GetGameSettingsGetter(),
apdCache);
@@ -215,6 +215,7 @@ TEST_F(PlayerSetupCommandFactoryTest, unplacedDefendingRanger_cannotHideOnOccupi
// Put an enemy at 2, 2
attacker1->mutable_location() = Coords(2, 2);
attacker1->mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT);
gameState.RebuildOccupantsCache();
CommandList commands{};
factory.AddAvailablePlayerSetupCommands(commands, 1, gameState);
@@ -52,14 +52,8 @@ TEST_F(FixedActionPointDistancesTest, testTiming) {
const auto start = system_clock::now();
for (const HexMapW& hexMap : hexMaps) {
auto result = shardok::FixedActionPointDistances::Create(
hexMap,
0x1234,
0xABCD,
battalionType,
true,
5);
// The result contains both the APD and whether it was loaded from file
shardok::FixedActionPointDistances
distances(hexMap, 0x1234, 0xABCD, battalionType, true, 5);
}
const auto end = system_clock::now();
@@ -66,7 +66,7 @@ TEST_F(UpdateGameStatusActionTests, onePlayerLeft_addsGameOverResult) {
GetGameSettingsGetter(),
false);
vector<ActionResult> results = action.Execute(GameStateW(), nullptr);
vector<ActionResult> results = action.Execute(gameState, nullptr);
EXPECT_GE(results.size(), 1);
EXPECT_EQ(net::eagle0::shardok::common::GAME_OVER, results[results.size() - 1].type());
EXPECT_EQ(
@@ -111,7 +111,7 @@ TEST_F(UpdateGameStatusActionTests, twoPlayersLeft_noGameOverResult) {
GetGameSettingsGetter(),
false);
vector<ActionResult> results = action.Execute(GameStateW(), nullptr);
vector<ActionResult> results = action.Execute(gameState, nullptr);
EXPECT_EQ(results.size(), 0);
}
@@ -146,16 +146,15 @@ TEST_F(UpdateGameStatusActionTests, onePlayerAllUnitsHidden_gameOverResult) {
auto gameState = Wrapper<GameState>(fbb);
gameState->mutable_units()->GetMutableObject(1)->mutate_hidden(true);
gameState->mutable_units()->GetMutableObject(1)->mutate_hidden(true);
GameStateW gameStateW(gameState);
auto action = UpdateGameStatusAction(
gameState,
gameStateW,
criticalTileLocations,
GetGameSettingsGetter(),
false);
vector<ActionResult> results = action.Execute(GameStateW(), nullptr);
vector<ActionResult> results = action.Execute(gameStateW, nullptr);
EXPECT_GE(results.size(), 1);
EXPECT_EQ(net::eagle0::shardok::common::GAME_OVER, results[results.size() - 1].type());
EXPECT_EQ(
@@ -198,7 +197,7 @@ TEST_F(UpdateGameStatusActionTests, remainingPlayerDoesNotHaveLastPlayerStanding
GetGameSettingsGetter(),
false);
vector<ActionResult> results = action.Execute(GameStateW(), nullptr);
vector<ActionResult> results = action.Execute(gameState, nullptr);
EXPECT_EQ(0, results.size());
}
@@ -206,8 +205,6 @@ TEST_F(UpdateGameStatusActionTests, attackerHoldsAllCastles_gameOverResult) {
FlatBufferBuilder fbb;
fbb.ForceDefaults(true);
auto hexMapOffset = AddBasicMap(fbb);
auto attackerPlayer = AddPlayerInfo(
fbb,
0,
@@ -232,6 +229,12 @@ TEST_F(UpdateGameStatusActionTests, attackerHoldsAllCastles_gameOverResult) {
statusBuilder.add_state(net::eagle0::shardok::storage::fb::GameStatus_::State_GAME_RUNNING);
auto statusOffset = statusBuilder.Finish();
// Create a minimal hex map for the cache
auto hexMapBuilder = net::eagle0::shardok::storage::fb::HexMapBuilder(fbb);
hexMapBuilder.add_row_count(10);
hexMapBuilder.add_column_count(10);
auto hexMapOffset = hexMapBuilder.Finish();
net::eagle0::shardok::storage::fb::GameStateBuilder gsBuilder(fbb);
gsBuilder.add_player_infos(playersOffset);
gsBuilder.add_units(unitsOffset);
@@ -241,13 +244,17 @@ TEST_F(UpdateGameStatusActionTests, attackerHoldsAllCastles_gameOverResult) {
fbb.Finish(gsBuilder.Finish());
auto gameState = Wrapper<GameState>(fbb);
GameStateW gameStateW(gameState);
criticalTileLocations.Add(Coords(0, 4));
auto action =
UpdateGameStatusAction(gameState, criticalTileLocations, GetGameSettingsGetter(), true);
auto action = UpdateGameStatusAction(
gameStateW,
criticalTileLocations,
GetGameSettingsGetter(),
true);
vector<ActionResult> results = action.Execute(GameStateW(gameState), nullptr);
vector<ActionResult> results = action.Execute(gameStateW, nullptr);
EXPECT_GE(results.size(), 1);
EXPECT_EQ(net::eagle0::shardok::common::GAME_OVER, results[results.size() - 1].type());
EXPECT_EQ(
@@ -259,8 +266,6 @@ TEST_F(UpdateGameStatusActionTests, attackerHoldsAllCastles_hiddenDefenderFlees)
FlatBufferBuilder fbb;
fbb.ForceDefaults(true);
auto hexMapOffset = AddBasicMap(fbb);
auto attackerPlayer = AddPlayerInfo(
fbb,
0,
@@ -291,20 +296,30 @@ TEST_F(UpdateGameStatusActionTests, attackerHoldsAllCastles_hiddenDefenderFlees)
gsBuilder.add_player_infos(playersOffset);
gsBuilder.add_units(unitsOffset);
gsBuilder.add_status(statusOffset);
gsBuilder.add_hex_map(hexMapOffset);
// Create a minimal hex map for the cache
auto hexMapBuilder = net::eagle0::shardok::storage::fb::HexMapBuilder(fbb);
hexMapBuilder.add_row_count(10);
hexMapBuilder.add_column_count(10);
auto hexMapOffset = hexMapBuilder.Finish();
gsBuilder.add_hex_map(hexMapOffset);
fbb.Finish(gsBuilder.Finish());
auto gameState = Wrapper<GameState>(fbb);
gameState->mutable_units()->GetMutableObject(2)->mutate_hidden(true);
gameState->mutable_units()->GetMutableObject(2)->mutate_can_flee(true);
GameStateW gameStateW(gameState);
criticalTileLocations.Add(Coords(0, 4));
auto action =
UpdateGameStatusAction(gameState, criticalTileLocations, GetGameSettingsGetter(), true);
auto action = UpdateGameStatusAction(
gameStateW,
criticalTileLocations,
GetGameSettingsGetter(),
true);
vector<ActionResult> results = action.Execute(GameStateW(gameState), nullptr);
vector<ActionResult> results = action.Execute(gameStateW, nullptr);
EXPECT_GE(results.size(), 1);
auto finalResult = results[0];
@@ -321,8 +336,6 @@ TEST_F(UpdateGameStatusActionTests,
FlatBufferBuilder fbb;
fbb.ForceDefaults(true);
auto hexMapOffset = AddBasicMap(fbb);
auto attackerPlayer = AddPlayerInfo(
fbb,
0,
@@ -353,20 +366,22 @@ TEST_F(UpdateGameStatusActionTests,
gsBuilder.add_player_infos(playersOffset);
gsBuilder.add_units(unitsOffset);
gsBuilder.add_status(statusOffset);
gsBuilder.add_hex_map(hexMapOffset);
fbb.Finish(gsBuilder.Finish());
auto gameState = Wrapper<GameState>(fbb);
gameState->mutable_units()->GetMutableObject(2)->mutate_hidden(true);
gameState->mutable_units()->GetMutableObject(2)->mutate_can_flee(false);
GameStateW gameStateW(gameState);
criticalTileLocations.Add(Coords(0, 4));
auto action = UpdateGameStatusAction(
gameStateW,
criticalTileLocations,
GetGameSettingsGetter(),
true);
auto action =
UpdateGameStatusAction(gameState, criticalTileLocations, GetGameSettingsGetter(), true);
vector<ActionResult> results = action.Execute(GameStateW(gameState), nullptr);
vector<ActionResult> results = action.Execute(gameStateW, nullptr);
EXPECT_GE(results.size(), 1);
auto finalResult = results[0];
@@ -378,8 +393,6 @@ TEST_F(UpdateGameStatusActionTests, attackerHoldsAllCastles_hiddenDefenderTooTir
FlatBufferBuilder fbb;
fbb.ForceDefaults(true);
auto hexMapOffset = AddBasicMap(fbb);
auto attackerPlayer = AddPlayerInfo(
fbb,
0,
@@ -410,22 +423,23 @@ TEST_F(UpdateGameStatusActionTests, attackerHoldsAllCastles_hiddenDefenderTooTir
gsBuilder.add_player_infos(playersOffset);
gsBuilder.add_units(unitsOffset);
gsBuilder.add_status(statusOffset);
gsBuilder.add_hex_map(hexMapOffset);
fbb.Finish(gsBuilder.Finish());
auto gameState = Wrapper<GameState>(fbb);
gameState->mutable_units()->GetMutableObject(2)->mutate_hidden(true);
gameState->mutable_units()->GetMutableObject(2)->mutate_can_flee(true);
gameState->mutable_units()->GetMutableObject(2)->mutable_attached_hero().mutate_vigor(8);
GameStateW gameStateW(gameState);
criticalTileLocations.Add(Coords(0, 4));
auto action = UpdateGameStatusAction(
gameStateW,
criticalTileLocations,
GetGameSettingsGetter(),
true);
auto action =
UpdateGameStatusAction(gameState, criticalTileLocations, GetGameSettingsGetter(), true);
vector<ActionResult> results = action.Execute(GameStateW(gameState), nullptr);
vector<ActionResult> results = action.Execute(gameStateW, nullptr);
EXPECT_GE(results.size(), 1);
auto finalResult = results[0];
@@ -437,8 +451,6 @@ TEST_F(UpdateGameStatusActionTests, attackerHoldsAllCastles_notHiddenDefender_do
FlatBufferBuilder fbb;
fbb.ForceDefaults(true);
auto hexMapOffset = AddBasicMap(fbb);
auto attackerPlayer = AddPlayerInfo(
fbb,
0,
@@ -469,20 +481,22 @@ TEST_F(UpdateGameStatusActionTests, attackerHoldsAllCastles_notHiddenDefender_do
gsBuilder.add_player_infos(playersOffset);
gsBuilder.add_units(unitsOffset);
gsBuilder.add_status(statusOffset);
gsBuilder.add_hex_map(hexMapOffset);
fbb.Finish(gsBuilder.Finish());
auto gameState = Wrapper<GameState>(fbb);
gameState->mutable_units()->GetMutableObject(2)->mutate_hidden(false);
gameState->mutable_units()->GetMutableObject(2)->mutate_can_flee(true);
GameStateW gameStateW(gameState);
criticalTileLocations.Add(Coords(0, 4));
auto action = UpdateGameStatusAction(
gameStateW,
criticalTileLocations,
GetGameSettingsGetter(),
true);
auto action =
UpdateGameStatusAction(gameState, criticalTileLocations, GetGameSettingsGetter(), true);
vector<ActionResult> results = action.Execute(GameStateW(gameState), nullptr);
vector<ActionResult> results = action.Execute(gameStateW, nullptr);
EXPECT_GE(results.size(), 1);
auto finalResult = results[0];
@@ -494,8 +508,6 @@ TEST_F(UpdateGameStatusActionTests, attackerHoldsAllCastles_notEndOfRound_noGame
FlatBufferBuilder fbb;
fbb.ForceDefaults(true);
auto hexMapOffset = AddBasicMap(fbb);
auto attackerPlayer = AddPlayerInfo(
fbb,
0,
@@ -524,7 +536,6 @@ TEST_F(UpdateGameStatusActionTests, attackerHoldsAllCastles_notEndOfRound_noGame
gsBuilder.add_player_infos(playersOffset);
gsBuilder.add_units(unitsOffset);
gsBuilder.add_status(statusOffset);
gsBuilder.add_hex_map(hexMapOffset);
fbb.Finish(gsBuilder.Finish());
@@ -538,7 +549,7 @@ TEST_F(UpdateGameStatusActionTests, attackerHoldsAllCastles_notEndOfRound_noGame
GetGameSettingsGetter(),
false);
vector<ActionResult> results = action.Execute(GameStateW(gameState), nullptr);
vector<ActionResult> results = action.Execute(gameState, nullptr);
EXPECT_EQ(0, results.size());
}
@@ -547,8 +558,6 @@ TEST_F(UpdateGameStatusActionTests,
FlatBufferBuilder fbb;
fbb.ForceDefaults(true);
auto hexMapOffset = AddBasicMap(fbb);
auto attackerPlayer = AddPlayerInfo(
fbb,
0,
@@ -577,7 +586,6 @@ TEST_F(UpdateGameStatusActionTests,
gsBuilder.add_player_infos(playersOffset);
gsBuilder.add_units(unitsOffset);
gsBuilder.add_status(statusOffset);
gsBuilder.add_hex_map(hexMapOffset);
fbb.Finish(gsBuilder.Finish());
@@ -585,10 +593,14 @@ TEST_F(UpdateGameStatusActionTests,
criticalTileLocations.Add(Coords(0, 4));
auto action =
UpdateGameStatusAction(gameState, criticalTileLocations, GetGameSettingsGetter(), true);
GameStateW gameStateW(gameState);
auto action = UpdateGameStatusAction(
gameStateW,
criticalTileLocations,
GetGameSettingsGetter(),
true);
vector<ActionResult> results = action.Execute(GameStateW(gameState), nullptr);
vector<ActionResult> results = action.Execute(gameStateW, nullptr);
EXPECT_EQ(0, results.size());
}
@@ -596,8 +608,6 @@ TEST_F(UpdateGameStatusActionTests, allianceVictory_alliesHaveConditionSet_retur
FlatBufferBuilder fbb;
fbb.ForceDefaults(true);
auto hexMapOffset = AddBasicMap(fbb);
auto attackerPlayer = AddPlayerInfo(
fbb,
0,
@@ -635,7 +645,6 @@ TEST_F(UpdateGameStatusActionTests, allianceVictory_alliesHaveConditionSet_retur
gsBuilder.add_player_infos(playersOffset);
gsBuilder.add_units(unitsOffset);
gsBuilder.add_status(statusOffset);
gsBuilder.add_hex_map(hexMapOffset);
fbb.Finish(gsBuilder.Finish());
@@ -643,10 +652,14 @@ TEST_F(UpdateGameStatusActionTests, allianceVictory_alliesHaveConditionSet_retur
criticalTileLocations.Add(Coords(0, 4));
auto action =
UpdateGameStatusAction(gameState, criticalTileLocations, GetGameSettingsGetter(), true);
GameStateW gameStateW(gameState);
auto action = UpdateGameStatusAction(
gameStateW,
criticalTileLocations,
GetGameSettingsGetter(),
true);
vector<ActionResult> results = action.Execute(GameStateW(gameState), nullptr);
vector<ActionResult> results = action.Execute(gameStateW, nullptr);
EXPECT_GE(results.size(), 1);
EXPECT_EQ(net::eagle0::shardok::common::GAME_OVER, results[results.size() - 1].type());
EXPECT_EQ(
@@ -658,8 +671,6 @@ TEST_F(UpdateGameStatusActionTests, allianceVictory_notAllAlliesHaveConditionSet
FlatBufferBuilder fbb;
fbb.ForceDefaults(true);
auto hexMapOffset = AddBasicMap(fbb);
auto attackerPlayer = AddPlayerInfo(
fbb,
0,
@@ -697,7 +708,6 @@ TEST_F(UpdateGameStatusActionTests, allianceVictory_notAllAlliesHaveConditionSet
gsBuilder.add_player_infos(playersOffset);
gsBuilder.add_units(unitsOffset);
gsBuilder.add_status(statusOffset);
gsBuilder.add_hex_map(hexMapOffset);
fbb.Finish(gsBuilder.Finish());
@@ -705,10 +715,14 @@ TEST_F(UpdateGameStatusActionTests, allianceVictory_notAllAlliesHaveConditionSet
criticalTileLocations.Add(Coords(0, 4));
auto action =
UpdateGameStatusAction(gameState, criticalTileLocations, GetGameSettingsGetter(), true);
GameStateW gameStateW(gameState);
auto action = UpdateGameStatusAction(
gameStateW,
criticalTileLocations,
GetGameSettingsGetter(),
true);
vector<ActionResult> results = action.Execute(GameStateW(gameState), nullptr);
vector<ActionResult> results = action.Execute(gameStateW, nullptr);
EXPECT_EQ(0, results.size());
}
@@ -716,8 +730,6 @@ TEST_F(UpdateGameStatusActionTests, allianceVictory_notAllAllied_returnsNoAction
FlatBufferBuilder fbb;
fbb.ForceDefaults(true);
auto hexMapOffset = AddBasicMap(fbb);
auto attackerPlayer = AddPlayerInfo(
fbb,
0,
@@ -755,7 +767,6 @@ TEST_F(UpdateGameStatusActionTests, allianceVictory_notAllAllied_returnsNoAction
gsBuilder.add_player_infos(playersOffset);
gsBuilder.add_units(unitsOffset);
gsBuilder.add_status(statusOffset);
gsBuilder.add_hex_map(hexMapOffset);
fbb.Finish(gsBuilder.Finish());
@@ -763,9 +774,13 @@ TEST_F(UpdateGameStatusActionTests, allianceVictory_notAllAllied_returnsNoAction
criticalTileLocations.Add(Coords(0, 4));
auto action =
UpdateGameStatusAction(gameState, criticalTileLocations, GetGameSettingsGetter(), true);
GameStateW gameStateW(gameState);
auto action = UpdateGameStatusAction(
gameStateW,
criticalTileLocations,
GetGameSettingsGetter(),
true);
vector<ActionResult> results = action.Execute(GameStateW(gameState), nullptr);
vector<ActionResult> results = action.Execute(gameStateW, nullptr);
EXPECT_EQ(0, results.size());
}
@@ -98,6 +98,7 @@ TEST_F(UpdateOpponentKnowledgeActionTests, attackerGainsOneOnDefender) {
TEST_F(UpdateOpponentKnowledgeActionTests, defenderGainsMoreIfAdjacent) {
defender->mutable_location() = adjacentPosition;
gameState.RebuildOccupantsCache();
const auto action = UpdateOpponentKnowledgeAction(GetGameSettingsGetter());
@@ -113,6 +114,7 @@ TEST_F(UpdateOpponentKnowledgeActionTests, defenderGainsMoreIfAdjacent) {
TEST_F(UpdateOpponentKnowledgeActionTests, attackerGainsMoreIfAdjacent) {
defender->mutable_location() = adjacentPosition;
gameState.RebuildOccupantsCache();
const auto action = UpdateOpponentKnowledgeAction(GetGameSettingsGetter());
@@ -45,6 +45,9 @@ class HideCommandTests : public ::testing::Test {
enemy->mutable_attached_hero().mutable_profession_info().mutate_profession(
net::eagle0::shardok::storage::fb::Profession_RANGER);
enemy->mutate_hidden(true);
// Rebuild cache after mutations
gameState.RebuildOccupantsCache();
}
public:
@@ -104,6 +107,7 @@ TEST_F(HideCommandTests, Execute_setsHidden) {
TEST_F(HideCommandTests, ExecuteIntoOccupant_fails) {
enemy->mutable_location() = target;
gameState.RebuildOccupantsCache();
const ActionResult hideResult =
HideCommand(cost, rangerPid, ranger->unit_id(), target, agiXp, minimumKnowledge)
@@ -114,6 +118,7 @@ TEST_F(HideCommandTests, ExecuteIntoOccupant_fails) {
TEST_F(HideCommandTests, ExecuteIntoOccupant_doesNotMove) {
enemy->mutable_location() = target;
gameState.RebuildOccupantsCache();
ActionResult hideResult =
HideCommand(cost, rangerPid, ranger->unit_id(), target, agiXp, minimumKnowledge)
@@ -125,6 +130,7 @@ TEST_F(HideCommandTests, ExecuteIntoOccupant_doesNotMove) {
TEST_F(HideCommandTests, ExecuteIntoOccupant_doesNotHide) {
enemy->mutable_location() = target;
gameState.RebuildOccupantsCache();
ActionResult hideResult =
HideCommand(cost, rangerPid, ranger->unit_id(), target, agiXp, minimumKnowledge)
@@ -136,6 +142,7 @@ TEST_F(HideCommandTests, ExecuteIntoOccupant_doesNotHide) {
TEST_F(HideCommandTests, ExecuteIntoOccupant_revealsOccupant) {
enemy->mutable_location() = target;
gameState.RebuildOccupantsCache();
ActionResult hideResult =
HideCommand(cost, rangerPid, ranger->unit_id(), target, agiXp, minimumKnowledge)
@@ -147,6 +154,7 @@ TEST_F(HideCommandTests, ExecuteIntoOccupant_revealsOccupant) {
TEST_F(HideCommandTests, ExecuteNextToRanger_fails) {
enemy->mutable_location() = Coords(2, 4);
gameState.RebuildOccupantsCache();
const ActionResult hideResult =
HideCommand(cost, rangerPid, ranger->unit_id(), target, agiXp, minimumKnowledge)
@@ -157,6 +165,7 @@ TEST_F(HideCommandTests, ExecuteNextToRanger_fails) {
TEST_F(HideCommandTests, ExecuteNextToRanger_doesNotHide) {
enemy->mutable_location() = Coords(2, 4);
gameState.RebuildOccupantsCache();
ActionResult hideResult =
HideCommand(cost, rangerPid, ranger->unit_id(), target, agiXp, minimumKnowledge)
@@ -168,6 +177,7 @@ TEST_F(HideCommandTests, ExecuteNextToRanger_doesNotHide) {
TEST_F(HideCommandTests, ExecuteNextToRanger_stillMoves) {
enemy->mutable_location() = Coords(2, 4);
gameState.RebuildOccupantsCache();
ActionResult hideResult =
HideCommand(cost, rangerPid, ranger->unit_id(), target, agiXp, minimumKnowledge)
@@ -179,6 +189,7 @@ TEST_F(HideCommandTests, ExecuteNextToRanger_stillMoves) {
TEST_F(HideCommandTests, ExecuteNextToRanger_revealsEnemy) {
enemy->mutable_location() = Coords(2, 4);
gameState.RebuildOccupantsCache();
ActionResult hideResult =
HideCommand(cost, rangerPid, ranger->unit_id(), target, agiXp, minimumKnowledge)
@@ -51,16 +51,9 @@ class MoveCommandTest : public ::testing::Test {
std::vector<int16_t> chargeeIds(6, -1);
auto chargeeIdsVector = fbbWithRanger.CreateVector(chargeeIds);
// Copy the hex_map to the new FlatBuffer
net::eagle0::shardok::storage::fb::HexMapT hexMapT;
BASIC_MAP_FB->UnPackTo(&hexMapT);
auto hexMapOffset =
net::eagle0::shardok::storage::fb::HexMap::Pack(fbbWithRanger, &hexMapT);
auto gsb2 = GameStateBuilder(fbbWithRanger);
gsb2.add_units(u2);
gsb2.add_possible_chargee_ids(chargeeIdsVector);
gsb2.add_hex_map(hexMapOffset);
fbbWithRanger.Finish(gsb2.Finish());
gameStateWithRanger = GameStateW(fbbWithRanger);
@@ -91,15 +84,8 @@ class MoveCommandTest : public ::testing::Test {
auto vec3 = vector<Unit>{undeadUnit, otherUnit};
auto u3 = fbbUndead.CreateVectorOfSortedStructs(&vec3);
// Copy the hex_map to the new FlatBuffer
net::eagle0::shardok::storage::fb::HexMapT hexMapT3;
BASIC_MAP_FB->UnPackTo(&hexMapT3);
auto hexMapOffset3 = net::eagle0::shardok::storage::fb::HexMap::Pack(fbbUndead, &hexMapT3);
GameStateBuilder gsb3(fbbUndead);
gsb3.add_units(u3);
gsb3.add_hex_map(hexMapOffset3);
fbbUndead.Finish(gsb3.Finish());
@@ -189,6 +189,7 @@ TEST_F(RaiseDeadCommandTest, successRollOnHiddenDefender_fails) {
other->mutable_location() = target;
other->mutate_hidden(true);
gameState.RebuildOccupantsCache();
const auto results = RaiseDeadCommand(
cost,