mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 04:42:18 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5018f882d8 | ||
|
|
7065288cf2 | ||
|
|
60b4c4fcea | ||
|
|
ce532b4b9a | ||
|
|
9acf324ba1 | ||
|
|
0382d08ed5 | ||
|
|
2159f87dc9 | ||
|
|
ca6770b237 | ||
|
|
d80e5e413c | ||
|
|
3e35e678b3 | ||
|
|
0640ea7542 | ||
|
|
bce577758f | ||
|
|
ad8e34ec3d | ||
|
|
215ebbee24 | ||
|
|
1b7b2a2332 | ||
|
|
d5eb0e95c1 | ||
|
|
f96780ac83 | ||
|
|
837825eb90 | ||
|
|
5ea2d7e4d7 | ||
|
|
ff9dd51418 | ||
|
|
e609fcac17 |
@@ -18,11 +18,31 @@ static inline auto MixIn(uint64_t& hash, const uint8_t byte) {
|
||||
}
|
||||
|
||||
// Hash an entire buffer using FNV-1a
|
||||
// Fast word-at-a-time implementation - processes 8 bytes at once for better performance
|
||||
// while maintaining good distribution properties for hash table use
|
||||
static inline auto HashBuffer(const uint8_t* data, size_t size) -> uint64_t {
|
||||
if (data == nullptr) { return FNV_OFFSET_BASIS; }
|
||||
|
||||
uint64_t hash = FNV_OFFSET_BASIS;
|
||||
if (data != nullptr) {
|
||||
for (size_t i = 0; i < size; ++i) { MixIn(hash, data[i]); }
|
||||
const uint8_t* end = data + size;
|
||||
|
||||
// Process 8 bytes at a time
|
||||
while (data + 8 <= end) {
|
||||
uint64_t word;
|
||||
// Use memcpy to avoid alignment issues and let compiler optimize
|
||||
__builtin_memcpy(&word, data, sizeof(word));
|
||||
hash ^= word;
|
||||
hash *= FNV_PRIME;
|
||||
data += 8;
|
||||
}
|
||||
|
||||
// Process remaining bytes
|
||||
while (data < end) {
|
||||
hash ^= static_cast<uint64_t>(*data);
|
||||
hash *= FNV_PRIME;
|
||||
data++;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
#include "AbstractMCTSAI.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <future>
|
||||
#include <mutex>
|
||||
#include <random>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
|
||||
namespace shardok::mcts {
|
||||
@@ -31,11 +31,31 @@ auto AbstractMCTSAI::Search(
|
||||
result.searchTime = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - startTime);
|
||||
|
||||
if (!rootNode || rootNode->children.empty()) {
|
||||
// Fallback to first action if no tree was built
|
||||
result.bestActionIndex = 0;
|
||||
result.bestScore = 0.0;
|
||||
return result;
|
||||
if (!rootNode) {
|
||||
throw MCTSInternalError("MCTS search: BuildMCTSTree returned null root node");
|
||||
}
|
||||
|
||||
if (rootNode->children.empty()) {
|
||||
// This can happen legitimately when:
|
||||
// 1. No legal actions available (terminal state)
|
||||
// 2. Only one action and we early-exited without exploring
|
||||
// In case 1, there's no valid action to return. In case 2, we should have
|
||||
// expanded that one child. Check totalActions to distinguish.
|
||||
if (rootNode->totalActions == 0) {
|
||||
throw MCTSInternalError(
|
||||
"MCTS search: No legal actions available in initial state - cannot return an "
|
||||
"action index");
|
||||
}
|
||||
// Single action case - should have been expanded in BuildMCTSTree
|
||||
if (rootNode->totalActions == 1) {
|
||||
result.bestActionIndex = 0;
|
||||
result.bestScore = 0.0;
|
||||
return result;
|
||||
}
|
||||
// Multiple actions but no children expanded - this shouldn't happen
|
||||
throw MCTSInternalError(
|
||||
"MCTS search: Root has " + std::to_string(rootNode->totalActions) +
|
||||
" actions but no children expanded - this indicates a bug in BuildMCTSTree");
|
||||
}
|
||||
|
||||
// Find best child
|
||||
@@ -44,7 +64,7 @@ auto AbstractMCTSAI::Search(
|
||||
// Use actionIndex which is the index into the filtered actions from
|
||||
// engine.getLegalActions()
|
||||
result.bestActionIndex = bestChild->actionIndex;
|
||||
result.bestScore = bestChild->averageReward;
|
||||
result.bestScore = bestChild->lookaheadScore; // Use minimax value, not poisoned average
|
||||
result.searchDepth = bestChild->depth;
|
||||
result.nodesEvaluated = rootNode->visitCount;
|
||||
|
||||
@@ -66,17 +86,34 @@ auto AbstractMCTSAI::BuildMCTSTree(
|
||||
const MCTSGameState& initialState,
|
||||
const std::chrono::steady_clock::time_point deadline) const -> std::unique_ptr<MCTSNode> {
|
||||
// Create root node
|
||||
auto root = std::make_unique<MCTSNode>(initialState.clone(), playerId_, 0);
|
||||
// IMPORTANT: Use the initial state's current player, not playerId_
|
||||
// node->playerId represents "whose turn it is", not "who we're searching for"
|
||||
// This is critical for correct player flip tracking
|
||||
auto root = std::make_unique<MCTSNode>(initialState.clone(), initialState.currentPlayerId(), 0);
|
||||
|
||||
// Set whether root is maximizing based on whether current player matches who we're searching
|
||||
// for
|
||||
root->isMaximizingPlayer = (initialState.currentPlayerId() == playerId_);
|
||||
|
||||
// Get legal actions from engine for the root state
|
||||
const auto rootActions = engine.getLegalActions(initialState);
|
||||
// Root has 0 player flips
|
||||
const auto rootActions =
|
||||
engine.getLegalActions(initialState, playerId_, 0, config_.maxPlayerFlips);
|
||||
|
||||
// Initialize untried actions from the root actions
|
||||
root->untriedActionIndices.reserve(rootActions.size());
|
||||
for (size_t i = 0; i < rootActions.size(); ++i) { root->untriedActionIndices.push_back(i); }
|
||||
// Early exit if only one action available - no need to search
|
||||
if (rootActions.size() <= 1) {
|
||||
// Expand the single action so Search() can return it
|
||||
if (!rootActions.empty()) {
|
||||
root->totalActions = 1;
|
||||
[[maybe_unused]] auto* expanded = MCTSExpansion(root.get(), engine);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
// Initialize action counter
|
||||
root->totalActions = rootActions.size();
|
||||
|
||||
std::atomic<int> iterations{0};
|
||||
constexpr int maxIterations = 100000;
|
||||
|
||||
if (config_.useMultithreading && config_.numThreads > 1) {
|
||||
// Multithreaded MCTS
|
||||
@@ -86,8 +123,7 @@ auto AbstractMCTSAI::BuildMCTSTree(
|
||||
futures.reserve(config_.numThreads);
|
||||
for (int threadId = 0; threadId < config_.numThreads; ++threadId) {
|
||||
futures.push_back(std::async(std::launch::async, [&] {
|
||||
while (std::chrono::steady_clock::now() < deadline &&
|
||||
iterations.load() < maxIterations) {
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
// Selection and Expansion (with lock - tree modification must be serialized)
|
||||
MCTSNode* expanded;
|
||||
{
|
||||
@@ -103,10 +139,13 @@ auto AbstractMCTSAI::BuildMCTSTree(
|
||||
|
||||
// Backpropagation (with lock - modifies node statistics)
|
||||
{
|
||||
const double reward =
|
||||
MCTSSimulation(engine, *expanded->gameState, playerId_);
|
||||
const double reward = MCTSSimulation(
|
||||
engine,
|
||||
*expanded->gameState,
|
||||
playerId_,
|
||||
expanded->playerFlips);
|
||||
std::lock_guard lock(treeMutex);
|
||||
MCTSBackpropagation(expanded, reward);
|
||||
MCTSBackpropagation(expanded, reward, config_.backpropagationPolicy);
|
||||
iterations.fetch_add(1);
|
||||
}
|
||||
}
|
||||
@@ -117,7 +156,7 @@ auto AbstractMCTSAI::BuildMCTSTree(
|
||||
for (auto& future : futures) { future.wait(); }
|
||||
} else {
|
||||
// Single-threaded MCTS
|
||||
while (std::chrono::steady_clock::now() < deadline && iterations < maxIterations) {
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
// Selection
|
||||
auto* selected = MCTSSelection(root.get());
|
||||
if (!selected) break;
|
||||
@@ -126,10 +165,11 @@ auto AbstractMCTSAI::BuildMCTSTree(
|
||||
auto* expanded = MCTSExpansion(selected, engine);
|
||||
|
||||
// Simulation
|
||||
const double reward = MCTSSimulation(engine, *expanded->gameState, playerId_);
|
||||
const double reward =
|
||||
MCTSSimulation(engine, *expanded->gameState, playerId_, expanded->playerFlips);
|
||||
|
||||
// Backpropagation
|
||||
MCTSBackpropagation(expanded, reward);
|
||||
MCTSBackpropagation(expanded, reward, config_.backpropagationPolicy);
|
||||
|
||||
++iterations;
|
||||
|
||||
@@ -165,54 +205,73 @@ auto AbstractMCTSAI::MCTSSelection(MCTSNode* root) const -> MCTSNode* {
|
||||
|
||||
auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine) const
|
||||
-> MCTSNode* {
|
||||
if (node->untriedActionIndices.empty() || node->isTerminal) {
|
||||
if (!node->CanExpand() || node->isTerminal) {
|
||||
return node; // Nothing to expand
|
||||
}
|
||||
|
||||
// Select a random untried action
|
||||
thread_local std::mt19937 gen(std::random_device{}());
|
||||
std::uniform_int_distribution<size_t> dis(0, node->untriedActionIndices.size() - 1);
|
||||
const size_t randomIndex = dis(gen);
|
||||
const size_t actionIndex = node->untriedActionIndices[randomIndex];
|
||||
|
||||
// Remove from untried list
|
||||
node->untriedActionIndices.erase(std::next(
|
||||
node->untriedActionIndices.begin(),
|
||||
static_cast<std::vector<size_t>::difference_type>(randomIndex)));
|
||||
|
||||
if (node->untriedActionIndices.empty()) { node->fullyExpanded = true; }
|
||||
// Get next action to expand (sequential order)
|
||||
const size_t actionIndex = node->nextUntriedActionIndex++;
|
||||
|
||||
// Get legal actions from engine (uses cached engine for performance)
|
||||
const auto nodeActions = engine.getLegalActions(*node->gameState);
|
||||
// Use the parameterized version to respect player flips
|
||||
const auto nodeActions = engine.getLegalActions(
|
||||
*node->gameState,
|
||||
playerId_,
|
||||
node->playerFlips,
|
||||
config_.maxPlayerFlips);
|
||||
|
||||
// Create new child node
|
||||
if (actionIndex >= nodeActions.size()) {
|
||||
return node; // Invalid action index
|
||||
throw MCTSInternalError(
|
||||
"MCTS expansion: actionIndex (" + std::to_string(actionIndex) +
|
||||
") >= nodeActions.size() (" + std::to_string(nodeActions.size()) +
|
||||
") - this indicates a bug in action indexing");
|
||||
}
|
||||
|
||||
// Get action weights from engine (for prior-weighted UCB)
|
||||
const auto actionWeights = engine.getActionWeights(nodeActions, *node->gameState);
|
||||
|
||||
const auto& action = nodeActions[actionIndex];
|
||||
const double actionWeight =
|
||||
actionIndex < actionWeights.size() ? actionWeights[actionIndex] : 1.0;
|
||||
|
||||
auto newState = engine.applyAction(*node->gameState, *action);
|
||||
if (!newState) {
|
||||
return node; // Failed to apply action
|
||||
throw MCTSInternalError(
|
||||
"MCTS expansion: engine.applyAction() returned nullptr for action " +
|
||||
action->getDescription() + " - this indicates a game engine error");
|
||||
}
|
||||
|
||||
// Determine if player changed
|
||||
const MCTSPlayerId newPlayerId = newState->currentPlayerId();
|
||||
const bool playerChanged = (newPlayerId != node->playerId);
|
||||
|
||||
// Calculate player flips and maximizing status
|
||||
const int newPlayerFlips = node->playerFlips + (playerChanged ? 1 : 0);
|
||||
// Node is maximizing if current player is the root player (playerId_)
|
||||
const bool newIsMaximizing = (newPlayerId == playerId_);
|
||||
|
||||
auto child = std::make_unique<MCTSNode>(
|
||||
action->clone(),
|
||||
std::move(newState),
|
||||
node->gameState->currentPlayerId(),
|
||||
newPlayerId,
|
||||
node->depth + 1,
|
||||
actionIndex);
|
||||
actionIndex,
|
||||
newPlayerFlips,
|
||||
newIsMaximizing,
|
||||
actionWeight); // Pass the action weight for prior-weighted UCB
|
||||
|
||||
// Set up child's untried actions if not terminal
|
||||
if (!child->isTerminal) {
|
||||
const auto childActions = engine.getLegalActions(*child->gameState);
|
||||
child->untriedActionIndices.reserve(childActions.size());
|
||||
for (size_t i = 0; i < childActions.size(); ++i) {
|
||||
child->untriedActionIndices.push_back(i);
|
||||
}
|
||||
const auto childActions = engine.getLegalActions(
|
||||
*child->gameState,
|
||||
playerId_,
|
||||
newPlayerFlips,
|
||||
config_.maxPlayerFlips);
|
||||
child->totalActions = childActions.size();
|
||||
}
|
||||
|
||||
// Calculate immediate and lookahead scores
|
||||
// Calculate immediate and lookahead scores from root player's perspective
|
||||
child->immediateScore = engine.evaluateState(*child->gameState, playerId_);
|
||||
child->lookaheadScore = child->immediateScore;
|
||||
|
||||
@@ -226,20 +285,41 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
|
||||
auto AbstractMCTSAI::MCTSSimulation(
|
||||
const MCTSGameEngine& engine,
|
||||
const MCTSGameState& state,
|
||||
const MCTSPlayerId startingPlayer) const -> double {
|
||||
const MCTSPlayerId startingPlayer,
|
||||
const int startingPlayerFlips) const -> double {
|
||||
if (state.isTerminal()) { return state.score(startingPlayer); }
|
||||
|
||||
// Create a mutable copy for simulation
|
||||
auto currentState = state.clone();
|
||||
int depth = 0;
|
||||
int playerFlips = startingPlayerFlips; // Start from the expanded node's flip count
|
||||
MCTSPlayerId previousPlayer = currentState->currentPlayerId();
|
||||
|
||||
// Simulate until terminal or max depth
|
||||
while (!currentState->isTerminal() && depth < config_.maxSimulationDepth) {
|
||||
const auto actions = engine.getLegalActions(*currentState);
|
||||
// Track player changes
|
||||
const MCTSPlayerId currentPlayer = currentState->currentPlayerId();
|
||||
if (currentPlayer != previousPlayer) {
|
||||
playerFlips++;
|
||||
previousPlayer = currentPlayer;
|
||||
}
|
||||
|
||||
// Get legal actions with player flip tracking
|
||||
const auto actions = engine.getLegalActions(
|
||||
*currentState,
|
||||
playerId_,
|
||||
playerFlips,
|
||||
config_.maxPlayerFlips);
|
||||
if (actions.empty()) { break; }
|
||||
|
||||
// Determine if current player is maximizing or minimizing
|
||||
// Maximizing: current player is root player (trying to maximize root player's score)
|
||||
// Minimizing: current player is opponent (trying to minimize root player's score)
|
||||
const bool isMaximizing = (currentPlayer == playerId_);
|
||||
|
||||
// Select action based on simulation policy
|
||||
const size_t selectedIndex = SelectSimulationAction(engine, *currentState, actions);
|
||||
const size_t selectedIndex =
|
||||
SelectSimulationAction(engine, *currentState, actions, isMaximizing);
|
||||
if (selectedIndex >= actions.size()) { break; }
|
||||
|
||||
// Apply action
|
||||
@@ -253,18 +333,63 @@ auto AbstractMCTSAI::MCTSSimulation(
|
||||
return currentState->score(startingPlayer);
|
||||
}
|
||||
|
||||
auto AbstractMCTSAI::MCTSBackpropagation(MCTSNode* node, const double reward) -> void {
|
||||
auto AbstractMCTSAI::MCTSBackpropagation(
|
||||
MCTSNode* node,
|
||||
const double reward,
|
||||
const MCTSBackpropagationPolicy policy) const -> void {
|
||||
// Backpropagation strategy is configured via MCTSConfig:
|
||||
// - AVERAGING: Traditional MCTS averaging (for stochastic/single-player games)
|
||||
// - MINIMAX: Minimax backup (for deterministic adversarial games)
|
||||
|
||||
const bool useMinimaxBackup = (policy == MCTSBackpropagationPolicy::MINIMAX);
|
||||
|
||||
while (node) {
|
||||
node->visitCount++;
|
||||
|
||||
// Always track average for UCB
|
||||
node->totalReward += reward;
|
||||
node->averageReward = node->totalReward / node->visitCount;
|
||||
|
||||
// Update lookahead score as weighted average
|
||||
if (node->visitCount == 1) {
|
||||
node->lookaheadScore = reward;
|
||||
// Update lookahead score based on strategy
|
||||
if (useMinimaxBackup && !node->children.empty()) {
|
||||
// Minimax backup: use best/worst child value for adversarial games
|
||||
// This is correct when exploring opponent responses
|
||||
double minmaxValue = node->isMaximizingPlayer ? -std::numeric_limits<double>::max()
|
||||
: std::numeric_limits<double>::max();
|
||||
|
||||
for (const auto& child : node->children) {
|
||||
if (child->visitCount == 0) continue; // Unvisited children don't contribute
|
||||
|
||||
const double childValue = child->lookaheadScore;
|
||||
|
||||
if (node->isMaximizingPlayer) {
|
||||
minmaxValue = std::max(minmaxValue, childValue);
|
||||
} else {
|
||||
minmaxValue = std::min(minmaxValue, childValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Use minimax value if we found any visited children, else use average
|
||||
if (minmaxValue != (node->isMaximizingPlayer ? -std::numeric_limits<double>::max()
|
||||
: std::numeric_limits<double>::max())) {
|
||||
node->lookaheadScore = minmaxValue;
|
||||
} else {
|
||||
// No children visited yet, fall back to average
|
||||
if (node->visitCount == 1) {
|
||||
node->lookaheadScore = reward;
|
||||
} else {
|
||||
const double alpha = 1.0 / node->visitCount;
|
||||
node->lookaheadScore = (1.0 - alpha) * node->lookaheadScore + alpha * reward;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const double alpha = 1.0 / node->visitCount;
|
||||
node->lookaheadScore = (1.0 - alpha) * node->lookaheadScore + alpha * reward;
|
||||
// Standard MCTS averaging (for maxPlayerFlips=0 or leaf nodes)
|
||||
if (node->visitCount == 1) {
|
||||
node->lookaheadScore = reward;
|
||||
} else {
|
||||
const double alpha = 1.0 / node->visitCount;
|
||||
node->lookaheadScore = (1.0 - alpha) * node->lookaheadScore + alpha * reward;
|
||||
}
|
||||
}
|
||||
|
||||
node = node->parent;
|
||||
@@ -274,8 +399,13 @@ auto AbstractMCTSAI::MCTSBackpropagation(MCTSNode* node, const double reward) ->
|
||||
auto AbstractMCTSAI::SelectSimulationAction(
|
||||
const MCTSGameEngine& engine,
|
||||
const MCTSGameState& state,
|
||||
const std::vector<std::unique_ptr<MCTSAction>>& actions) const -> size_t {
|
||||
if (actions.empty()) { return 0; }
|
||||
const std::vector<std::unique_ptr<MCTSAction>>& actions,
|
||||
const bool isMaximizing) const -> size_t {
|
||||
if (actions.empty()) {
|
||||
throw MCTSInternalError(
|
||||
"MCTSSimulation called with empty actions list - this indicates a bug in the "
|
||||
"MCTS tree building or game state");
|
||||
}
|
||||
|
||||
thread_local std::mt19937 gen(std::random_device{}());
|
||||
|
||||
@@ -297,13 +427,31 @@ auto AbstractMCTSAI::SelectSimulationAction(
|
||||
}
|
||||
|
||||
case MCTSSimulationPolicy::BEST_IMMEDIATE: {
|
||||
double bestScore = -std::numeric_limits<double>::max();
|
||||
size_t bestIndex = 0;
|
||||
// For adversarial search:
|
||||
// - Maximizing nodes select action with HIGHEST score (best for root player)
|
||||
// - Minimizing nodes select action with LOWEST score (worst for root player)
|
||||
|
||||
for (size_t i = 0; i < actions.size(); ++i) {
|
||||
const double score =
|
||||
engine.getActionScore(state, *actions[i], state.currentPlayerId());
|
||||
if (score > bestScore) {
|
||||
// First, filter out obviously bad moves (e.g., BECOME_OUTLAW)
|
||||
const auto filteredIndices = engine.filterActions(actions, state);
|
||||
if (filteredIndices.empty()) {
|
||||
// If all actions filtered out, fall back to first action
|
||||
return 0;
|
||||
}
|
||||
|
||||
double bestScore = isMaximizing ? -std::numeric_limits<double>::max()
|
||||
: std::numeric_limits<double>::max();
|
||||
size_t bestIndex = filteredIndices[0];
|
||||
|
||||
for (const size_t i : filteredIndices) {
|
||||
// CRITICAL: Always get score from ROOT player's perspective for adversarial search
|
||||
// If we use currentPlayerId, opponent actions would be scored from their
|
||||
// perspective, causing them to select moves that help themselves instead of hurt
|
||||
// us!
|
||||
const double score = engine.getActionScore(state, *actions[i], playerId_);
|
||||
|
||||
const bool shouldSelect = isMaximizing ? (score > bestScore) : (score < bestScore);
|
||||
|
||||
if (shouldSelect) {
|
||||
bestScore = score;
|
||||
bestIndex = i;
|
||||
}
|
||||
@@ -317,15 +465,23 @@ auto AbstractMCTSAI::SelectSimulationAction(
|
||||
scores.reserve(actions.size());
|
||||
|
||||
for (size_t i = 0; i < actions.size(); ++i) {
|
||||
const double score =
|
||||
engine.getActionScore(state, *actions[i], state.currentPlayerId());
|
||||
// CRITICAL: Always get score from ROOT player's perspective for adversarial search
|
||||
const double score = engine.getActionScore(state, *actions[i], playerId_);
|
||||
scores.emplace_back(i, score);
|
||||
}
|
||||
|
||||
// Sort by score
|
||||
std::ranges::sort(scores, [](const auto& a, const auto& b) {
|
||||
return a.second > b.second;
|
||||
});
|
||||
// - Maximizing: highest scores first (prefer actions that maximize root player's score)
|
||||
// - Minimizing: lowest scores first (prefer actions that minimize root player's score)
|
||||
if (isMaximizing) {
|
||||
std::ranges::sort(scores, [](const auto& a, const auto& b) {
|
||||
return a.second > b.second; // Descending
|
||||
});
|
||||
} else {
|
||||
std::ranges::sort(scores, [](const auto& a, const auto& b) {
|
||||
return a.second < b.second; // Ascending
|
||||
});
|
||||
}
|
||||
|
||||
// Create weights based on ranking
|
||||
std::vector<double> weights;
|
||||
@@ -338,6 +494,36 @@ auto AbstractMCTSAI::SelectSimulationAction(
|
||||
std::discrete_distribution<> dis(weights.begin(), weights.end());
|
||||
return scores[dis(gen)].first;
|
||||
}
|
||||
|
||||
case MCTSSimulationPolicy::WEIGHTED_HEURISTIC: {
|
||||
// Get heuristic weights from engine (fast O(1) per action)
|
||||
const auto weights = engine.getActionWeights(actions, state);
|
||||
|
||||
// Filter out zero-weight actions
|
||||
std::vector<size_t> validIndices;
|
||||
std::vector<double> validWeights;
|
||||
validIndices.reserve(actions.size());
|
||||
validWeights.reserve(actions.size());
|
||||
|
||||
for (size_t i = 0; i < weights.size() && i < actions.size(); ++i) {
|
||||
if (weights[i] > 0.0) {
|
||||
validIndices.push_back(i);
|
||||
validWeights.push_back(weights[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// If all actions filtered out, this is a bug in the weighting logic
|
||||
if (validWeights.empty()) {
|
||||
throw MCTSInternalError(
|
||||
"MCTS simulation: All actions have zero weight in WEIGHTED_HEURISTIC "
|
||||
"policy (action count: " +
|
||||
std::to_string(actions.size()) + ") - this indicates incorrect weighting");
|
||||
}
|
||||
|
||||
// Select based on heuristic weights using discrete_distribution
|
||||
std::discrete_distribution<> dis(validWeights.begin(), validWeights.end());
|
||||
return validIndices[dis(gen)];
|
||||
}
|
||||
}
|
||||
|
||||
// Default to random
|
||||
@@ -396,11 +582,11 @@ auto AbstractMCTSAI::LogSearchResults(
|
||||
printf("MCTS: Top actions by visits:\n");
|
||||
for (size_t i = 0; i < std::min(static_cast<size_t>(3), sortedChildren.size()); ++i) {
|
||||
const auto* child = sortedChildren[i];
|
||||
printf(" [%zu] visits:%d immediate:%.2f backprop:%.2f",
|
||||
printf(" [%zu] visits:%d immediate:%.2f lookahead:%.2f",
|
||||
i,
|
||||
child->visitCount,
|
||||
child->immediateScore,
|
||||
child->averageReward);
|
||||
child->lookaheadScore);
|
||||
|
||||
// Show the action's own description
|
||||
if (child->action) { printf(" %s", child->action->getDescription().c_str()); }
|
||||
@@ -456,10 +642,40 @@ auto AbstractMCTSAI::LogSearchResults(
|
||||
const auto* node = bestSequence[i];
|
||||
printf(" %zu.", i + 1);
|
||||
if (node->action) { printf(" %s", node->action->getDescription().c_str()); }
|
||||
printf(" (visits:%d, immediate:%.2f, backprop:%.2f)\n",
|
||||
printf(" (visits:%d, immediate:%.2f, lookahead:%.2f)\n",
|
||||
node->visitCount,
|
||||
node->immediateScore,
|
||||
node->averageReward);
|
||||
node->lookaheadScore);
|
||||
|
||||
// For non-root nodes in the sequence, show what the top alternatives were
|
||||
// This helps diagnose if opponent moves are being properly explored
|
||||
if (i > 0 && node->parent && !node->parent->children.empty()) {
|
||||
// Collect all siblings (including this node) and sort by visit count
|
||||
std::vector<const MCTSNode*> siblings;
|
||||
siblings.reserve(node->parent->children.size());
|
||||
for (const auto& child : node->parent->children) {
|
||||
if (!child->isRedundant) { siblings.push_back(child.get()); }
|
||||
}
|
||||
|
||||
// Sort by visit count (descending)
|
||||
std::ranges::sort(siblings, [](const MCTSNode* a, const MCTSNode* b) {
|
||||
return a->visitCount > b->visitCount;
|
||||
});
|
||||
|
||||
// Show top 3 alternatives at this decision point
|
||||
printf(" Alternatives at this node (%zu total):\n", siblings.size());
|
||||
const size_t topN = std::min(siblings.size(), size_t(3));
|
||||
for (size_t j = 0; j < topN; ++j) {
|
||||
const auto* alt = siblings[j];
|
||||
printf(" [%zu] visits:%d immediate:%.2f lookahead:%.2f",
|
||||
j,
|
||||
alt->visitCount,
|
||||
alt->immediateScore,
|
||||
alt->lookaheadScore);
|
||||
if (alt->action) { printf(" %s", alt->action->getDescription().c_str()); }
|
||||
printf("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,15 +66,18 @@ private:
|
||||
[[nodiscard]] auto MCTSSimulation(
|
||||
const MCTSGameEngine& engine,
|
||||
const MCTSGameState& state,
|
||||
MCTSPlayerId startingPlayer) const -> double;
|
||||
MCTSPlayerId startingPlayer,
|
||||
int startingPlayerFlips = 0) const -> double;
|
||||
|
||||
static auto MCTSBackpropagation(MCTSNode* node, double reward) -> void;
|
||||
auto MCTSBackpropagation(MCTSNode* node, double reward, MCTSBackpropagationPolicy policy) const
|
||||
-> void;
|
||||
|
||||
// Helper functions
|
||||
[[nodiscard]] auto SelectSimulationAction(
|
||||
const MCTSGameEngine& engine,
|
||||
const MCTSGameState& state,
|
||||
const std::vector<std::unique_ptr<MCTSAction>>& actions) const -> size_t;
|
||||
const std::vector<std::unique_ptr<MCTSAction>>& actions,
|
||||
bool isMaximizing) const -> size_t;
|
||||
|
||||
// Logging
|
||||
static auto LogSearchResults(
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#include <random>
|
||||
#include <vector>
|
||||
|
||||
#include "MCTSTypes.hpp" // For MCTSInternalError
|
||||
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
@@ -26,7 +28,7 @@ double MCTSGameEngine::simulateRandomPlayout(
|
||||
|
||||
// Simulate until terminal or max depth
|
||||
while (!currentState->isTerminal() && depth < maxDepth) {
|
||||
auto actions = getLegalActions(*currentState);
|
||||
auto actions = getLegalActions(*currentState, playerId, 0, 0);
|
||||
if (actions.empty()) { break; }
|
||||
|
||||
size_t selectedIndex = 0;
|
||||
@@ -95,6 +97,38 @@ double MCTSGameEngine::simulateRandomPlayout(
|
||||
selectedIndex = scores[dis(gen)].first;
|
||||
break;
|
||||
}
|
||||
|
||||
case MCTSSimulationPolicy::WEIGHTED_HEURISTIC: {
|
||||
// Get heuristic weights (fast O(1) per action)
|
||||
const auto weights = getActionWeights(actions, *currentState);
|
||||
|
||||
// Filter out zero-weight actions
|
||||
std::vector<size_t> validIndices;
|
||||
std::vector<double> validWeights;
|
||||
validIndices.reserve(actions.size());
|
||||
validWeights.reserve(actions.size());
|
||||
|
||||
for (size_t i = 0; i < weights.size() && i < actions.size(); ++i) {
|
||||
if (weights[i] > 0.0) {
|
||||
validIndices.push_back(i);
|
||||
validWeights.push_back(weights[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// If all actions filtered out, this is a bug in the weighting logic
|
||||
if (validWeights.empty()) {
|
||||
throw MCTSInternalError(
|
||||
"MCTS simulation (playout): All actions have zero weight in "
|
||||
"WEIGHTED_HEURISTIC policy (action count: " +
|
||||
std::to_string(actions.size()) +
|
||||
") - this indicates incorrect weighting");
|
||||
}
|
||||
|
||||
// Select based on heuristic weights
|
||||
std::discrete_distribution<> dis(validWeights.begin(), validWeights.end());
|
||||
selectedIndex = validIndices[dis(gen)];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply selected action using mutable version for efficiency
|
||||
|
||||
@@ -34,9 +34,13 @@ public:
|
||||
state = applyAction(*state, action);
|
||||
}
|
||||
|
||||
// Get all legal actions for the current state
|
||||
// Get all legal actions for the current state with player flip tracking
|
||||
// Default implementation ignores flip tracking and calls base version
|
||||
[[nodiscard]] virtual std::vector<std::unique_ptr<MCTSAction>> getLegalActions(
|
||||
const MCTSGameState& state) const = 0;
|
||||
const MCTSGameState& state,
|
||||
MCTSPlayerId /*rootPlayerId*/,
|
||||
int /*currentPlayerFlips*/,
|
||||
int /*maxPlayerFlips*/) const = 0;
|
||||
|
||||
// Check if a state is terminal
|
||||
[[nodiscard]] virtual bool isTerminal(const MCTSGameState& state) const = 0;
|
||||
@@ -57,6 +61,17 @@ public:
|
||||
return indices;
|
||||
}
|
||||
|
||||
// Get heuristic weights for actions (used by WEIGHTED_HEURISTIC simulation policy)
|
||||
// Returns weights corresponding to each action (same size as actions vector)
|
||||
// Weight of 0.0 = never select, higher = more likely to select
|
||||
// Default: uniform weights (all actions equally likely)
|
||||
[[nodiscard]] virtual std::vector<double> getActionWeights(
|
||||
const std::vector<std::unique_ptr<MCTSAction>>& actions,
|
||||
const MCTSGameState& /*state*/) const {
|
||||
// Default: uniform weights
|
||||
return std::vector<double>(actions.size(), 1.0);
|
||||
}
|
||||
|
||||
// Simulate a random playout from the given state
|
||||
// Default implementation uses policy to select actions
|
||||
[[nodiscard]] virtual double simulateRandomPlayout(
|
||||
|
||||
@@ -35,17 +35,20 @@ struct MCTSNode {
|
||||
double totalReward = 0.0;
|
||||
double averageReward = 0.0;
|
||||
mutable double ucb1Value = 0.0;
|
||||
double actionWeight = 1.0; // Prior probability/weight for this action (from heuristics)
|
||||
|
||||
// Tree structure
|
||||
std::vector<std::unique_ptr<MCTSNode>> children;
|
||||
std::vector<size_t> untriedActionIndices;
|
||||
bool fullyExpanded = false;
|
||||
size_t nextUntriedActionIndex = 0; // Next action to expand
|
||||
size_t totalActions = 0; // Total number of available actions
|
||||
MCTSNode* parent = nullptr;
|
||||
|
||||
// Game context
|
||||
MCTSPlayerId playerId;
|
||||
int depth = 0;
|
||||
bool isTerminal = false;
|
||||
int playerFlips = 0; // Number of times the active player has changed from root player
|
||||
bool isMaximizingPlayer = true; // True if this node is maximizing for root player
|
||||
|
||||
// Transposition detection
|
||||
uint64_t stateHash = 0;
|
||||
@@ -55,7 +58,9 @@ struct MCTSNode {
|
||||
MCTSNode(std::unique_ptr<MCTSGameState> state, MCTSPlayerId pid, int d)
|
||||
: gameState(std::move(state)),
|
||||
playerId(pid),
|
||||
depth(d) {
|
||||
depth(d),
|
||||
playerFlips(0),
|
||||
isMaximizingPlayer(true) {
|
||||
if (gameState) {
|
||||
stateHash = gameState->hash();
|
||||
isTerminal = gameState->isTerminal();
|
||||
@@ -68,12 +73,18 @@ struct MCTSNode {
|
||||
std::unique_ptr<MCTSGameState> state,
|
||||
MCTSPlayerId pid,
|
||||
int d,
|
||||
size_t actIdx = SIZE_MAX)
|
||||
size_t actIdx = SIZE_MAX,
|
||||
int flips = 0,
|
||||
bool isMaximizing = true,
|
||||
double weight = 1.0)
|
||||
: action(std::move(act)),
|
||||
actionIndex(actIdx),
|
||||
gameState(std::move(state)),
|
||||
actionWeight(weight),
|
||||
playerId(pid),
|
||||
depth(d) {
|
||||
depth(d),
|
||||
playerFlips(flips),
|
||||
isMaximizingPlayer(isMaximizing) {
|
||||
if (gameState) {
|
||||
stateHash = gameState->hash();
|
||||
isTerminal = gameState->isTerminal();
|
||||
@@ -100,20 +111,30 @@ struct MCTSNode {
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate UCB1 value for this node
|
||||
void CalculateUCB1(const double explorationConstant) const {
|
||||
if (visitCount == 0) {
|
||||
ucb1Value = std::numeric_limits<double>::max();
|
||||
} else if (parent && parent->visitCount > 0) {
|
||||
ucb1Value = averageReward +
|
||||
explorationConstant * std::sqrt(std::log(parent->visitCount) / visitCount);
|
||||
} else {
|
||||
ucb1Value = averageReward;
|
||||
}
|
||||
// Calculate UCB1 value for this node from parent's perspective
|
||||
// Uses prior-weighted formula similar to AlphaGo:
|
||||
// UCB = Q + c * P * sqrt(N_parent) / (1 + N_child)
|
||||
// Where P is the action weight (prior probability from heuristics)
|
||||
[[nodiscard]] double CalculateUCB1(
|
||||
const double explorationConstant,
|
||||
const int parentVisitCount,
|
||||
const bool parentIsMaximizing) const {
|
||||
// Exploitation: use lookahead score (minimax value)
|
||||
// For minimizing nodes, negate the score to prefer low child values
|
||||
const double exploitationValue = parentIsMaximizing ? lookaheadScore : -lookaheadScore;
|
||||
|
||||
// Exploration: prior-weighted formula (AlphaGo-style)
|
||||
// Actions with weight 0.0 (like FLEE_COMMAND) get no exploration bonus
|
||||
// Unvisited nodes get: c * weight * sqrt(N_parent)
|
||||
// This prevents bad actions from dominating exploration due to infinite UCB
|
||||
const double explorationValue = explorationConstant * actionWeight *
|
||||
std::sqrt(parentVisitCount) / (1.0 + visitCount);
|
||||
|
||||
return exploitationValue + explorationValue;
|
||||
}
|
||||
|
||||
// Check if this node can be expanded
|
||||
[[nodiscard]] bool CanExpand() const { return !fullyExpanded && !untriedActionIndices.empty(); }
|
||||
[[nodiscard]] bool CanExpand() const { return nextUntriedActionIndex < totalActions; }
|
||||
|
||||
// Get best child based on UCB1
|
||||
[[nodiscard]] MCTSNode* GetBestChild(const double explorationConstant) const {
|
||||
@@ -126,10 +147,28 @@ struct MCTSNode {
|
||||
// Skip redundant nodes
|
||||
if (child->isRedundant) continue;
|
||||
|
||||
child->CalculateUCB1(explorationConstant);
|
||||
// Calculate UCB1 value using the helper function
|
||||
const double value =
|
||||
child->CalculateUCB1(explorationConstant, visitCount, isMaximizingPlayer);
|
||||
|
||||
if (child->ucb1Value > bestValue) {
|
||||
bestValue = child->ucb1Value;
|
||||
// Debug logging for UCB selection
|
||||
static bool enableUCBDebug = false;
|
||||
if (enableUCBDebug && child->visitCount > 0) {
|
||||
const double exploitationValue =
|
||||
isMaximizingPlayer ? child->lookaheadScore : -child->lookaheadScore;
|
||||
const double explorationValue =
|
||||
explorationConstant * std::sqrt(std::log(visitCount) / child->visitCount);
|
||||
printf(" UCB: %s lookahead=%.2f expl=%.2f (+%.2f) = %.2f [%s]\n",
|
||||
isMaximizingPlayer ? "MAX" : "MIN",
|
||||
child->lookaheadScore,
|
||||
exploitationValue,
|
||||
explorationValue,
|
||||
value,
|
||||
child->action ? child->action->getDescription().c_str() : "root");
|
||||
}
|
||||
|
||||
if (value > bestValue) {
|
||||
bestValue = value;
|
||||
bestChild = child.get();
|
||||
}
|
||||
}
|
||||
@@ -143,7 +182,8 @@ struct MCTSNode {
|
||||
|
||||
MCTSNode* bestChild = nullptr;
|
||||
int bestVisits = 0;
|
||||
double bestScore = -std::numeric_limits<double>::max();
|
||||
double bestScore = isMaximizingPlayer ? -std::numeric_limits<double>::max()
|
||||
: std::numeric_limits<double>::max();
|
||||
|
||||
for (const auto& child : children) {
|
||||
// Skip redundant nodes
|
||||
@@ -152,12 +192,18 @@ struct MCTSNode {
|
||||
// Prefer most-visited node (robust child selection)
|
||||
if (child->visitCount > bestVisits) {
|
||||
bestVisits = child->visitCount;
|
||||
bestScore = child->averageReward;
|
||||
bestChild = child.get();
|
||||
} else if (child->visitCount == bestVisits && child->averageReward > bestScore) {
|
||||
// Tie-break on average reward
|
||||
bestScore = child->averageReward;
|
||||
bestScore = child->lookaheadScore;
|
||||
bestChild = child.get();
|
||||
} else if (child->visitCount == bestVisits) {
|
||||
// Tie-break on lookahead score (minimax value, not poisoned average)
|
||||
// Maximizing: prefer higher score (better for root player)
|
||||
// Minimizing: prefer lower score (worse for root player)
|
||||
const bool shouldReplace = isMaximizingPlayer ? (child->lookaheadScore > bestScore)
|
||||
: (child->lookaheadScore < bestScore);
|
||||
if (shouldReplace) {
|
||||
bestScore = child->lookaheadScore;
|
||||
bestChild = child.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +212,9 @@ struct MCTSNode {
|
||||
for (const auto& child : children) {
|
||||
if (child->isRedundant) continue;
|
||||
|
||||
if (child->lookaheadScore > bestScore) {
|
||||
const bool shouldReplace = isMaximizingPlayer ? (child->lookaheadScore > bestScore)
|
||||
: (child->lookaheadScore < bestScore);
|
||||
if (shouldReplace) {
|
||||
bestScore = child->lookaheadScore;
|
||||
bestChild = child.get();
|
||||
}
|
||||
|
||||
@@ -5,18 +5,34 @@
|
||||
#ifndef EAGLE0_MCTS_TYPES_HPP
|
||||
#define EAGLE0_MCTS_TYPES_HPP
|
||||
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
// Exception thrown when MCTS encounters an internal error that indicates a bug
|
||||
class MCTSInternalError : public std::logic_error {
|
||||
public:
|
||||
explicit MCTSInternalError(const std::string& message) : std::logic_error(message) {}
|
||||
};
|
||||
|
||||
// Abstract player identifier type
|
||||
using MCTSPlayerId = int;
|
||||
|
||||
// Simulation policy for MCTS rollouts
|
||||
enum class MCTSSimulationPolicy {
|
||||
RANDOM, // Pure random selection
|
||||
FILTERED_RANDOM, // Random from filtered actions
|
||||
BEST_IMMEDIATE, // Choose best immediate score
|
||||
WEIGHTED_BEST_IMMEDIATE // Random weighted by score ranking
|
||||
RANDOM, // Pure random selection
|
||||
FILTERED_RANDOM, // Random from filtered actions
|
||||
BEST_IMMEDIATE, // Choose best immediate score
|
||||
WEIGHTED_BEST_IMMEDIATE, // Random weighted by score ranking
|
||||
WEIGHTED_HEURISTIC // Random weighted by fast heuristics (no score evaluation)
|
||||
};
|
||||
|
||||
// Backpropagation policy for MCTS tree updates
|
||||
enum class MCTSBackpropagationPolicy {
|
||||
AVERAGING, // Traditional MCTS averaging (for stochastic/single-player games)
|
||||
MINIMAX // Minimax backup (for deterministic adversarial games)
|
||||
};
|
||||
|
||||
// Configuration for MCTS algorithm
|
||||
@@ -27,6 +43,9 @@ struct MCTSConfig {
|
||||
bool useMultithreading = true; // Enable parallel MCTS
|
||||
int numThreads = 16; // Number of threads for parallel MCTS
|
||||
MCTSSimulationPolicy simulationPolicy = MCTSSimulationPolicy::BEST_IMMEDIATE;
|
||||
MCTSBackpropagationPolicy backpropagationPolicy = MCTSBackpropagationPolicy::AVERAGING;
|
||||
int maxPlayerFlips = 0; // Maximum number of player changes to explore (0 = stop at first
|
||||
// flip, 1 = explore through opponent's response, etc.)
|
||||
};
|
||||
|
||||
} // namespace mcts
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
#include <limits>
|
||||
|
||||
#include "AICommandFilter.hpp"
|
||||
#include "AIScoreCalculator.hpp"
|
||||
#include "TranspositionTable.hpp"
|
||||
#include "src/main/cpp/net/eagle0/common/SequenceRandomGenerator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexCubeUtils.hpp"
|
||||
|
||||
|
||||
@@ -13,6 +13,13 @@ enum class AIAlgorithmType {
|
||||
MCTS // Monte Carlo Tree Search with multithreading
|
||||
};
|
||||
|
||||
// Enum for scoring calculator selection
|
||||
enum class ScoringCalculatorType {
|
||||
STANDARD, // Default: Unbounded raw scores
|
||||
NORMALIZED, // Normalized scores in [0, 1] range for ML training
|
||||
MCTS_OPTIMIZED // Bounded linear scores tuned for MCTS
|
||||
};
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_AI_CONFIG_HPP
|
||||
@@ -8,9 +8,9 @@
|
||||
#include <ranges>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackGroups.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
//
|
||||
// Fast heuristic weighting implementation with context-aware logic
|
||||
//
|
||||
|
||||
#include "AIHeuristicWeighting.hpp"
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistances.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
using CommandType = net::eagle0::shardok::common::CommandType;
|
||||
using Coords = net::eagle0::shardok::storage::fb::Coords;
|
||||
using ProtoCoords = net::eagle0::shardok::common::Coords;
|
||||
|
||||
double AIHeuristicWeighting::GetCommandWeight(
|
||||
const net::eagle0::shardok::api::CommandDescriptor& command,
|
||||
const GameStateW& state,
|
||||
const CoordsSet& castleCoords,
|
||||
const APDCache* apdCache,
|
||||
bool isDefender,
|
||||
std::function<BattalionTypeSPtr(BattalionTypeId)> getBattalionType) {
|
||||
// Fast O(1) heuristic weights based on command type and game context
|
||||
// Higher weight = more likely to select in simulation
|
||||
// 0.0 = never select (filtered out)
|
||||
|
||||
const auto* hexMap = state->hex_map();
|
||||
const auto* units = state->units();
|
||||
const auto actorPlayerId = command.player();
|
||||
|
||||
switch (command.type()) {
|
||||
// === HIGH VALUE OFFENSIVE (10.0) ===
|
||||
// Ranged attacks - very valuable, typically available when in range
|
||||
case CommandType::ARCHERY_COMMAND: return 20.0;
|
||||
case CommandType::LIGHTNING_BOLT_COMMAND: return 10.0;
|
||||
case CommandType::FEAR_COMMAND: return 10.0;
|
||||
|
||||
// Area/tactical spells - high impact
|
||||
case CommandType::METEOR_START_COMMAND: {
|
||||
// High weight per enemy unit at or adjacent to target
|
||||
if (!command.has_target()) return 0.0; // Default if no target info
|
||||
|
||||
const Coords targetCoords(command.target().row(), command.target().column());
|
||||
int enemyCount = 0;
|
||||
|
||||
// Count enemies at target
|
||||
if (const auto* targetUnit = Occupant(units, targetCoords)) {
|
||||
if (targetUnit->player_id() != actorPlayerId) { enemyCount++; }
|
||||
}
|
||||
|
||||
// Count enemies adjacent to target
|
||||
for (const auto& neighbor : HexMapUtils::GetAdjacentTiles(hexMap, targetCoords)) {
|
||||
if (const auto* unit = Occupant(units, neighbor.coords)) {
|
||||
if (unit->player_id() != actorPlayerId) { enemyCount++; }
|
||||
}
|
||||
}
|
||||
|
||||
return 1.0 + (enemyCount * 15.0); // Base 1 + 15 per enemy in range
|
||||
}
|
||||
|
||||
case CommandType::METEOR_TARGET_COMMAND: {
|
||||
// High weight per enemy unit at or adjacent to target
|
||||
if (!command.has_target()) return 6.0; // Default if no target info
|
||||
|
||||
const Coords targetCoords(command.target().row(), command.target().column());
|
||||
int enemyCount = 0;
|
||||
|
||||
// Count enemies at target
|
||||
if (const auto* targetUnit = Occupant(units, targetCoords)) {
|
||||
if (targetUnit->player_id() != actorPlayerId) { enemyCount++; }
|
||||
}
|
||||
|
||||
// Count enemies adjacent to target
|
||||
for (const auto& neighbor : HexMapUtils::GetAdjacentTiles(hexMap, targetCoords)) {
|
||||
if (const auto* unit = Occupant(units, neighbor.coords)) {
|
||||
if (unit->player_id() != actorPlayerId) { enemyCount++; }
|
||||
}
|
||||
}
|
||||
|
||||
return 1.0 + (enemyCount * 15.0); // Base 1 + 15 per enemy in range
|
||||
}
|
||||
|
||||
case CommandType::RAISE_DEAD_COMMAND: return 10.0;
|
||||
case CommandType::HOLY_WAVE_COMMAND: return 8.0;
|
||||
|
||||
// Fire on enemy (context-dependent)
|
||||
case CommandType::START_FIRE_COMMAND: {
|
||||
// High if enemy at target, low otherwise
|
||||
if (!command.has_target()) return 3.0; // Default
|
||||
|
||||
const Coords targetCoords(command.target().row(), command.target().column());
|
||||
if (const auto* targetUnit = Occupant(units, targetCoords)) {
|
||||
if (targetUnit->player_id() != actorPlayerId) {
|
||||
return 10.0; // Enemy at target - high value
|
||||
}
|
||||
}
|
||||
return 1.0; // No enemy - low value
|
||||
}
|
||||
|
||||
// === MEDIUM-HIGH OFFENSIVE (5.0-7.0) ===
|
||||
// Direct damage melee
|
||||
case CommandType::MELEE_COMMAND: return 7.0;
|
||||
case CommandType::CHARGE_COMMAND: return 7.0; // Damage + movement
|
||||
case CommandType::CHALLENGE_DUEL_COMMAND: return 5.0;
|
||||
|
||||
// Control and tactical magic
|
||||
case CommandType::CONTROL_COMMAND: return 6.0;
|
||||
case CommandType::METEOR_CAST_COMMAND: return 6.0; // Finish meteor
|
||||
|
||||
case CommandType::REDUCE_COMMAND: {
|
||||
// High if enemy at target, zero otherwise
|
||||
if (!command.has_target()) return 0.0;
|
||||
|
||||
const Coords targetCoords(command.target().row(), command.target().column());
|
||||
if (const auto* targetUnit = Occupant(units, targetCoords)) {
|
||||
if (targetUnit->player_id() != actorPlayerId) {
|
||||
return 10.0; // Enemy at target - very high value
|
||||
}
|
||||
}
|
||||
return 0.0; // No enemy - don't use
|
||||
}
|
||||
|
||||
// === MOVEMENT - Context-dependent ===
|
||||
case CommandType::MOVE_COMMAND: {
|
||||
if (isDefender) {
|
||||
return 0.0; // Defenders don't move
|
||||
}
|
||||
|
||||
// Attackers: weight based on distance improvement towards castle
|
||||
if (!command.has_target()) return 4.0; // Default if no target
|
||||
|
||||
// Get actor unit to determine battalion type and start position
|
||||
const auto* actorUnit = units->Get(command.actor().value());
|
||||
if (!actorUnit) return 4.0; // Default if can't find actor
|
||||
|
||||
// Get battalion type for distance calculation
|
||||
const auto battalionTypeId = actorUnit->battalion().type();
|
||||
const auto battalionTypePtr = getBattalionType(battalionTypeId);
|
||||
if (!battalionTypePtr) return 4.0; // Default if can't get battalion type
|
||||
|
||||
// Get ActionPointDistances for this battalion type
|
||||
const auto mapId = ActionPointDistancesCache::GetMapId(hexMap);
|
||||
const auto* apd = (*apdCache)->GetRaw(hexMap, mapId, battalionTypePtr, false, -1);
|
||||
if (!apd) return 4.0; // Default if can't get distances
|
||||
|
||||
// Calculate minimum distance from start to any castle
|
||||
const Coords startCoords = actorUnit->location();
|
||||
auto minStartDistance = ActionPointDistances::IMPOSSIBLE;
|
||||
for (const auto& castleCoord : castleCoords) {
|
||||
const auto dist = apd->Distance(startCoords, castleCoord);
|
||||
if (dist < minStartDistance) { minStartDistance = dist; }
|
||||
}
|
||||
|
||||
// Calculate minimum distance from end to any castle
|
||||
const Coords endCoords(command.target().row(), command.target().column());
|
||||
auto minEndDistance = ActionPointDistances::IMPOSSIBLE;
|
||||
for (const auto& castleCoord : castleCoords) {
|
||||
const auto dist = apd->Distance(endCoords, castleCoord);
|
||||
if (dist < minEndDistance) { minEndDistance = dist; }
|
||||
}
|
||||
|
||||
// Return weight based on distance improvement
|
||||
// Higher weight if we're moving closer to castle
|
||||
if (minStartDistance == ActionPointDistances::IMPOSSIBLE ||
|
||||
minEndDistance == ActionPointDistances::IMPOSSIBLE) {
|
||||
return 4.0; // Default if distances are impossible
|
||||
}
|
||||
|
||||
const auto improvement = static_cast<double>(minStartDistance - minEndDistance);
|
||||
return std::max(0.0, improvement);
|
||||
}
|
||||
|
||||
case CommandType::BRAVE_WATER_COMMAND: return 3.0; // Tactical movement
|
||||
case CommandType::SCOUT_COMMAND:
|
||||
return 2.0; // Information gathering
|
||||
|
||||
// Terrain manipulation
|
||||
case CommandType::FREEZE_WATER_COMMAND: return 3.0;
|
||||
case CommandType::BUILD_BRIDGE_COMMAND: return 3.0;
|
||||
|
||||
// === LOW VALUE DEFENSIVE/UTILITY (1.0-2.0) ===
|
||||
case CommandType::EXTINGUISH_FIRE_COMMAND: {
|
||||
// High if friendly at target, low otherwise
|
||||
if (!command.has_target()) return 2.0; // Default
|
||||
|
||||
const Coords targetCoords(command.target().row(), command.target().column());
|
||||
if (const auto* targetUnit = Occupant(units, targetCoords)) {
|
||||
if (targetUnit->player_id() == actorPlayerId) {
|
||||
return 8.0; // Friendly at target - high value
|
||||
}
|
||||
}
|
||||
return 1.0; // No friendly - low value
|
||||
}
|
||||
|
||||
case CommandType::UNIT_REST_COMMAND: return 1.5;
|
||||
case CommandType::FORTIFY_COMMAND: return 2.0;
|
||||
|
||||
// Zero weight - don't use in simulation
|
||||
case CommandType::REPAIR_COMMAND: return 0.0;
|
||||
case CommandType::HIDE_COMMAND: return 0.0;
|
||||
case CommandType::RELEASE_UNIT_COMMAND: return 0.0;
|
||||
|
||||
case CommandType::REINFORCE_COMMAND: return 10.0;
|
||||
case CommandType::MANAGE_PRISONER: return 1.0;
|
||||
|
||||
// === ZERO WEIGHT - NEVER SELECT (0.0) ===
|
||||
// Explicitly bad actions
|
||||
case CommandType::FLEE_COMMAND: return 0.0; // Never flee in simulation
|
||||
case CommandType::RETREAT_COMMAND: return 0.0;
|
||||
case CommandType::BECOME_OUTLAW_COMMAND: return 0.0; // Never become outlaw
|
||||
case CommandType::DISMISS_UNIT_COMMAND:
|
||||
return 0.0; // Never dismiss in combat
|
||||
|
||||
// Actions that are fine as a fallback
|
||||
case CommandType::END_TURN_COMMAND: return 1.0;
|
||||
case CommandType::UNIT_STOP_COMMAND: return 1.0;
|
||||
case CommandType::METEOR_CANCEL_COMMAND: return 1.0;
|
||||
|
||||
// Setup commands (shouldn't appear in combat, but filter anyway)
|
||||
case CommandType::PLACE_UNIT_COMMAND: return 10.0;
|
||||
case CommandType::PLACE_HIDDEN_UNIT_COMMAND: return 1.0;
|
||||
case CommandType::END_PLAYER_SETUP_COMMAND: return 1.0;
|
||||
|
||||
// Unknown/unhandled
|
||||
case CommandType::UNKNOWN_COMMAND:
|
||||
default: return 0.0; // Don't select unknown commands
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// Fast heuristic weighting for MCTS simulations
|
||||
// Provides O(1) weights based on command type and context
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_AI_HEURISTIC_WEIGHTING_HPP
|
||||
#define EAGLE0_AI_HEURISTIC_WEIGHTING_HPP
|
||||
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
|
||||
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
|
||||
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
|
||||
#pragma clang diagnostic pop
|
||||
|
||||
#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"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
// Fast heuristic-based command weighting for MCTS simulation policy
|
||||
// Avoids expensive score calculation while maintaining intelligent bias
|
||||
class AIHeuristicWeighting {
|
||||
public:
|
||||
// Get weight for a command using fast heuristics with game context
|
||||
// Returns weight >= 0.0, where 0.0 means "never select" and higher is more likely
|
||||
static double GetCommandWeight(
|
||||
const net::eagle0::shardok::api::CommandDescriptor& command,
|
||||
const GameStateW& state,
|
||||
const CoordsSet& castleCoords,
|
||||
const APDCache* apdCache,
|
||||
bool isDefender,
|
||||
std::function<BattalionTypeSPtr(BattalionTypeId)> getBattalionType);
|
||||
};
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_AI_HEURISTIC_WEIGHTING_HPP
|
||||
@@ -24,10 +24,40 @@ int AIEvaluationCounter::GetCurrentCount() { return activeCount.load(); }
|
||||
auto CalculateTimeBudget(
|
||||
const PlayerId playerId,
|
||||
const GameSettingsSPtr &settings,
|
||||
const GameStateW &state) -> AITimeBudget {
|
||||
const GameStateW &state,
|
||||
const size_t numCommands) -> AITimeBudget {
|
||||
const auto settingsGetter = settings->GetGetter();
|
||||
const auto castleCoords = AllCastleCoords(state->hex_map());
|
||||
|
||||
// Check if we're in setup phase
|
||||
const bool isSetupPhase = state->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP;
|
||||
|
||||
// Get maximum budget cap from settings (in seconds)
|
||||
const double maxBudgetSeconds =
|
||||
settingsGetter.Backing().lookahead_time_budget_maximum_seconds();
|
||||
const double maxBudgetMs = maxBudgetSeconds * 1000.0;
|
||||
|
||||
// During setup, use the setup-specific time budget
|
||||
if (isSetupPhase) {
|
||||
// Dynamic budget: msPerCommand × numCommands
|
||||
const double msPerCommand =
|
||||
settingsGetter.Backing().lookahead_time_budget_per_command_setup_ms();
|
||||
const double budgetMs = msPerCommand * static_cast<double>(numCommands);
|
||||
|
||||
// Clamp to reasonable bounds: 200ms minimum, maxBudgetMs maximum
|
||||
const auto clampedBudgetMs = std::clamp(budgetMs, 200.0, maxBudgetMs);
|
||||
const auto remainingBudget =
|
||||
std::chrono::milliseconds(static_cast<int64_t>(clampedBudgetMs));
|
||||
|
||||
const size_t minDepth = settingsGetter.Backing().min_lookahead_turns();
|
||||
|
||||
return AITimeBudget{
|
||||
.remainingBudget = remainingBudget,
|
||||
.minDepthRequired = minDepth,
|
||||
.isCloseToEnemy = false}; // Not relevant during setup
|
||||
}
|
||||
|
||||
// Determine proximity (≤4 hex distance) - applies to both attackers and defenders
|
||||
bool isClose = false;
|
||||
const auto *units = state->units();
|
||||
@@ -72,12 +102,16 @@ auto CalculateTimeBudget(
|
||||
}
|
||||
}
|
||||
|
||||
// Get time budget from settings
|
||||
const auto budget = std::chrono::duration<double>(
|
||||
isClose ? settingsGetter.Backing().lookahead_time_budget_close_in_seconds()
|
||||
: settingsGetter.Backing().lookahead_time_budget_far_in_seconds());
|
||||
// Get time budget from settings - dynamic based on number of commands
|
||||
// Dynamic budget: msPerCommand × numCommands
|
||||
const double msPerCommand =
|
||||
isClose ? settingsGetter.Backing().lookahead_time_budget_per_command_close_ms()
|
||||
: settingsGetter.Backing().lookahead_time_budget_per_command_far_ms();
|
||||
const double budgetMs = msPerCommand * static_cast<double>(numCommands);
|
||||
|
||||
const auto remainingBudget = std::chrono::duration_cast<std::chrono::milliseconds>(budget);
|
||||
// Clamp to reasonable bounds: 200ms minimum, maxBudgetMs maximum
|
||||
const auto clampedBudgetMs = std::clamp(budgetMs, 200.0, maxBudgetMs);
|
||||
const auto remainingBudget = std::chrono::milliseconds(static_cast<int64_t>(clampedBudgetMs));
|
||||
|
||||
// Get minimum depth requirement
|
||||
const size_t minDepth = settingsGetter.Backing().min_lookahead_turns();
|
||||
|
||||
@@ -36,10 +36,13 @@ struct AITimeBudget {
|
||||
};
|
||||
|
||||
// Calculate time budget based on proximity to enemies and castles
|
||||
// Time budget is calculated dynamically based on number of available commands:
|
||||
// budget = msPerCommand × numCommands (clamped to 200-5000ms)
|
||||
auto CalculateTimeBudget(
|
||||
PlayerId playerId,
|
||||
const GameSettingsSPtr &settings,
|
||||
const GameStateW &state) -> AITimeBudget;
|
||||
const GameStateW &state,
|
||||
size_t numCommands) -> AITimeBudget;
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
|
||||
@@ -39,8 +39,9 @@ cc_library(
|
||||
hdrs = ["AIAttackGroups.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":ai_attack_locations",
|
||||
@@ -58,6 +59,10 @@ cc_library(
|
||||
srcs = ["AIAttackLocations.cpp"],
|
||||
hdrs = ["AIAttackLocations.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":ai_score_utilities",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/map:terrain",
|
||||
@@ -96,8 +101,10 @@ cc_library(
|
||||
hdrs = ["AIDistanceDebuf.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":ai_attack_locations",
|
||||
@@ -130,8 +137,10 @@ cc_library(
|
||||
hdrs = ["AIScoreUtilities.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
|
||||
@@ -159,6 +168,25 @@ cc_library(
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "ai_heuristic_weighting",
|
||||
srcs = ["AIHeuristicWeighting.cpp"],
|
||||
hdrs = ["AIHeuristicWeighting.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts/adapters:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/map:coords_set",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
|
||||
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "ai_command_evaluator",
|
||||
srcs = ["AICommandEvaluator.cpp"],
|
||||
@@ -170,10 +198,10 @@ cc_library(
|
||||
],
|
||||
deps = [
|
||||
":ai_command_filter",
|
||||
":ai_score_calculator_interface",
|
||||
":ai_strategy",
|
||||
":transposition_table",
|
||||
"//src/main/cpp/net/eagle0/common:sequence_random_generator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
|
||||
"//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/map:coords_set",
|
||||
@@ -220,59 +248,13 @@ cc_library(
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "ai_score_calculator_interface",
|
||||
hdrs = ["AIScoreCalculator.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts/adapters:__pkg__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
|
||||
],
|
||||
deps = [
|
||||
":ai_attack_locations",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/map:coords_set",
|
||||
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "standard_ai_score_calculator",
|
||||
srcs = ["StandardAIScoreCalculator.cpp"],
|
||||
hdrs = ["StandardAIScoreCalculator.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts/adapters:__pkg__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
|
||||
],
|
||||
deps = [
|
||||
":ai_score_calculator_interface",
|
||||
":ai_strategy",
|
||||
":ai_unit_score_calculator",
|
||||
":ai_victory_condition_score_calculator",
|
||||
":ai_water_crossing_calculator",
|
||||
"//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/map:coords_set",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "ai_strategy",
|
||||
srcs = ["AIStrategy.cpp"],
|
||||
hdrs = ["AIStrategy.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts/adapters:__pkg__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
],
|
||||
@@ -288,6 +270,7 @@ cc_library(
|
||||
hdrs = ["AIUnitScoreCalculator.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
],
|
||||
@@ -298,28 +281,6 @@ cc_library(
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "ai_victory_condition_score_calculator",
|
||||
srcs = ["AIVictoryConditionScoreCalculator.cpp"],
|
||||
hdrs = ["AIVictoryConditionScoreCalculator.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":ai_attack_groups",
|
||||
":ai_attack_locations",
|
||||
":ai_common_types",
|
||||
":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",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "ai_water_crossing_calculator",
|
||||
srcs = ["AIWaterCrossingCalculator.cpp"],
|
||||
@@ -327,12 +288,13 @@ cc_library(
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok:__pkg__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":ai_common_types",
|
||||
":ai_minimum_distance_and_target",
|
||||
":ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
|
||||
"//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",
|
||||
@@ -391,10 +353,10 @@ cc_library(
|
||||
":ai_attacker_strategy_selector",
|
||||
":ai_command_evaluator",
|
||||
":ai_defender_strategy_selector",
|
||||
":ai_score_calculator_interface",
|
||||
":ai_time_budget",
|
||||
":ai_water_crossing_command_chooser",
|
||||
"//src/main/cpp/net/eagle0/common:time_utils",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
|
||||
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
|
||||
@@ -423,12 +385,14 @@ cc_library(
|
||||
":ai_defender_strategy_selector",
|
||||
":ai_flee_decision_calculator",
|
||||
":ai_iterative_deepening", # Direct dependency for runtime selection
|
||||
":ai_score_calculator_interface",
|
||||
":ai_time_budget",
|
||||
":ai_water_crossing_command_chooser",
|
||||
":standard_ai_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/common:time_utils",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:shardok_mcts_ai", # MCTS with abstraction layer
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:mcts_optimized_ai_score_calculator", # Bounded linear scorer for MCTS
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:normalized_ai_score_calculator", # Normalized [0,1] scorer for ML training
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:standard_ai_score_calculator", # Standard unbounded scorer (default)
|
||||
"//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/cpp/net/eagle0/shardok/library/util:game_state_dumper",
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
#include "AIAttackerStrategySelector.hpp"
|
||||
#include "AICommandEvaluator.hpp"
|
||||
#include "AIScoreCalculator.hpp"
|
||||
#include "TranspositionTable.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
#include <future>
|
||||
#include <vector>
|
||||
|
||||
#include "AIScoreCalculator.hpp"
|
||||
#include "AIStrategy.hpp"
|
||||
#include "AITimeBudget.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
|
||||
@@ -19,8 +19,10 @@
|
||||
#include "AIScoreUtilities.hpp"
|
||||
#include "AITimeBudget.hpp"
|
||||
#include "IterativeDeepeningAI.hpp"
|
||||
#include "StandardAIScoreCalculator.hpp"
|
||||
#include "mcts/ShardokMCTSAI.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/MCTSOptimizedAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/NormalizedAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/StandardAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateGuesser.hpp"
|
||||
#include "src/main/protobuf/net/eagle0/shardok/api/action_result_view.pb.h"
|
||||
@@ -45,12 +47,16 @@ ShardokAIClient::ShardokAIClient(
|
||||
const bool isDefender,
|
||||
const HexMap *hexMap,
|
||||
const SettingsGetter &settings,
|
||||
const AIAlgorithmType aiAlgorithmType)
|
||||
const AIAlgorithmType aiAlgorithmType,
|
||||
const ScoringCalculatorType scoringCalculatorType,
|
||||
const mcts::MCTSConfig &mctsConfig)
|
||||
: playerId(playerId),
|
||||
isDefender(isDefender),
|
||||
aiAlgorithmType(aiAlgorithmType),
|
||||
scoringCalculatorType(scoringCalculatorType),
|
||||
alCache(std::make_unique<AttackLocationsCache>(hexMap, settings)),
|
||||
waterCrossingCommandChooser(playerId, apdCache) {
|
||||
waterCrossingCommandChooser(playerId, apdCache),
|
||||
mctsConfig(mctsConfig) {
|
||||
// Pre-generate the most common cache entries for better performance
|
||||
const auto mapId = ActionPointDistancesCache::GetMapId(hexMap);
|
||||
|
||||
@@ -96,13 +102,35 @@ auto ShardokAIClient::StandardChooseCommandIndex(
|
||||
const vector<CommandProto> &realAvailableCommands) const -> CommandChoiceResults {
|
||||
const auto settingsGetter = settings->GetGetter();
|
||||
const auto guessedEngine = ShardokEngine(settings, guessedState);
|
||||
|
||||
// Calculate time budget based on game situation using new settings
|
||||
const auto timeBudget = CalculateTimeBudget(playerId, settings, guessedState);
|
||||
|
||||
const auto guessedCommands = guessedEngine.GetAvailableCommandProtos(playerId, false);
|
||||
const auto commandCount = guessedCommands.size();
|
||||
|
||||
// Calculate time budget based on game situation using new dynamic per-command settings
|
||||
const auto timeBudget = CalculateTimeBudget(playerId, settings, guessedState, commandCount);
|
||||
|
||||
// Configure MCTS based on proximity to enemy
|
||||
// When far from enemy: use AVERAGING with maxPlayerFlips=0 (single-player lookahead)
|
||||
// - AVERAGING naturally penalizes longer paths through variance
|
||||
// - No opponent nodes, so no one-bad-child problem
|
||||
// When close to enemy: use MINIMAX with maxPlayerFlips=1 (adversarial lookahead)
|
||||
// - MINIMAX correctly models opponent choosing best response
|
||||
// - Explores through one opponent turn for tactical accuracy
|
||||
auto adjustedMCTSConfig = mctsConfig;
|
||||
// adjustedMCTSConfig.maxPlayerFlips = 0;
|
||||
// if (timeBudget.isCloseToEnemy) {
|
||||
// adjustedMCTSConfig.maxPlayerFlips = 1;
|
||||
// adjustedMCTSConfig.backpropagationPolicy = mcts::MCTSBackpropagationPolicy::MINIMAX;
|
||||
// if constexpr (kPerformanceLogging) {
|
||||
// printf("MCTS Config: Close to enemy - using maxPlayerFlips=1, MINIMAX backprop\n");
|
||||
// }
|
||||
// } else {
|
||||
// adjustedMCTSConfig.maxPlayerFlips = 0;
|
||||
// adjustedMCTSConfig.backpropagationPolicy = mcts::MCTSBackpropagationPolicy::AVERAGING;
|
||||
// if constexpr (kPerformanceLogging) {
|
||||
// printf("MCTS Config: Far from enemy - using maxPlayerFlips=0, AVERAGING backprop\n");
|
||||
// }
|
||||
// }
|
||||
|
||||
assert(commandCount == realAvailableCommands.size());
|
||||
for (size_t i = 0; i < commandCount; i++) {
|
||||
CheckCommand(realAvailableCommands[i], guessedCommands[i]);
|
||||
@@ -115,8 +143,18 @@ auto ShardokAIClient::StandardChooseCommandIndex(
|
||||
return settingsGetter.GetBattalionType(typeId);
|
||||
};
|
||||
|
||||
// Create scorer for actual scoring during search
|
||||
const auto scorer = MakeStandardAIScoreCalculator(settingsGetter, apdCache, alCache);
|
||||
// Create scorer for actual scoring during search - type selected at construction
|
||||
std::unique_ptr<AIScoreCalculator> scorer;
|
||||
switch (scoringCalculatorType) {
|
||||
case ScoringCalculatorType::NORMALIZED:
|
||||
scorer = MakeNormalizedAIScoreCalculator(settingsGetter, apdCache, alCache);
|
||||
break;
|
||||
case ScoringCalculatorType::MCTS_OPTIMIZED:
|
||||
scorer = MakeMCTSOptimizedAIScoreCalculator(settingsGetter, apdCache, alCache);
|
||||
break;
|
||||
case ScoringCalculatorType::STANDARD:
|
||||
default: scorer = MakeStandardAIScoreCalculator(settingsGetter, apdCache, alCache); break;
|
||||
}
|
||||
|
||||
// Determine strategy once for consistent scoring throughout iterative deepening
|
||||
const auto castleCoords = AllCastleCoords(guessedState->hex_map());
|
||||
@@ -143,7 +181,15 @@ auto ShardokAIClient::StandardChooseCommandIndex(
|
||||
|
||||
if (aiAlgorithmType == AIAlgorithmType::MCTS) {
|
||||
// Using Monte Carlo Tree Search AI (with abstraction layer)
|
||||
ShardokMCTSAI ai(playerId, isDefender, strategy, castleCoords, *scorer, apdCache, alCache);
|
||||
ShardokMCTSAI ai(
|
||||
playerId,
|
||||
isDefender,
|
||||
strategy,
|
||||
castleCoords,
|
||||
*scorer,
|
||||
apdCache,
|
||||
alCache,
|
||||
adjustedMCTSConfig);
|
||||
search_result = ai.Search(settings, guessedState, timeBudget);
|
||||
} else {
|
||||
// Using Iterative Deepening AI (default)
|
||||
|
||||
@@ -12,11 +12,12 @@
|
||||
#include <vector>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/common/RandomGenerator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/common/mcts/abstract/MCTSTypes.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIConfig.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AITimeBudget.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCommandChooser.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/IterativeDeepeningAI.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
#include "src/main/protobuf/net/eagle0/shardok/api/game_state_view.pb.h"
|
||||
|
||||
namespace shardok {
|
||||
@@ -40,12 +41,16 @@ private:
|
||||
const PlayerId playerId;
|
||||
const bool isDefender;
|
||||
const AIAlgorithmType aiAlgorithmType;
|
||||
const ScoringCalculatorType scoringCalculatorType;
|
||||
|
||||
APDCache apdCache = std::make_shared<ActionPointDistancesCache>();
|
||||
ALCache alCache;
|
||||
|
||||
const AIWaterCrossingCommandChooser waterCrossingCommandChooser;
|
||||
|
||||
// MCTS configuration (only used when aiAlgorithmType == MCTS)
|
||||
mcts::MCTSConfig mctsConfig;
|
||||
|
||||
[[nodiscard]] auto StandardChooseCommandIndex(
|
||||
const GameSettingsSPtr& settings,
|
||||
const GameStateW& guessedState,
|
||||
@@ -70,13 +75,19 @@ public:
|
||||
bool isDefender,
|
||||
const HexMap* hexMap,
|
||||
const SettingsGetter& settings,
|
||||
AIAlgorithmType aiAlgorithmType = AIAlgorithmType::ITERATIVE_DEEPENING);
|
||||
AIAlgorithmType aiAlgorithmType,
|
||||
ScoringCalculatorType scoringCalculatorType,
|
||||
const mcts::MCTSConfig& mctsConfig);
|
||||
~ShardokAIClient() = default;
|
||||
|
||||
[[nodiscard]] auto GetPlayerId() const -> PlayerId { return playerId; }
|
||||
|
||||
[[nodiscard]] auto ChooseCommandIndex(const ShardokEngine& engine) const
|
||||
-> CommandChoiceResults;
|
||||
|
||||
// MCTS configuration methods (only relevant when using MCTS algorithm)
|
||||
[[nodiscard]] auto GetMCTSConfig() const -> const mcts::MCTSConfig& { return mctsConfig; }
|
||||
void SetMCTSConfig(const mcts::MCTSConfig& config) { mctsConfig = config; }
|
||||
};
|
||||
} // namespace shardok
|
||||
|
||||
|
||||
@@ -1,826 +0,0 @@
|
||||
//
|
||||
// Standard implementation of AIScoreCalculator
|
||||
//
|
||||
|
||||
#include "StandardAIScoreCalculator.hpp"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <future>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "AIAttackGroups.hpp"
|
||||
#include "AIAttackLocations.hpp"
|
||||
#include "AIScoreCalculator.hpp"
|
||||
#include "AIScoreUtilities.hpp"
|
||||
#include "AIStrategy.hpp"
|
||||
#include "AIUnitScoreCalculator.hpp"
|
||||
#include "AIVictoryConditionScoreCalculator.hpp"
|
||||
#include "AIWaterCrossingCalculator.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/settings/GameSettings.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexCubeUtils.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
#include "src/main/protobuf/net/eagle0/shardok/api/unit_view.pb.h"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
using net::eagle0::shardok::storage::fb::BattalionTypeId;
|
||||
|
||||
// Forward declare the implementation class
|
||||
class StandardAIScoreCalculator;
|
||||
|
||||
// Anonymous namespace for helper functions that don't need access to scorer
|
||||
namespace {
|
||||
|
||||
#define LOGGING_ 0
|
||||
#define PERFORMANCE_LOGGING_ 0
|
||||
|
||||
// Performance logging for AttackerScoreForState
|
||||
struct AttackerScorePerformanceLogger {
|
||||
static constexpr int LOG_INTERVAL = 100000;
|
||||
|
||||
static std::atomic<int> callCount;
|
||||
static std::atomic<double> intervalTime;
|
||||
static std::atomic<double> totalTime;
|
||||
|
||||
static void LogCall(double duration) {
|
||||
callCount.fetch_add(1);
|
||||
intervalTime.fetch_add(duration);
|
||||
totalTime.fetch_add(duration);
|
||||
|
||||
if (callCount.load() % LOG_INTERVAL == 0) {
|
||||
double intervalAvg = intervalTime.load() / LOG_INTERVAL;
|
||||
double overallAvg = totalTime.load() / callCount.load();
|
||||
printf("AttackerScoreForState: %d calls, last %d avg: %.1f µs, overall avg: %.1f µs\n",
|
||||
callCount.load(),
|
||||
LOG_INTERVAL,
|
||||
intervalAvg * 1000000.0,
|
||||
overallAvg * 1000000.0);
|
||||
intervalTime.store(0.0); // Reset for next interval
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
std::atomic<int> AttackerScorePerformanceLogger::callCount{0};
|
||||
std::atomic<double> AttackerScorePerformanceLogger::intervalTime{0.0};
|
||||
std::atomic<double> AttackerScorePerformanceLogger::totalTime{0.0};
|
||||
|
||||
// RAII timer for automatic performance logging
|
||||
class AttackerScoreTimer {
|
||||
private:
|
||||
std::chrono::high_resolution_clock::time_point startTime;
|
||||
|
||||
public:
|
||||
AttackerScoreTimer() : startTime(std::chrono::high_resolution_clock::now()) {}
|
||||
|
||||
~AttackerScoreTimer() {
|
||||
auto endTime = std::chrono::high_resolution_clock::now();
|
||||
auto duration =
|
||||
std::chrono::duration_cast<std::chrono::duration<double>>(endTime - startTime);
|
||||
AttackerScorePerformanceLogger::LogCall(duration.count());
|
||||
}
|
||||
};
|
||||
|
||||
// Memoization cache for EffectiveDistance calls
|
||||
struct EffectiveDistanceCache {
|
||||
struct CacheKey {
|
||||
UnitId unitId;
|
||||
Coords target;
|
||||
bool operator==(const CacheKey &other) const {
|
||||
return unitId == other.unitId && target == other.target;
|
||||
}
|
||||
};
|
||||
|
||||
struct CacheKeyHash {
|
||||
size_t operator()(const CacheKey &key) const {
|
||||
return std::hash<UnitId>{}(key.unitId) ^ (std::hash<int>{}(key.target.row()) << 1) ^
|
||||
(std::hash<int>{}(key.target.column()) << 2);
|
||||
}
|
||||
};
|
||||
|
||||
mutable gtl::flat_hash_map<CacheKey, DIST_T, CacheKeyHash> cache;
|
||||
|
||||
DIST_T GetOrCompute(
|
||||
const Unit *unit,
|
||||
const Coords &target,
|
||||
const ActionPointDistances *notBravingApd,
|
||||
const ActionPointDistances *bravingApd,
|
||||
const HexMap *hexMap) const {
|
||||
CacheKey key{unit->unit_id(), target};
|
||||
auto it = cache.find(key);
|
||||
if (it != cache.end()) { return it->second; }
|
||||
|
||||
CoordsSet targetSet(hexMap);
|
||||
targetSet.Add(target);
|
||||
|
||||
DIST_T result = EffectiveDistance(unit, notBravingApd, bravingApd, targetSet);
|
||||
cache[key] = result;
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
#define MULTITHREAD true
|
||||
|
||||
constexpr double UNITS_BASE_MULTIPLIER = 0.05;
|
||||
|
||||
constexpr double FLEE_UNIT_SCORE = -10000;
|
||||
constexpr double FLEE_CONTROLLING_UNIT_SCORE = -10000;
|
||||
constexpr double CAPTURED_UNIT_SCORE = -10000;
|
||||
constexpr double CAPTURED_VIP_SCORE = -25000;
|
||||
|
||||
constexpr double kMaxProximityBuf = 1.5;
|
||||
constexpr double kDistanceDebufRatio = 8.0;
|
||||
|
||||
using std::async;
|
||||
using std::future;
|
||||
|
||||
using flatbuffers::FlatBufferBuilder;
|
||||
using flatbuffers::Offset;
|
||||
|
||||
using net::eagle0::shardok::api::HeroView;
|
||||
using net::eagle0::shardok::api::UnitView;
|
||||
using GameState = fb::GameState;
|
||||
using Unit = fb::Unit;
|
||||
|
||||
static auto RecursiveAttackerMultiplierForTargetDistance(
|
||||
const Unit *attackingUnit,
|
||||
vector<TargetAndAttackLocations>::const_iterator &priorityListNext,
|
||||
const vector<TargetAndAttackLocations>::const_iterator &priorityListEnd,
|
||||
const vector<const Unit *> &occupants,
|
||||
const HexMap *map,
|
||||
const BattalionTypeSPtr &battType,
|
||||
const ActionPointDistances *notBravingApd,
|
||||
const ActionPointDistances *bravingApd,
|
||||
bool isLateGame) -> double;
|
||||
|
||||
static auto RecursiveAttackerMultiplierForTargetDistance(
|
||||
const Unit *attackingUnit,
|
||||
vector<TargetAndAttackLocations>::const_iterator &priorityListNext,
|
||||
const vector<TargetAndAttackLocations>::const_iterator &priorityListEnd,
|
||||
const vector<const Unit *> &occupants,
|
||||
const HexMap *map,
|
||||
const BattalionTypeSPtr &battType,
|
||||
const ActionPointDistances *notBravingApd,
|
||||
const ActionPointDistances *bravingApd,
|
||||
const bool isLateGame) -> double {
|
||||
if (priorityListNext == priorityListEnd) return 1.0;
|
||||
|
||||
const auto &[target, attackLocations] = *priorityListNext;
|
||||
const Coords &topPriorityTarget = target;
|
||||
|
||||
// If the target is unoccupied or is occupied by this player, give the maximum multiplier, but
|
||||
// also add the bonus for the next up in the priority list
|
||||
if (const Unit *occupant = occupants
|
||||
[topPriorityTarget.row() * map->column_count() + topPriorityTarget.column()];
|
||||
!occupant || occupant->player_id() == attackingUnit->player_id()) {
|
||||
return kMaxProximityBuf + RecursiveAttackerMultiplierForTargetDistance(
|
||||
attackingUnit,
|
||||
++priorityListNext,
|
||||
priorityListEnd,
|
||||
occupants,
|
||||
map,
|
||||
battType,
|
||||
notBravingApd,
|
||||
bravingApd,
|
||||
isLateGame);
|
||||
}
|
||||
|
||||
// Use optimized EffectiveDistance with pre-computed ActionPointDistances
|
||||
// attackLocations is already the CoordsSet of attack locations for this target
|
||||
const DIST_T distance =
|
||||
EffectiveDistance(attackingUnit, notBravingApd, bravingApd, attackLocations);
|
||||
|
||||
return kMaxProximityBuf / (1 + distance / kDistanceDebufRatio);
|
||||
}
|
||||
|
||||
// Overload that accepts pre-computed ActionPointDistances
|
||||
auto AttackerMultiplierForTargetDistance(
|
||||
const Unit *attackingUnit,
|
||||
const vector<TargetAndAttackLocations> &priorityList,
|
||||
const vector<const Unit *> &occupants,
|
||||
const HexMap *map,
|
||||
const BattalionTypeSPtr &battType,
|
||||
const ActionPointDistances *notBravingApd,
|
||||
const ActionPointDistances *bravingApd,
|
||||
const bool isLateGame) -> double {
|
||||
auto iter = begin(priorityList);
|
||||
return RecursiveAttackerMultiplierForTargetDistance(
|
||||
attackingUnit,
|
||||
iter,
|
||||
end(priorityList),
|
||||
occupants,
|
||||
map,
|
||||
battType,
|
||||
notBravingApd,
|
||||
bravingApd,
|
||||
isLateGame);
|
||||
}
|
||||
|
||||
auto FleeStrategyScoreForState(const GameStateW &gameState, const PlayerId playerId) -> ScoreValue {
|
||||
ScoreValue scoreValue = 0.0;
|
||||
|
||||
const auto *gameStatePtr = gameState.Get();
|
||||
const auto *units = gameStatePtr->units();
|
||||
|
||||
for (const auto *unit : *units) {
|
||||
if (unit->status() != net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) continue;
|
||||
|
||||
if (unit->player_id() == playerId &&
|
||||
unit->battalion().type() != net::eagle0::shardok::storage::fb::BattalionTypeId_UNDEAD) {
|
||||
scoreValue += FLEE_UNIT_SCORE;
|
||||
|
||||
if (unit->has_attached_hero() &&
|
||||
unit->attached_hero().control_info().controlled_unit_id() != -1) {
|
||||
scoreValue += FLEE_CONTROLLING_UNIT_SCORE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scoreValue;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
/// Standard implementation of AIScoreCalculator that uses the default scoring algorithm.
|
||||
/// Stores specific setting values needed for scoring rather than the entire SettingsGetter.
|
||||
class StandardAIScoreCalculator : public AIScoreCalculator {
|
||||
public:
|
||||
StandardAIScoreCalculator(
|
||||
int maxRounds,
|
||||
ActionPoints braveWaterCost,
|
||||
int meteorRange,
|
||||
double meteorCastVigorCost,
|
||||
int minimumFleeOddsThreshold,
|
||||
int desperateFleeThreshold,
|
||||
std::unordered_map<BattalionTypeId, BattalionTypeSPtr> battalionTypes,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache)
|
||||
: maxRounds_(maxRounds),
|
||||
braveWaterCost_(braveWaterCost),
|
||||
meteorRange_(meteorRange),
|
||||
meteorCastVigorCost_(meteorCastVigorCost),
|
||||
minimumFleeOddsThreshold_(minimumFleeOddsThreshold),
|
||||
desperateFleeThreshold_(desperateFleeThreshold),
|
||||
battalionTypes_(std::move(battalionTypes)),
|
||||
apdCache_(apdCache),
|
||||
alCache_(alCache) {}
|
||||
|
||||
[[nodiscard]] auto GuessedStateScore(
|
||||
bool isDefender,
|
||||
const GameStateW &state,
|
||||
const AIStrategy &aiStrategy,
|
||||
const CoordsSet &allCastleCoords) const -> ScoreValue override;
|
||||
|
||||
private:
|
||||
// Internal accessor methods
|
||||
[[nodiscard]] auto GetBattalionType(BattalionTypeId typeId) const -> BattalionTypeSPtr {
|
||||
auto it = battalionTypes_.find(typeId);
|
||||
if (it == battalionTypes_.end()) {
|
||||
throw ShardokInternalErrorException("Unknown battalion type ID");
|
||||
}
|
||||
return it->second;
|
||||
}
|
||||
[[nodiscard]] auto GetApdCache() const -> const APDCache & { return apdCache_; }
|
||||
[[nodiscard]] auto GetAlCache() const -> const ALCache & { return alCache_; }
|
||||
[[nodiscard]] auto GetBraveWaterCost() const -> ActionPoints { return braveWaterCost_; }
|
||||
[[nodiscard]] auto GetMaxRounds() const -> int { return maxRounds_; }
|
||||
[[nodiscard]] auto GetMeteorRange() const -> int { return meteorRange_; }
|
||||
[[nodiscard]] auto GetMeteorCastVigorCost() const -> double { return meteorCastVigorCost_; }
|
||||
[[nodiscard]] auto GetMinimumFleeOddsThreshold() const -> int {
|
||||
return minimumFleeOddsThreshold_;
|
||||
}
|
||||
[[nodiscard]] auto GetDesperateFleeThreshold() const -> int { return desperateFleeThreshold_; }
|
||||
|
||||
// Implementation methods (converted from internal namespace functions)
|
||||
[[nodiscard]] auto AttackerUnitsScore(
|
||||
const GameStateW &gameState,
|
||||
int roundsRemaining,
|
||||
bool attackerWantsCastles,
|
||||
bool defenderShouldScatter,
|
||||
const vector<TargetPriorityList> &attackerTargetPriorities,
|
||||
const MapId &mapId) const -> ScoreValue;
|
||||
|
||||
[[nodiscard]] auto DefenderScatterStrategyScoreForState(
|
||||
const GameStateW &gameState,
|
||||
int roundsRemaining) const -> ScoreValue;
|
||||
|
||||
[[nodiscard]] auto DefenderHoldCastlesStrategyScoreForState(
|
||||
const GameStateW &gameState,
|
||||
const CoordsSet &castleCoords,
|
||||
int roundsRemaining) const -> ScoreValue;
|
||||
|
||||
[[nodiscard]] auto DefenderScoreForState(
|
||||
const GameStateW &gameState,
|
||||
const AIStrategy &defenderStrategy,
|
||||
const CoordsSet &castleCoords,
|
||||
int roundsRemaining) const -> ScoreValue;
|
||||
|
||||
[[nodiscard]] auto AttackerScoreForState(
|
||||
const GameStateW &gameState,
|
||||
const AIStrategy &attackerStrategy,
|
||||
const CoordsSet &castleCoords,
|
||||
int roundsRemaining) const -> ScoreValue;
|
||||
|
||||
// Scalar settings extracted from SettingsGetter
|
||||
int maxRounds_;
|
||||
ActionPoints braveWaterCost_;
|
||||
int meteorRange_;
|
||||
double meteorCastVigorCost_;
|
||||
int minimumFleeOddsThreshold_;
|
||||
int desperateFleeThreshold_;
|
||||
|
||||
// Battalion type lookup map
|
||||
std::unordered_map<BattalionTypeId, BattalionTypeSPtr> battalionTypes_;
|
||||
|
||||
// Caches (stored as references)
|
||||
const APDCache &apdCache_;
|
||||
const ALCache &alCache_;
|
||||
};
|
||||
|
||||
// Implementation of StandardAIScoreCalculator methods
|
||||
|
||||
auto StandardAIScoreCalculator::AttackerUnitsScore(
|
||||
const GameStateW &gameState,
|
||||
int roundsRemaining,
|
||||
bool attackerWantsCastles,
|
||||
bool defenderShouldScatter,
|
||||
const vector<TargetPriorityList> &attackerTargetPriorities,
|
||||
const MapId &mapId) const -> ScoreValue {
|
||||
// Cache frequently accessed FlatBuffer fields to avoid repeated offset calculations
|
||||
const auto *gameStateRawPtr = gameState.Get();
|
||||
const auto *cachedUnits = gameStateRawPtr->units();
|
||||
const auto *cachedHexMap = gameStateRawPtr->hex_map();
|
||||
|
||||
const int16_t cachedRowCount = cachedHexMap->row_count();
|
||||
const int16_t cachedColumnCount = cachedHexMap->column_count();
|
||||
const int cachedCurrentRound = gameStateRawPtr->current_round();
|
||||
|
||||
bool isLateGame = cachedCurrentRound > 18; // Inline IsLateGame for efficiency
|
||||
|
||||
// APDCache now has built-in thread-local caching - no need for PreCachedAPDs
|
||||
ActionPoints braveWaterCost = GetBraveWaterCost();
|
||||
|
||||
// Memoization cache for EffectiveDistance calls
|
||||
EffectiveDistanceCache distanceCache;
|
||||
|
||||
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);
|
||||
|
||||
for (const Unit *unit : *cachedUnits) {
|
||||
const auto *pi = PlayerInfoForPid(gameState, unit->player_id());
|
||||
if (pi == nullptr) { continue; }
|
||||
|
||||
switch (unit->status()) {
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT: {
|
||||
if (pi->is_defender()) {
|
||||
defenderUnits.push_back(unit);
|
||||
} else {
|
||||
attackerUnits.push_back(unit);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_CAPTURED_UNIT: {
|
||||
double thisScore = unit->has_attached_hero() && unit->attached_hero().is_vip()
|
||||
? CAPTURED_VIP_SCORE
|
||||
: CAPTURED_UNIT_SCORE;
|
||||
if (pi->is_defender()) {
|
||||
defenderUnitsValue += thisScore;
|
||||
} else {
|
||||
attackerUnitsValue += thisScore;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_DESTROYED_SUMMONED_UNIT:
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_FLED_UNIT:
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_NEVER_ENTERED_UNIT:
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_OUTLAWED_UNIT:
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT:
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_RETREATED_UNIT:
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT: break;
|
||||
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_UNKNOWN_UNIT:
|
||||
throw ShardokInternalErrorException("Unknown unit status");
|
||||
}
|
||||
}
|
||||
|
||||
double defenderAdvantage = 1.0 + static_cast<double>(cachedCurrentRound) / 31.0;
|
||||
|
||||
// Can we cache this somehow, it won't usually change within your turn
|
||||
auto attackLocationsForAttacker = GetAlCache()->CachedLocations(defenderUnits, isLateGame);
|
||||
const auto &locationsCausingDanger = attackLocationsForAttacker.AllLocations();
|
||||
|
||||
// Process attacker units using cached ActionPointDistances
|
||||
for (const Unit *unit : attackerUnits) {
|
||||
const int battTypeId = unit->battalion().type();
|
||||
|
||||
const auto &priorityList = std::ranges::find_if(
|
||||
attackerTargetPriorities,
|
||||
[&unit](const TargetPriorityList &tpl) {
|
||||
return tpl.attackingUnitId == unit->unit_id();
|
||||
});
|
||||
|
||||
// If there are any tiles being targeted, give this unit a multiplier based on how close
|
||||
// they are to being able to attack it
|
||||
double distanceMultiplier =
|
||||
priorityList == end(attackerTargetPriorities)
|
||||
? 1.0
|
||||
: AttackerMultiplierForTargetDistance(
|
||||
unit,
|
||||
priorityList->priorityOrder,
|
||||
occupants,
|
||||
cachedHexMap,
|
||||
GetBattalionType(static_cast<BattalionTypeId>(battTypeId)),
|
||||
GetApdCache()->GetRaw(
|
||||
cachedHexMap,
|
||||
mapId,
|
||||
GetBattalionType(
|
||||
static_cast<BattalionTypeId>(battTypeId)),
|
||||
false),
|
||||
GetBattalionType(static_cast<BattalionTypeId>(battTypeId))
|
||||
->allowsBraveWater
|
||||
? GetApdCache()->GetRaw(
|
||||
cachedHexMap,
|
||||
mapId,
|
||||
GetBattalionType(static_cast<BattalionTypeId>(
|
||||
battTypeId)),
|
||||
true,
|
||||
braveWaterCost)
|
||||
: nullptr,
|
||||
isLateGame);
|
||||
|
||||
auto uv = UnitValue(
|
||||
unit,
|
||||
true,
|
||||
attackerUnits,
|
||||
attackerWantsCastles,
|
||||
/* includeCastleBonus=*/true,
|
||||
defenderUnits,
|
||||
cachedHexMap,
|
||||
roundsRemaining,
|
||||
attackLocationsForAttacker,
|
||||
locationsCausingDanger,
|
||||
GetApdCache()->GetRaw(
|
||||
cachedHexMap,
|
||||
mapId,
|
||||
GetBattalionType(static_cast<BattalionTypeId>(battTypeId)),
|
||||
false),
|
||||
GetMeteorRange(),
|
||||
GetMeteorCastVigorCost());
|
||||
|
||||
attackerUnitsValue += distanceMultiplier * uv;
|
||||
}
|
||||
|
||||
auto attackLocationsForDefender = GetAlCache()->CachedLocations(attackerUnits, isLateGame);
|
||||
const auto &locationsCausingDangerForAttacker = attackLocationsForDefender.AllLocations();
|
||||
|
||||
for (const Unit *unit : defenderUnits) {
|
||||
auto defenderUnitId = unit->unit_id();
|
||||
const int battTypeId = unit->battalion().type();
|
||||
|
||||
auto dv = UnitValue(
|
||||
unit,
|
||||
false,
|
||||
attackerUnits,
|
||||
attackerWantsCastles,
|
||||
/* includeCastleBonus=*/!defenderShouldScatter,
|
||||
defenderUnits,
|
||||
cachedHexMap,
|
||||
roundsRemaining,
|
||||
attackLocationsForDefender,
|
||||
locationsCausingDangerForAttacker,
|
||||
GetApdCache()->GetRaw(
|
||||
cachedHexMap,
|
||||
mapId,
|
||||
GetBattalionType(static_cast<BattalionTypeId>(battTypeId)),
|
||||
false),
|
||||
GetMeteorRange(),
|
||||
GetMeteorCastVigorCost());
|
||||
|
||||
double distanceMultiplier = 1.0;
|
||||
|
||||
// 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);
|
||||
myLocationSet.Add(unit->location());
|
||||
|
||||
DIST_T closestDistanceToEnemy = 999;
|
||||
for (const auto &attackerUnit : attackerUnits) {
|
||||
const int attackerBattTypeId = attackerUnit->battalion().type();
|
||||
const DIST_T thisDistance = distanceCache.GetOrCompute(
|
||||
attackerUnit,
|
||||
unit->location(),
|
||||
GetApdCache()->GetRaw(
|
||||
cachedHexMap,
|
||||
mapId,
|
||||
GetBattalionType(static_cast<BattalionTypeId>(attackerBattTypeId)),
|
||||
false),
|
||||
GetBattalionType(static_cast<BattalionTypeId>(attackerBattTypeId))
|
||||
->allowsBraveWater
|
||||
? GetApdCache()->GetRaw(
|
||||
cachedHexMap,
|
||||
mapId,
|
||||
GetBattalionType(
|
||||
static_cast<BattalionTypeId>(attackerBattTypeId)),
|
||||
true,
|
||||
braveWaterCost)
|
||||
: nullptr,
|
||||
cachedHexMap);
|
||||
if (thisDistance < closestDistanceToEnemy) {
|
||||
closestDistanceToEnemy = thisDistance;
|
||||
}
|
||||
}
|
||||
|
||||
// If the best we can do puts us very close to the enemy, and the unit is almost
|
||||
// destroyed, return a negative value; better to flee
|
||||
if (unit->can_flee() && closestDistanceToEnemy < 5 && unit->battalion().size() < 10) {
|
||||
distanceMultiplier = -1;
|
||||
} else {
|
||||
DIST_T closestDistanceToFriendly = 1;
|
||||
if (defenderUnits.size() > 1) {
|
||||
for (const auto &defenderUnit : defenderUnits) {
|
||||
if (defenderUnit->unit_id() != defenderUnitId) {
|
||||
const int defenderBattTypeId = defenderUnit->battalion().type();
|
||||
const DIST_T thisDistance = distanceCache.GetOrCompute(
|
||||
defenderUnit,
|
||||
unit->location(),
|
||||
GetApdCache()->GetRaw(
|
||||
cachedHexMap,
|
||||
mapId,
|
||||
GetBattalionType(static_cast<BattalionTypeId>(
|
||||
defenderBattTypeId)),
|
||||
false),
|
||||
GetBattalionType(
|
||||
static_cast<BattalionTypeId>(defenderBattTypeId))
|
||||
->allowsBraveWater
|
||||
? GetApdCache()->GetRaw(
|
||||
cachedHexMap,
|
||||
mapId,
|
||||
GetBattalionType(static_cast<BattalionTypeId>(
|
||||
defenderBattTypeId)),
|
||||
true,
|
||||
braveWaterCost)
|
||||
: nullptr,
|
||||
cachedHexMap);
|
||||
if (thisDistance < closestDistanceToEnemy) {
|
||||
closestDistanceToFriendly = thisDistance;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
distanceMultiplier =
|
||||
(closestDistanceToEnemy + closestDistanceToFriendly / 5.0) / 5.0;
|
||||
}
|
||||
}
|
||||
|
||||
defenderUnitsValue += distanceMultiplier * dv;
|
||||
}
|
||||
|
||||
defenderUnitsValue *= defenderAdvantage;
|
||||
|
||||
return attackerUnitsValue - defenderUnitsValue;
|
||||
}
|
||||
|
||||
auto StandardAIScoreCalculator::DefenderScatterStrategyScoreForState(
|
||||
const GameStateW &gameState,
|
||||
const int roundsRemaining) const -> ScoreValue {
|
||||
const auto *gameStatePtr = gameState.Get();
|
||||
const auto *status = gameStatePtr->status();
|
||||
|
||||
if (status->state() == net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
|
||||
const auto *winningIds = status->winning_shardok_ids();
|
||||
const auto *playerInfos = gameStatePtr->player_infos();
|
||||
|
||||
for (const PlayerId winningPid : *winningIds) {
|
||||
if (winningPid < 0) continue;
|
||||
if (playerInfos->Get(winningPid)->is_defender()) return INT_MAX;
|
||||
return INT_MIN;
|
||||
}
|
||||
return INT_MAX;
|
||||
}
|
||||
if (status->state() == net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW) { return 0; }
|
||||
|
||||
const auto *hexMap = gameStatePtr->hex_map();
|
||||
|
||||
const auto mapId = ActionPointDistancesCache::GetMapId(hexMap);
|
||||
|
||||
const auto unitsTotal = -AttackerUnitsScore(
|
||||
gameState,
|
||||
roundsRemaining,
|
||||
/* attackerWantsCastles=*/false,
|
||||
/* defenderShouldScatter=*/true,
|
||||
{},
|
||||
mapId);
|
||||
|
||||
return unitsTotal;
|
||||
}
|
||||
|
||||
auto StandardAIScoreCalculator::DefenderHoldCastlesStrategyScoreForState(
|
||||
const GameStateW &gameState,
|
||||
const CoordsSet &castleCoords,
|
||||
const int roundsRemaining) const -> ScoreValue {
|
||||
const auto unitsTotal = -AttackerUnitsScore(
|
||||
gameState,
|
||||
roundsRemaining,
|
||||
/* attackerWantsCastles=*/true,
|
||||
/*defenderShouldScatter=*/false,
|
||||
{},
|
||||
ActionPointDistancesCache::GetMapId(gameState->hex_map()));
|
||||
|
||||
// Check the victory condition types
|
||||
ScoreValue victoryConditionTotal = 0.0;
|
||||
|
||||
const PlayerInfo *defenderPi = nullptr;
|
||||
for (const PlayerInfo *pi : *gameState->player_infos()) {
|
||||
if (pi->is_defender()) defenderPi = pi;
|
||||
}
|
||||
|
||||
victoryConditionTotal +=
|
||||
DefenderHoldsCriticalTilesVictoryScore(gameState, castleCoords, defenderPi);
|
||||
|
||||
const double unitsMultiplier =
|
||||
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
|
||||
|
||||
return UNITS_BASE_MULTIPLIER * unitsMultiplier * unitsTotal + victoryConditionTotal;
|
||||
}
|
||||
|
||||
auto StandardAIScoreCalculator::DefenderScoreForState(
|
||||
const GameStateW &gameState,
|
||||
const AIStrategy &defenderStrategy,
|
||||
const CoordsSet &castleCoords,
|
||||
const int roundsRemaining) const -> ScoreValue {
|
||||
if (gameState->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
|
||||
for (const PlayerId winningPid : *gameState->status()->winning_shardok_ids()) {
|
||||
if (winningPid < 0) continue;
|
||||
if (defenderStrategy.strategyType == AIStrategy::STRATEGY_FLEE) return 0;
|
||||
if (gameState->player_infos()->Get(winningPid)->is_defender()) return INT_MAX;
|
||||
return INT_MIN;
|
||||
}
|
||||
return INT_MIN;
|
||||
}
|
||||
if (gameState->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
switch (defenderStrategy.strategyType) {
|
||||
case AIStrategy::STRATEGY_ATTACK_CASTLES:
|
||||
throw ShardokInternalErrorException("Defender cannot use AttackCastlesStrategy");
|
||||
case AIStrategy::STRATEGY_ATTACK_UNITS:
|
||||
throw ShardokInternalErrorException("Defender cannot use AttackUnitsStrategy");
|
||||
case AIStrategy::STRATEGY_CROSS_RIVERS:
|
||||
throw ShardokInternalErrorException("Defender cannot use CrossRiversStrategy");
|
||||
case AIStrategy::STRATEGY_HOLD_CASTLES:
|
||||
return DefenderHoldCastlesStrategyScoreForState(
|
||||
gameState,
|
||||
castleCoords,
|
||||
roundsRemaining);
|
||||
case AIStrategy::STRATEGY_SCATTER:
|
||||
return DefenderScatterStrategyScoreForState(gameState, roundsRemaining);
|
||||
case AIStrategy::STRATEGY_FLEE:
|
||||
for (const auto *pi : *gameState->player_infos()) {
|
||||
if (pi->is_defender()) {
|
||||
return FleeStrategyScoreForState(gameState, pi->player_id());
|
||||
}
|
||||
}
|
||||
throw ShardokInternalErrorException("Unable to find defender for FleeStrategy");
|
||||
}
|
||||
throw ShardokInternalErrorException("Escaped AIStrategy switch");
|
||||
}
|
||||
|
||||
auto StandardAIScoreCalculator::AttackerScoreForState(
|
||||
const GameStateW &gameState,
|
||||
const AIStrategy &attackerStrategy,
|
||||
const CoordsSet &castleCoords,
|
||||
const int roundsRemaining) const -> ScoreValue {
|
||||
#if PERFORMANCE_LOGGING_
|
||||
AttackerScoreTimer timer;
|
||||
#endif // # PERFORMANCE_LOGGING_
|
||||
if (gameState->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
|
||||
for (const PlayerId winningPid : *gameState->status()->winning_shardok_ids()) {
|
||||
if (winningPid < 0) continue;
|
||||
if (attackerStrategy.strategyType == AIStrategy::STRATEGY_FLEE) return 0;
|
||||
if (gameState->player_infos()->Get(winningPid)->is_defender()) return INT_MIN;
|
||||
return INT_MAX;
|
||||
}
|
||||
return INT_MAX;
|
||||
}
|
||||
if (gameState->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const auto mapId = ActionPointDistancesCache::GetMapId(gameState->hex_map());
|
||||
|
||||
const auto unitsTotal = AttackerUnitsScore(
|
||||
gameState,
|
||||
roundsRemaining,
|
||||
attackerStrategy.strategyType == AIStrategy::STRATEGY_HOLD_CASTLES,
|
||||
/* defenderShouldScatter=*/false,
|
||||
attackerStrategy.targetPriorities,
|
||||
mapId);
|
||||
|
||||
// Check the victory condition types
|
||||
ScoreValue victoryConditionTotal = 0.0;
|
||||
for (const PlayerInfo *pi : *gameState->player_infos()) {
|
||||
if (pi->is_defender()) continue;
|
||||
|
||||
switch (attackerStrategy.strategyType) {
|
||||
case AIStrategy::STRATEGY_CROSS_RIVERS:
|
||||
victoryConditionTotal += WaterCrossingScore(
|
||||
pi->player_id(),
|
||||
[this](BattalionTypeId typeId) { return GetBattalionType(typeId); },
|
||||
gameState,
|
||||
castleCoords,
|
||||
attackerStrategy.targetLocations,
|
||||
GetApdCache());
|
||||
break;
|
||||
|
||||
case AIStrategy::STRATEGY_ATTACK_CASTLES:
|
||||
case AIStrategy::STRATEGY_ATTACK_UNITS:
|
||||
// already factored into AttackerUnitsScore
|
||||
break;
|
||||
|
||||
case AIStrategy::STRATEGY_HOLD_CASTLES:
|
||||
victoryConditionTotal += AttackerHoldsCriticalTilesVictoryScore(
|
||||
gameState,
|
||||
castleCoords,
|
||||
pi,
|
||||
GetApdCache(),
|
||||
GetAlCache(),
|
||||
[this](BattalionTypeId typeId) { return GetBattalionType(typeId); },
|
||||
GetBraveWaterCost());
|
||||
break;
|
||||
|
||||
case AIStrategy::STRATEGY_SCATTER:
|
||||
throw ShardokInternalErrorException("Attacker cannot use ScatterStrategy");
|
||||
|
||||
case AIStrategy::STRATEGY_FLEE:
|
||||
return FleeStrategyScoreForState(gameState, pi->player_id());
|
||||
}
|
||||
}
|
||||
|
||||
const double unitsMultiplier =
|
||||
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
|
||||
|
||||
const double finalScore =
|
||||
UNITS_BASE_MULTIPLIER * unitsMultiplier * unitsTotal + victoryConditionTotal;
|
||||
|
||||
return finalScore;
|
||||
}
|
||||
|
||||
auto StandardAIScoreCalculator::GuessedStateScore(
|
||||
const bool isDefender,
|
||||
const GameStateW &state,
|
||||
const AIStrategy &aiStrategy,
|
||||
const CoordsSet &allCastleCoords) const -> ScoreValue {
|
||||
const int roundsRemaining = GetMaxRounds() - state->current_round();
|
||||
|
||||
if (isDefender) {
|
||||
return DefenderScoreForState(state, aiStrategy, allCastleCoords, roundsRemaining);
|
||||
}
|
||||
return AttackerScoreForState(state, aiStrategy, allCastleCoords, roundsRemaining);
|
||||
}
|
||||
|
||||
// Factory function implementation
|
||||
auto MakeStandardAIScoreCalculator(
|
||||
const SettingsGetter &settingsGetter,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> std::unique_ptr<AIScoreCalculator> {
|
||||
// Extract all battalion types
|
||||
std::unordered_map<BattalionTypeId, BattalionTypeSPtr> battalionTypes;
|
||||
for (int typeId = BattalionTypeId::BattalionTypeId_MIN;
|
||||
typeId <= BattalionTypeId::BattalionTypeId_MAX;
|
||||
typeId++) {
|
||||
auto battalionTypeId = static_cast<BattalionTypeId>(typeId);
|
||||
battalionTypes[battalionTypeId] = settingsGetter.GetBattalionType(battalionTypeId);
|
||||
}
|
||||
|
||||
return std::make_unique<StandardAIScoreCalculator>(
|
||||
settingsGetter.Backing().max_rounds(),
|
||||
settingsGetter.Backing().brave_water_action_point_cost(),
|
||||
settingsGetter.Backing().meteor_range(),
|
||||
settingsGetter.Backing().meteor_cast_vigor_cost(),
|
||||
settingsGetter.Backing().ai_minimum_flee_odds_threshold(),
|
||||
settingsGetter.Backing().ai_desperate_flee_threshold(),
|
||||
std::move(battalionTypes),
|
||||
apdCache,
|
||||
alCache);
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
@@ -7,7 +7,7 @@
|
||||
#include "adapters/ShardokGameEngine.hpp"
|
||||
#include "adapters/ShardokGameState.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AICommandFilter.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
|
||||
@@ -23,9 +23,9 @@ ShardokMCTSAI::ShardokMCTSAI(
|
||||
const ALCache& alCache,
|
||||
MCTSConfig config)
|
||||
: abstractAI_(std::make_unique<mcts::AbstractMCTSAI>(
|
||||
static_cast<mcts::MCTSPlayerId>(playerId),
|
||||
static_cast<mcts::MCTSPlayerId>(
|
||||
playerId), // Use actual player ID for correct scoring
|
||||
config)),
|
||||
playerId_(playerId),
|
||||
isDefender_(isDefender),
|
||||
strategy_(strategy),
|
||||
castleCoords_(castleCoords),
|
||||
@@ -58,12 +58,10 @@ auto ShardokMCTSAI::Search(
|
||||
// Create game engine adapter (passing critical tiles to avoid recomputation)
|
||||
auto gameEngine = mcts::ShardokMCTSFactory::createGameEngine(
|
||||
engine,
|
||||
nullptr, // commandFilter - simplified
|
||||
&scoreCalculator_, // Pass the score calculator
|
||||
settings,
|
||||
apdCache_,
|
||||
alCache_,
|
||||
playerId_,
|
||||
isDefender_,
|
||||
strategy_,
|
||||
castleCoords_,
|
||||
@@ -73,6 +71,11 @@ auto ShardokMCTSAI::Search(
|
||||
const auto timeLimit = budget.remainingBudget;
|
||||
const auto abstractResult = abstractAI_->Search(*gameEngine, *gameState, timeLimit);
|
||||
|
||||
// Report cache statistics for performance analysis
|
||||
if (auto* shardokEngine = dynamic_cast<mcts::ShardokGameEngine*>(gameEngine.get())) {
|
||||
shardokEngine->reportCacheStatistics();
|
||||
}
|
||||
|
||||
// Get unfiltered command count for consistent reporting with IterativeDeepeningAI
|
||||
// (MCTS uses filtered commands internally, but we report unfiltered count for metrics)
|
||||
const auto unfilteredCommands = engine.GetAvailableCommandsForAIPlayer(
|
||||
|
||||
@@ -60,7 +60,6 @@ private:
|
||||
std::unique_ptr<mcts::AbstractMCTSAI> abstractAI_;
|
||||
|
||||
// Shardok-specific context
|
||||
PlayerId playerId_;
|
||||
bool isDefender_;
|
||||
AIStrategy strategy_;
|
||||
const CoordsSet& castleCoords_;
|
||||
|
||||
@@ -27,8 +27,8 @@ cc_library(
|
||||
],
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/common/mcts/abstract:mcts_game_state",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_strategy",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
|
||||
@@ -50,7 +50,8 @@ cc_library(
|
||||
"//src/main/cpp/net/eagle0/common:sequence_random_generator",
|
||||
"//src/main/cpp/net/eagle0/common/mcts/abstract:mcts_game_engine",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_command_filter",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_heuristic_weighting",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
|
||||
@@ -72,7 +73,7 @@ cc_library(
|
||||
":shardok_game_engine",
|
||||
":shardok_game_state",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_command_filter",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
|
||||
|
||||
@@ -4,45 +4,49 @@
|
||||
|
||||
#include "ShardokGameEngine.hpp"
|
||||
|
||||
#include <chrono>
|
||||
|
||||
#include "ShardokAction.hpp"
|
||||
#include "ShardokGameState.hpp"
|
||||
#include "src/main/cpp/net/eagle0/common/mcts/abstract/MCTSTypes.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AICommandFilter.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIHeuristicWeighting.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
|
||||
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
|
||||
#pragma clang diagnostic pop
|
||||
|
||||
namespace shardok::mcts {
|
||||
|
||||
// Static helper for average random generator
|
||||
// Note: Using nullptr for simplicity - could be improved with proper random generator
|
||||
// Thread-local cache for legal actions (avoids lock contention in multithreaded simulation)
|
||||
thread_local gtl::flat_hash_map<uint64_t, ShardokGameEngine::LegalActionsCache>
|
||||
ShardokGameEngine::legalActionsCache_;
|
||||
thread_local uint64_t ShardokGameEngine::cacheHits_ = 0;
|
||||
thread_local uint64_t ShardokGameEngine::cacheMisses_ = 0;
|
||||
thread_local uint64_t ShardokGameEngine::timeInHashComputation_ = 0;
|
||||
thread_local uint64_t ShardokGameEngine::timeInLegalActionsComputation_ = 0;
|
||||
|
||||
ShardokGameEngine::ShardokGameEngine(
|
||||
[[maybe_unused]] const ShardokEngine* engine,
|
||||
const AICommandFilter* commandFilter,
|
||||
const AIScoreCalculator* scoreCalculator,
|
||||
const GameSettingsSPtr& gameSettings,
|
||||
const APDCache* apdCache,
|
||||
const ALCache* alCache,
|
||||
PlayerId playerId,
|
||||
bool isDefender,
|
||||
const AIStrategy& strategy,
|
||||
const CoordsSet& castleCoords,
|
||||
const CoordsSet& criticalTileCoords)
|
||||
: commandFilter_(commandFilter),
|
||||
scoreCalculator_(scoreCalculator),
|
||||
: scoreCalculator_(scoreCalculator),
|
||||
gameSettings_(gameSettings),
|
||||
apdCache_(apdCache),
|
||||
alCache_(alCache),
|
||||
playerId_(playerId),
|
||||
isDefender_(isDefender),
|
||||
strategy_(strategy),
|
||||
castleCoords_(castleCoords),
|
||||
criticalTileCoords_(criticalTileCoords) {}
|
||||
criticalTileCoords_(criticalTileCoords) {
|
||||
// Thread-local cache is automatically initialized per thread
|
||||
// Reserve space to reduce rehashing (based on profiling: ~30-50K unique states per search)
|
||||
legalActionsCache_.reserve(100000);
|
||||
}
|
||||
|
||||
std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
|
||||
const MCTSGameState& state,
|
||||
@@ -79,7 +83,7 @@ std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
|
||||
engine->PostCommand(currentPlayer, shardokAction->getIndex(), nullptr);
|
||||
|
||||
// Create and return the new state (don't cache the mutated engine)
|
||||
return std::make_unique<ShardokGameState>(
|
||||
auto newState = std::make_unique<ShardokGameState>(
|
||||
engine->GetCurrentGameState(),
|
||||
scoreCalculator_,
|
||||
gameSettings_.get(),
|
||||
@@ -89,6 +93,10 @@ std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
|
||||
*apdCache_,
|
||||
*alCache_,
|
||||
criticalTileCoords_);
|
||||
|
||||
// Don't pre-compute hash - let it be computed lazily on first use
|
||||
// Many states (especially in simulation) never need their hash computed
|
||||
return newState;
|
||||
}
|
||||
|
||||
void ShardokGameEngine::applyActionMutable(
|
||||
@@ -125,19 +133,68 @@ void ShardokGameEngine::applyActionMutable(
|
||||
|
||||
engine->PostCommand(currentPlayer, shardokAction->getIndex(), nullptr);
|
||||
shardokState->getMutableShardokState() = engine->GetCurrentGameState();
|
||||
// Clear the cached engine since the state has been mutated
|
||||
// Clear the cached engine and hash since the state has been mutated
|
||||
shardokState->setCachedEngine(nullptr);
|
||||
shardokState->invalidateHashCache();
|
||||
}
|
||||
|
||||
std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
|
||||
const MCTSGameState& state) const {
|
||||
const MCTSGameState& state,
|
||||
MCTSPlayerId /*rootPlayerId*/,
|
||||
int currentPlayerFlips,
|
||||
int maxPlayerFlips) const {
|
||||
const auto* shardokState = dynamic_cast<const ShardokGameState*>(&state);
|
||||
if (!shardokState) { return {}; }
|
||||
|
||||
const auto currentPlayer = static_cast<PlayerId>(state.currentPlayerId());
|
||||
|
||||
// Stop simulation if it's not our player's turn (turn boundary)
|
||||
if (currentPlayer != playerId_) { return {}; }
|
||||
// Check if we've exceeded the maximum allowed player flips
|
||||
// currentPlayerFlips is the number of times the player has changed since root
|
||||
// maxPlayerFlips is the maximum number of changes we allow
|
||||
// If maxPlayerFlips is 0, only explore root player's moves (stop when player first changes)
|
||||
// If maxPlayerFlips is 1, explore through opponent's response (stop after opponent's moves)
|
||||
if (currentPlayerFlips > maxPlayerFlips) {
|
||||
return {}; // Stop exploration - we've exceeded the flip limit
|
||||
}
|
||||
|
||||
// Time hash computation
|
||||
const auto hashStart = std::chrono::high_resolution_clock::now();
|
||||
const uint64_t stateHash = shardokState->hash();
|
||||
const auto hashEnd = std::chrono::high_resolution_clock::now();
|
||||
timeInHashComputation_ +=
|
||||
std::chrono::duration_cast<std::chrono::microseconds>(hashEnd - hashStart).count();
|
||||
|
||||
// Check transposition table for cached legal actions
|
||||
if (auto it = legalActionsCache_.find(stateHash); it != legalActionsCache_.end()) {
|
||||
cacheHits_++;
|
||||
|
||||
// Use cached engine
|
||||
shardokState->setCachedEngine(it->second.engine);
|
||||
|
||||
// Get commands from the cached engine (Engine already caches these internally)
|
||||
const CommandListSPtr commands =
|
||||
it->second.engine->GetAvailableCommandsForAIPlayer(currentPlayer);
|
||||
|
||||
if (!commands || commands->empty()) { return {}; }
|
||||
|
||||
// Convert to MCTSActions using stored filtered indices
|
||||
std::vector<std::unique_ptr<MCTSAction>> actions;
|
||||
actions.reserve(it->second.filteredIndices.size());
|
||||
|
||||
for (const size_t origIdx : it->second.filteredIndices) {
|
||||
if (origIdx < commands->size()) {
|
||||
const auto& cmd = commands->at(origIdx);
|
||||
actions.push_back(std::make_unique<ShardokAction>(cmd->GetCommandProto(), origIdx));
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
cacheMisses_++;
|
||||
|
||||
// Time legal actions computation
|
||||
const auto actionsStart = std::chrono::high_resolution_clock::now();
|
||||
|
||||
// Use cached engine if available, otherwise create and cache it
|
||||
std::shared_ptr<ShardokEngine> engine;
|
||||
@@ -178,6 +235,19 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
|
||||
}
|
||||
}
|
||||
|
||||
const auto actionsEnd = std::chrono::high_resolution_clock::now();
|
||||
timeInLegalActionsComputation_ +=
|
||||
std::chrono::duration_cast<std::chrono::microseconds>(actionsEnd - actionsStart)
|
||||
.count();
|
||||
|
||||
// Store in transposition table for future lookups
|
||||
// Note: We only store filtered indices and the engine (which caches commands internally)
|
||||
// This avoids duplicating heavy protocol buffer objects
|
||||
LegalActionsCache entry;
|
||||
entry.filteredIndices = filteredIndices;
|
||||
entry.engine = engine;
|
||||
legalActionsCache_[stateHash] = std::move(entry);
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
@@ -189,24 +259,51 @@ double ShardokGameEngine::evaluateState(const MCTSGameState& state, MCTSPlayerId
|
||||
|
||||
std::vector<size_t> ShardokGameEngine::filterActions(
|
||||
const std::vector<std::unique_ptr<MCTSAction>>& actions,
|
||||
const MCTSGameState& state) const {
|
||||
const auto* shardokState = dynamic_cast<const ShardokGameState*>(&state);
|
||||
if (!shardokState || !commandFilter_) {
|
||||
// No filtering - return all indices
|
||||
std::vector<size_t> indices;
|
||||
indices.reserve(actions.size());
|
||||
for (size_t i = 0; i < actions.size(); ++i) { indices.push_back(i); }
|
||||
return indices;
|
||||
}
|
||||
|
||||
// For now, return all indices - proper filtering would need more work
|
||||
// to match the AICommandFilter::FilterCommands signature
|
||||
const MCTSGameState& /*state*/) const {
|
||||
// All filtering is already done in getLegalActions() using AICommandFilter
|
||||
// This method is used by simulation policies and doesn't need additional filtering
|
||||
std::vector<size_t> indices;
|
||||
indices.reserve(actions.size());
|
||||
for (size_t i = 0; i < actions.size(); ++i) { indices.push_back(i); }
|
||||
return indices;
|
||||
}
|
||||
|
||||
std::vector<double> ShardokGameEngine::getActionWeights(
|
||||
const std::vector<std::unique_ptr<MCTSAction>>& actions,
|
||||
const MCTSGameState& state) const {
|
||||
// Cast to ShardokGameState to access Shardok-specific methods
|
||||
const auto* shardokState = dynamic_cast<const ShardokGameState*>(&state);
|
||||
if (!shardokState) {
|
||||
throw MCTSInternalError(
|
||||
"ShardokGameEngine::getActionWeights called with non-Shardok state - this "
|
||||
"indicates a type mismatch in the MCTS adapter layer");
|
||||
}
|
||||
|
||||
// Use AIHeuristicWeighting for fast O(1) context-aware command weighting
|
||||
std::vector<double> weights;
|
||||
weights.reserve(actions.size());
|
||||
|
||||
for (const auto& action : actions) {
|
||||
const auto* shardokAction = dynamic_cast<const ShardokAction*>(action.get());
|
||||
if (!shardokAction) {
|
||||
throw MCTSInternalError(
|
||||
"ShardokGameEngine::getActionWeights encountered non-Shardok action - this "
|
||||
"indicates a type mismatch in the MCTS adapter layer");
|
||||
}
|
||||
weights.push_back(AIHeuristicWeighting::GetCommandWeight(
|
||||
shardokAction->getCommand(),
|
||||
shardokState->getShardokState(),
|
||||
castleCoords_,
|
||||
apdCache_,
|
||||
isDefender_,
|
||||
[this](BattalionTypeId typeId) {
|
||||
return gameSettings_->GetGetter().GetBattalionType(typeId);
|
||||
}));
|
||||
}
|
||||
|
||||
return weights;
|
||||
}
|
||||
|
||||
double ShardokGameEngine::getActionScore(
|
||||
const MCTSGameState& state,
|
||||
const MCTSAction& action,
|
||||
@@ -228,7 +325,7 @@ size_t ShardokGameEngine::mapFilteredIndexToOriginal(
|
||||
size_t filteredIndex,
|
||||
const MCTSGameState& state) const {
|
||||
// Get the filtered actions (uses cached engine)
|
||||
auto actions = getLegalActions(state);
|
||||
auto actions = getLegalActions(state, state.currentPlayerId(), 0, 0);
|
||||
|
||||
// Check bounds
|
||||
if (filteredIndex >= actions.size()) { return filteredIndex; }
|
||||
@@ -241,4 +338,46 @@ size_t ShardokGameEngine::mapFilteredIndexToOriginal(
|
||||
return shardokAction->getIndex();
|
||||
}
|
||||
|
||||
void ShardokGameEngine::reportCacheStatistics() const {
|
||||
const uint64_t totalLookups = cacheHits_ + cacheMisses_;
|
||||
if (totalLookups > 0) {
|
||||
const double hitRate = static_cast<double>(cacheHits_) / static_cast<double>(totalLookups);
|
||||
const double avgHashTimeUs =
|
||||
static_cast<double>(timeInHashComputation_) / static_cast<double>(totalLookups);
|
||||
const double avgActionsTimeUs =
|
||||
cacheMisses_ > 0 ? static_cast<double>(timeInLegalActionsComputation_) /
|
||||
static_cast<double>(cacheMisses_)
|
||||
: 0.0;
|
||||
|
||||
printf("Legal Actions Cache Stats:\n");
|
||||
printf(" Lookups: %llu hits, %llu misses, %.1f%% hit rate, %zu entries\n",
|
||||
static_cast<unsigned long long>(cacheHits_),
|
||||
static_cast<unsigned long long>(cacheMisses_),
|
||||
hitRate * 100.0,
|
||||
legalActionsCache_.size());
|
||||
printf(" Timing: %.2f us avg hash, %.2f us avg actions (on miss)\n",
|
||||
avgHashTimeUs,
|
||||
avgActionsTimeUs);
|
||||
printf(" Total time: %.2f ms in hash, %.2f ms in actions\n",
|
||||
timeInHashComputation_ / 1000.0,
|
||||
timeInLegalActionsComputation_ / 1000.0);
|
||||
|
||||
// Calculate if transposition table is worth it
|
||||
const double timeWithCache = timeInHashComputation_ + timeInLegalActionsComputation_;
|
||||
const double timeWithoutCache =
|
||||
avgActionsTimeUs * static_cast<double>(totalLookups); // All lookups recompute
|
||||
const double savings = (timeWithoutCache - timeWithCache) / timeWithoutCache * 100.0;
|
||||
printf(" Cache savings: %.1f%% vs. no cache (%.2f ms saved)\n",
|
||||
savings,
|
||||
(timeWithoutCache - timeWithCache) / 1000.0);
|
||||
}
|
||||
}
|
||||
|
||||
void ShardokGameEngine::resetCacheStatistics() {
|
||||
cacheHits_ = 0;
|
||||
cacheMisses_ = 0;
|
||||
timeInHashComputation_ = 0;
|
||||
timeInLegalActionsComputation_ = 0;
|
||||
}
|
||||
|
||||
} // namespace shardok::mcts
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
#ifndef EAGLE0_SHARDOK_GAME_ENGINE_HPP
|
||||
#define EAGLE0_SHARDOK_GAME_ENGINE_HPP
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <gtl/phmap.hpp>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
@@ -34,12 +36,10 @@ class ShardokGameEngine : public MCTSGameEngine {
|
||||
public:
|
||||
ShardokGameEngine(
|
||||
const ShardokEngine* engine,
|
||||
const AICommandFilter* commandFilter,
|
||||
const AIScoreCalculator* scoreCalculator,
|
||||
const GameSettingsSPtr& gameSettings,
|
||||
const APDCache* apdCache,
|
||||
const ALCache* alCache,
|
||||
PlayerId playerId,
|
||||
bool isDefender,
|
||||
const AIStrategy& strategy,
|
||||
const CoordsSet& castleCoords,
|
||||
@@ -54,7 +54,10 @@ public:
|
||||
const override;
|
||||
|
||||
[[nodiscard]] std::vector<std::unique_ptr<MCTSAction>> getLegalActions(
|
||||
const MCTSGameState& state) const override;
|
||||
const MCTSGameState& state,
|
||||
MCTSPlayerId rootPlayerId,
|
||||
int currentPlayerFlips,
|
||||
int maxPlayerFlips) const override;
|
||||
|
||||
[[nodiscard]] bool isTerminal(const MCTSGameState& state) const override;
|
||||
|
||||
@@ -65,6 +68,10 @@ public:
|
||||
const std::vector<std::unique_ptr<MCTSAction>>& actions,
|
||||
const MCTSGameState& state) const override;
|
||||
|
||||
[[nodiscard]] std::vector<double> getActionWeights(
|
||||
const std::vector<std::unique_ptr<MCTSAction>>& actions,
|
||||
const MCTSGameState& state) const override;
|
||||
|
||||
[[nodiscard]] double getActionScore(
|
||||
const MCTSGameState& state,
|
||||
const MCTSAction& action,
|
||||
@@ -79,18 +86,39 @@ public:
|
||||
size_t filteredIndex,
|
||||
const MCTSGameState& state) const override;
|
||||
|
||||
// Report transposition table statistics
|
||||
void reportCacheStatistics() const;
|
||||
|
||||
// Reset cache statistics
|
||||
void resetCacheStatistics();
|
||||
|
||||
private:
|
||||
const AICommandFilter* commandFilter_;
|
||||
// Transposition table entry for caching legal actions
|
||||
// Note: We don't store command protos since the Engine already caches them
|
||||
struct LegalActionsCache {
|
||||
std::vector<size_t> filteredIndices;
|
||||
std::shared_ptr<ShardokEngine> engine; // Engine with populated command cache
|
||||
};
|
||||
|
||||
const AIScoreCalculator* scoreCalculator_;
|
||||
GameSettingsSPtr gameSettings_;
|
||||
const GameSettingsSPtr gameSettings_;
|
||||
const APDCache* apdCache_;
|
||||
const ALCache* alCache_;
|
||||
PlayerId playerId_;
|
||||
bool isDefender_;
|
||||
AIStrategy strategy_;
|
||||
const CoordsSet& castleCoords_;
|
||||
// Computed once to avoid 8.5% overhead per engine construction
|
||||
const CoordsSet& criticalTileCoords_;
|
||||
|
||||
// Transposition table for legal actions (thread-local to avoid lock contention)
|
||||
// Each thread maintains its own cache during multithreaded simulation
|
||||
static thread_local gtl::flat_hash_map<uint64_t, LegalActionsCache> legalActionsCache_;
|
||||
static thread_local uint64_t cacheHits_;
|
||||
static thread_local uint64_t cacheMisses_;
|
||||
|
||||
// Performance timing (in microseconds)
|
||||
static thread_local uint64_t timeInHashComputation_;
|
||||
static thread_local uint64_t timeInLegalActionsComputation_;
|
||||
};
|
||||
|
||||
} // namespace mcts
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
|
||||
namespace shardok::mcts {
|
||||
@@ -42,6 +42,8 @@ uint64_t ShardokGameState::hash() const {
|
||||
}
|
||||
|
||||
double ShardokGameState::score(MCTSPlayerId /*playerId*/) const {
|
||||
// Always return score from the perspective of the AI that created this state (isDefender_).
|
||||
// The playerId parameter is ignored - adversarial logic happens in selection, not scoring.
|
||||
return scoreCalculator_->GuessedStateScore(isDefender_, state_, strategy_, castleCoords_);
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,12 @@ public:
|
||||
void setCachedEngine(std::shared_ptr<ShardokEngine> engine) const { cachedEngine_ = engine; }
|
||||
[[nodiscard]] std::shared_ptr<ShardokEngine> getCachedEngine() const { return cachedEngine_; }
|
||||
|
||||
// Invalidate hash cache when state is mutated
|
||||
void invalidateHashCache() const {
|
||||
hashCached_ = false;
|
||||
cachedHash_ = 0;
|
||||
}
|
||||
|
||||
private:
|
||||
GameStateW state_;
|
||||
const AIScoreCalculator* scoreCalculator_;
|
||||
|
||||
@@ -14,24 +14,20 @@ namespace shardok::mcts {
|
||||
|
||||
std::unique_ptr<MCTSGameEngine> ShardokMCTSFactory::createGameEngine(
|
||||
const ShardokEngine& engine,
|
||||
const AICommandFilter* commandFilter,
|
||||
const AIScoreCalculator* scoreCalculator,
|
||||
const GameSettingsSPtr& gameSettings,
|
||||
const APDCache& apdCache,
|
||||
const ALCache& alCache,
|
||||
PlayerId playerId,
|
||||
bool isDefender,
|
||||
const AIStrategy& strategy,
|
||||
const CoordsSet& castleCoords,
|
||||
const CoordsSet& criticalTileCoords) {
|
||||
return std::make_unique<ShardokGameEngine>(
|
||||
&engine,
|
||||
commandFilter,
|
||||
scoreCalculator,
|
||||
gameSettings,
|
||||
&apdCache,
|
||||
&alCache,
|
||||
playerId,
|
||||
isDefender,
|
||||
strategy,
|
||||
castleCoords,
|
||||
|
||||
@@ -49,12 +49,10 @@ public:
|
||||
// Create a Shardok game engine adapter
|
||||
[[nodiscard]] static std::unique_ptr<MCTSGameEngine> createGameEngine(
|
||||
const ShardokEngine& engine,
|
||||
const AICommandFilter* commandFilter,
|
||||
const AIScoreCalculator* scoreCalculator,
|
||||
const GameSettingsSPtr& gameSettings,
|
||||
const APDCache& apdCache,
|
||||
const ALCache& alCache,
|
||||
PlayerId playerId,
|
||||
bool isDefender,
|
||||
const AIStrategy& strategy,
|
||||
const CoordsSet& castleCoords,
|
||||
|
||||
+2
-2
@@ -7,9 +7,9 @@
|
||||
#include <algorithm>
|
||||
#include <ranges>
|
||||
|
||||
#include "AIAttackLocations.hpp"
|
||||
#include "AIDistanceDebuf.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackGroups.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIDistanceDebuf.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/victory_condition.hpp"
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
load("//tools:copts.bzl", "COPTS")
|
||||
|
||||
cc_library(
|
||||
name = "ai_score_calculator_interface",
|
||||
hdrs = ["AIScoreCalculator.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_attack_locations",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/map:coords_set",
|
||||
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "ai_victory_condition_score_calculator",
|
||||
srcs = ["AIVictoryConditionScoreCalculator.cpp"],
|
||||
hdrs = ["AIVictoryConditionScoreCalculator.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score/private:__pkg__", # Needed by abstract base class
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_attack_groups",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_attack_locations",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_common_types",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_distance_debuf",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai: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",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "normalized_ai_score_calculator",
|
||||
srcs = ["NormalizedAIScoreCalculator.cpp"],
|
||||
hdrs = ["NormalizedAIScoreCalculator.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":ai_score_calculator_interface",
|
||||
":ai_victory_condition_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_strategy",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_unit_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_water_crossing_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score/private:abstract_ai_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score/private:ai_score_calculator_shared_utilities",
|
||||
"//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/map:coords_set",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "standard_ai_score_calculator",
|
||||
srcs = ["StandardAIScoreCalculator.cpp"],
|
||||
hdrs = ["StandardAIScoreCalculator.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":ai_score_calculator_interface",
|
||||
":ai_victory_condition_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_strategy",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_unit_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_water_crossing_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score/private:abstract_ai_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "mcts_optimized_ai_score_calculator",
|
||||
srcs = ["MCTSOptimizedAIScoreCalculator.cpp"],
|
||||
hdrs = ["MCTSOptimizedAIScoreCalculator.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":ai_score_calculator_interface",
|
||||
":ai_victory_condition_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_strategy",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_unit_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_water_crossing_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score/private:abstract_ai_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score/private:ai_score_calculator_shared_utilities",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,243 @@
|
||||
//
|
||||
// MCTS-Optimized implementation of AIScoreCalculator
|
||||
// Uses bounded linear scoring tuned for MCTS exploration/exploitation balance
|
||||
//
|
||||
|
||||
#include "MCTSOptimizedAIScoreCalculator.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "private/AIScoreCalculatorSharedUtilities.hpp"
|
||||
#include "private/AbstractAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
using net::eagle0::shardok::storage::fb::BattalionTypeId;
|
||||
|
||||
// Bring shared utilities into scope
|
||||
using score_calculator_internal::FleeStrategyScoreForState;
|
||||
using score_calculator_internal::UnitsScoreComponents;
|
||||
|
||||
// Scoring constants tuned for MCTS
|
||||
namespace {
|
||||
|
||||
// Scale constants designed to produce score differences in the range that works well for MCTS
|
||||
// With C=1.41 and typical parent visits ~10000, exploration term ≈ 0.42
|
||||
// We want:
|
||||
// - Early game tactical moves: 0.3-0.5 difference (ratio 0.7-1.2x exploration)
|
||||
// - Mid game advantages (10-30%): 4.0-8.0 difference (ratio 9-19x exploration)
|
||||
// - Late game crushing advantages: 10-40 difference (ratio 24-95x exploration)
|
||||
|
||||
// Maximum contribution from proportional unit advantage (applies to both battle sizes)
|
||||
// A 100% unit advantage (all attacker, no defender) produces ±80 score
|
||||
constexpr double UNITS_SCORE_SCALE = 80.0;
|
||||
|
||||
// Normalizer for victory condition scores (also proportional to army size)
|
||||
// Typical victory condition range: -3000 to 0 (raw), becomes approximately -30 to 0 (normalized)
|
||||
constexpr double VICTORY_SCORE_SCALE = 400.0;
|
||||
|
||||
// Minimum reference value to avoid division by zero in edge cases
|
||||
constexpr double MIN_REFERENCE_VALUE = 1000.0;
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
/// MCTS-Optimized implementation of AIScoreCalculator.
|
||||
/// Produces bounded linear scores that balance MCTS exploration and exploitation.
|
||||
/// Inherits from AbstractAIScoreCalculator to share common functionality.
|
||||
class MCTSOptimizedAIScoreCalculator : public AbstractAIScoreCalculator {
|
||||
public:
|
||||
MCTSOptimizedAIScoreCalculator(
|
||||
int maxRounds,
|
||||
ActionPoints braveWaterCost,
|
||||
int meteorRange,
|
||||
double meteorCastVigorCost,
|
||||
int minimumFleeOddsThreshold,
|
||||
int desperateFleeThreshold,
|
||||
std::vector<BattalionTypeSPtr> battalionTypes,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache)
|
||||
: AbstractAIScoreCalculator(
|
||||
maxRounds,
|
||||
braveWaterCost,
|
||||
meteorRange,
|
||||
meteorCastVigorCost,
|
||||
minimumFleeOddsThreshold,
|
||||
desperateFleeThreshold,
|
||||
std::move(battalionTypes),
|
||||
apdCache,
|
||||
alCache) {}
|
||||
|
||||
[[nodiscard]] auto GuessedStateScore(
|
||||
bool isDefender,
|
||||
const GameStateW &state,
|
||||
const AIStrategy &aiStrategy,
|
||||
const CoordsSet &allCastleCoords) const -> ScoreValue override;
|
||||
|
||||
// Implement pure virtual methods from AbstractAIScoreCalculator
|
||||
[[nodiscard]] auto InterpretDefenderOutcome(GameOutcome outcome) const -> ScoreValue override;
|
||||
[[nodiscard]] auto InterpretAttackerOutcome(GameOutcome outcome) const -> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto AttackerFleeStrategyScoreForState(const GameStateW &gameState) const
|
||||
-> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto CombineAttackerScores(
|
||||
const UnitsScoreComponents &components,
|
||||
double victoryConditionScore,
|
||||
int roundsRemaining) const -> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto CombineDefenderScatterScores(const UnitsScoreComponents &components) const
|
||||
-> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto CombineDefenderHoldCastlesScores(
|
||||
const UnitsScoreComponents &components,
|
||||
double victoryConditionScore,
|
||||
int roundsRemaining) const -> ScoreValue override;
|
||||
|
||||
private:
|
||||
[[nodiscard]] auto DefenderFleeStrategyScoreForState(const GameStateW &gameState) const
|
||||
-> ScoreValue override;
|
||||
};
|
||||
|
||||
// Implementation of MCTSOptimizedAIScoreCalculator methods
|
||||
|
||||
auto MCTSOptimizedAIScoreCalculator::InterpretDefenderOutcome(GameOutcome outcome) const
|
||||
-> ScoreValue {
|
||||
// Use bounded values instead of INT_MAX/MIN for numerical stability
|
||||
switch (outcome) {
|
||||
case GameOutcome::DEFENDER_VICTORY: return 1000.0;
|
||||
case GameOutcome::ATTACKER_VICTORY: return -1000.0;
|
||||
case GameOutcome::DRAW: return 0.0;
|
||||
case GameOutcome::FLEE_OUTCOME: return 0.0;
|
||||
}
|
||||
throw ShardokInternalErrorException("Unknown GameOutcome");
|
||||
}
|
||||
|
||||
auto MCTSOptimizedAIScoreCalculator::InterpretAttackerOutcome(GameOutcome outcome) const
|
||||
-> ScoreValue {
|
||||
// Use bounded values instead of INT_MAX/MIN for numerical stability
|
||||
switch (outcome) {
|
||||
case GameOutcome::ATTACKER_VICTORY: return 1000.0;
|
||||
case GameOutcome::DEFENDER_VICTORY: return -1000.0;
|
||||
case GameOutcome::DRAW: return 0.0;
|
||||
case GameOutcome::FLEE_OUTCOME: return 0.0;
|
||||
}
|
||||
throw ShardokInternalErrorException("Unknown GameOutcome");
|
||||
}
|
||||
|
||||
auto MCTSOptimizedAIScoreCalculator::CombineAttackerScores(
|
||||
const UnitsScoreComponents &components,
|
||||
const double victoryConditionScore,
|
||||
const int roundsRemaining) const -> ScoreValue {
|
||||
// Use actual total army value as reference (scales with battle size)
|
||||
const double totalArmyValue = components.attackerUnitsValue + components.defenderUnitsValue;
|
||||
const double reference = std::max(totalArmyValue, MIN_REFERENCE_VALUE);
|
||||
|
||||
// Normalize proportional unit difference to approximately [-80, +80] range
|
||||
const double unitsDiff = components.attackerUnitsValue - components.defenderUnitsValue;
|
||||
const double unitsScore = (unitsDiff / reference) * UNITS_SCORE_SCALE;
|
||||
|
||||
// Normalize victory condition (also proportional to army size) to approximately [-40, 0] range
|
||||
const double victoryScore = (victoryConditionScore / reference) * VICTORY_SCORE_SCALE;
|
||||
|
||||
// Weight units by rounds remaining (early: units matter less, late: units dominate)
|
||||
const double unitsMultiplier =
|
||||
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
|
||||
|
||||
return unitsMultiplier * unitsScore + victoryScore;
|
||||
}
|
||||
|
||||
auto MCTSOptimizedAIScoreCalculator::CombineDefenderScatterScores(
|
||||
const UnitsScoreComponents &components) const -> ScoreValue {
|
||||
// Use actual total army value as reference
|
||||
const double totalArmyValue = components.attackerUnitsValue + components.defenderUnitsValue;
|
||||
const double reference = std::max(totalArmyValue, MIN_REFERENCE_VALUE);
|
||||
|
||||
// For scatter strategy, just maximize proportional defender advantage
|
||||
const double unitsDiff = components.defenderUnitsValue - components.attackerUnitsValue;
|
||||
return (unitsDiff / reference) * UNITS_SCORE_SCALE;
|
||||
}
|
||||
|
||||
auto MCTSOptimizedAIScoreCalculator::CombineDefenderHoldCastlesScores(
|
||||
const UnitsScoreComponents &components,
|
||||
const double victoryConditionScore,
|
||||
const int roundsRemaining) const -> ScoreValue {
|
||||
// Use actual total army value as reference
|
||||
const double totalArmyValue = components.attackerUnitsValue + components.defenderUnitsValue;
|
||||
const double reference = std::max(totalArmyValue, MIN_REFERENCE_VALUE);
|
||||
|
||||
// Similar to attacker, but from defender's perspective
|
||||
const double unitsDiff = components.defenderUnitsValue - components.attackerUnitsValue;
|
||||
const double unitsScore = (unitsDiff / reference) * UNITS_SCORE_SCALE;
|
||||
const double victoryScore = (victoryConditionScore / reference) * VICTORY_SCORE_SCALE;
|
||||
|
||||
const double unitsMultiplier =
|
||||
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
|
||||
|
||||
return unitsMultiplier * unitsScore + victoryScore;
|
||||
}
|
||||
|
||||
auto MCTSOptimizedAIScoreCalculator::DefenderFleeStrategyScoreForState(
|
||||
const GameStateW &gameState) const -> ScoreValue {
|
||||
for (const auto *pi : *gameState->player_infos()) {
|
||||
if (pi->is_defender()) { return FleeStrategyScoreForState(gameState, pi->player_id()); }
|
||||
}
|
||||
throw ShardokInternalErrorException("Unable to find defender for FleeStrategy");
|
||||
}
|
||||
|
||||
auto MCTSOptimizedAIScoreCalculator::AttackerFleeStrategyScoreForState(
|
||||
const GameStateW &gameState) const -> ScoreValue {
|
||||
for (const PlayerInfo *pi : *gameState->player_infos()) {
|
||||
if (!pi->is_defender()) { return FleeStrategyScoreForState(gameState, pi->player_id()); }
|
||||
}
|
||||
throw ShardokInternalErrorException("Unable to find attacker for FleeStrategy");
|
||||
}
|
||||
|
||||
auto MCTSOptimizedAIScoreCalculator::GuessedStateScore(
|
||||
const bool isDefender,
|
||||
const GameStateW &state,
|
||||
const AIStrategy &aiStrategy,
|
||||
const CoordsSet &allCastleCoords) const -> ScoreValue {
|
||||
const int roundsRemaining = GetMaxRounds() - state->current_round();
|
||||
|
||||
if (isDefender) {
|
||||
return DefenderScoreForState(state, aiStrategy, allCastleCoords, roundsRemaining);
|
||||
}
|
||||
return AttackerScoreForState(state, aiStrategy, allCastleCoords, roundsRemaining);
|
||||
}
|
||||
|
||||
// Factory function implementation
|
||||
auto MakeMCTSOptimizedAIScoreCalculator(
|
||||
const SettingsGetter &settingsGetter,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> std::unique_ptr<AIScoreCalculator> {
|
||||
// Extract all battalion types into a vector indexed by BattalionTypeId
|
||||
std::vector<BattalionTypeSPtr> battalionTypes(BattalionTypeId::BattalionTypeId_MAX + 1);
|
||||
for (int typeId = BattalionTypeId::BattalionTypeId_MIN;
|
||||
typeId <= BattalionTypeId::BattalionTypeId_MAX;
|
||||
typeId++) {
|
||||
auto battalionTypeId = static_cast<BattalionTypeId>(typeId);
|
||||
battalionTypes[battalionTypeId] = settingsGetter.GetBattalionType(battalionTypeId);
|
||||
}
|
||||
|
||||
return std::make_unique<MCTSOptimizedAIScoreCalculator>(
|
||||
settingsGetter.Backing().max_rounds(),
|
||||
settingsGetter.Backing().brave_water_action_point_cost(),
|
||||
settingsGetter.Backing().meteor_range(),
|
||||
settingsGetter.Backing().meteor_cast_vigor_cost(),
|
||||
settingsGetter.Backing().ai_minimum_flee_odds_threshold(),
|
||||
settingsGetter.Backing().ai_desperate_flee_threshold(),
|
||||
std::move(battalionTypes),
|
||||
apdCache,
|
||||
alCache);
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// MCTS-Optimized implementation of AIScoreCalculator
|
||||
// Uses bounded linear scoring tuned for MCTS exploration/exploitation balance
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_MCTSOPTIMIZEDAISCORECALCULATOR_HPP
|
||||
#define EAGLE0_MCTSOPTIMIZEDAISCORECALCULATOR_HPP
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
// Forward declarations
|
||||
class AIScoreCalculator;
|
||||
|
||||
using APDCache = std::shared_ptr<ActionPointDistancesCache>;
|
||||
using ALCache = std::unique_ptr<AttackLocationsCache>;
|
||||
|
||||
/// Factory function to create an MCTSOptimizedAIScoreCalculator.
|
||||
/// Returns a unique_ptr to AIScoreCalculator to hide the implementation.
|
||||
[[nodiscard]] auto MakeMCTSOptimizedAIScoreCalculator(
|
||||
const SettingsGetter& settingsGetter,
|
||||
const APDCache& apdCache,
|
||||
const ALCache& alCache) -> std::unique_ptr<AIScoreCalculator>;
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_MCTSOPTIMIZEDAISCORECALCULATOR_HPP
|
||||
@@ -0,0 +1,388 @@
|
||||
# MCTS-Optimized Scoring Algorithm Design
|
||||
|
||||
## Problem Statement
|
||||
|
||||
We need a scoring algorithm that makes MCTS perform well by providing score differences in the right range:
|
||||
|
||||
- **Standard Scorer**: Returns unbounded relative scores. Small differences get amplified in MCTS UCB formula, causing over-exploitation (commits to 1-2 high-scoring nodes too early).
|
||||
- **Normalized Scorer**: Returns scores in [0,1] range with power transformation (exponent=0.1). Differences are too compressed (~0.02-0.05), causing over-exploration (all nodes explored equally, AI makes bad choices).
|
||||
|
||||
### MCTS Requirements
|
||||
|
||||
After ~100 visits, the **exploitation term** (cumulative_score / visits) should be comparable to the **exploration term** (C * sqrt(ln(parent_visits) / visits)).
|
||||
|
||||
With C=1.41 and typical parent visits ~10000:
|
||||
- Exploration term: 1.41 * sqrt(ln(10000) / 100) ≈ 0.42
|
||||
- **Target exploitation differences: 0.5 to 2.0**
|
||||
|
||||
This means individual scores should differ by **0.5 to 2.0** between meaningfully different positions.
|
||||
|
||||
## Scale Analysis from Codebase
|
||||
|
||||
### Unit Values
|
||||
- Single unit context-free value: 500-3000 (depends on battalion type, stats, size)
|
||||
- With modifiers (castle, terrain, ranged): 1000-6000 per unit
|
||||
- Full army (10 units max): 10,000-40,000
|
||||
- Typical strong army: ~20,000
|
||||
|
||||
### Victory Condition Scores
|
||||
- Castle held by defender: -(battalion_size + vigor) * distance_debuf ≈ -800 per castle
|
||||
- Distance debuf: 0.0 (adjacent) to 1.0 (unreachable), typically 0.9-0.95
|
||||
- 3 castles held by defender at medium distance: ≈ -2400
|
||||
- Range: 0 (all captured) to -3000 (all held, far away)
|
||||
|
||||
### Terminal States
|
||||
- Victory: INT_MAX (or 1.0 for normalized)
|
||||
- Defeat: INT_MIN (or 0.0 for normalized)
|
||||
- Flee/Draw: 0 (or 0.5 for normalized)
|
||||
- Captured unit: -10,000
|
||||
- Captured VIP: -25,000
|
||||
|
||||
## Proposed Algorithm: Bounded Linear Scorer
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Normalized scale**: Map scores to approximately [-15, +15] range
|
||||
2. **Separate components**: Units and victory conditions contribute separately
|
||||
3. **Preserve relative importance**: Victory conditions dominate early, units become important as advantage grows
|
||||
4. **Round-based weighting**: Similar to Standard scorer, weight units by rounds remaining
|
||||
|
||||
### Constants
|
||||
|
||||
```cpp
|
||||
constexpr double UNITS_SCORE_SCALE = 80.0; // Max contribution from proportional unit advantage
|
||||
constexpr double VICTORY_SCORE_SCALE = 400.0; // Normalizer for victory conditions (also proportional)
|
||||
constexpr double MIN_REFERENCE_VALUE = 1000.0; // Avoid division by zero in edge cases
|
||||
```
|
||||
|
||||
**Key insights**:
|
||||
1. Both unit scores AND victory condition scores scale proportionally with battle size (victory scores use battalion.size() in their calculation). Therefore, we normalize both by the **actual total army value** rather than a fixed reference.
|
||||
|
||||
2. **MCTS requires stronger signal than minimax**: Minimax (Iterative Deepening) just picks argmax, so even tiny score differences (0.01) work fine. MCTS needs score differences comparable to the exploration term (~0.4-0.5) to guide search effectively. We use 10x larger scale constants to amplify tactical differences like positioning, distance to objectives, and incremental unit advantages.
|
||||
|
||||
### Attacker Score Formula
|
||||
|
||||
```cpp
|
||||
auto CombineAttackerScores(
|
||||
const UnitsScoreComponents &components,
|
||||
double victoryConditionScore,
|
||||
int roundsRemaining) const -> ScoreValue {
|
||||
|
||||
// Use actual total army value as reference (scales with battle size)
|
||||
const double totalArmyValue = components.attackerUnitsValue + components.defenderUnitsValue;
|
||||
const double reference = std::max(totalArmyValue, MIN_REFERENCE_VALUE);
|
||||
|
||||
// Normalize proportional unit difference to [-8, +8] range
|
||||
const double unitsDiff = components.attackerUnitsValue - components.defenderUnitsValue;
|
||||
const double unitsScore = (unitsDiff / reference) * UNITS_SCORE_SCALE;
|
||||
|
||||
// Normalize victory condition (also proportional to army size) to approximately [-10, 0] range
|
||||
const double victoryScore = (victoryConditionScore / reference) * VICTORY_SCORE_SCALE;
|
||||
|
||||
// Weight units by rounds remaining (early: units matter less, late: units dominate)
|
||||
const double unitsMultiplier =
|
||||
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
|
||||
|
||||
return unitsMultiplier * unitsScore + victoryScore;
|
||||
}
|
||||
```
|
||||
|
||||
### Defender Score Formula
|
||||
|
||||
```cpp
|
||||
auto CombineDefenderScatterScores(
|
||||
const UnitsScoreComponents &components) const -> ScoreValue {
|
||||
|
||||
// Use actual total army value as reference
|
||||
const double totalArmyValue = components.attackerUnitsValue + components.defenderUnitsValue;
|
||||
const double reference = std::max(totalArmyValue, MIN_REFERENCE_VALUE);
|
||||
|
||||
// For scatter strategy, just maximize proportional defender advantage
|
||||
const double unitsDiff = components.defenderUnitsValue - components.attackerUnitsValue;
|
||||
return (unitsDiff / reference) * UNITS_SCORE_SCALE;
|
||||
}
|
||||
|
||||
auto CombineDefenderHoldCastlesScores(
|
||||
const UnitsScoreComponents &components,
|
||||
double victoryConditionScore,
|
||||
int roundsRemaining) const -> ScoreValue {
|
||||
|
||||
// Use actual total army value as reference
|
||||
const double totalArmyValue = components.attackerUnitsValue + components.defenderUnitsValue;
|
||||
const double reference = std::max(totalArmyValue, MIN_REFERENCE_VALUE);
|
||||
|
||||
// Similar to attacker, but from defender's perspective
|
||||
const double unitsDiff = components.defenderUnitsValue - components.attackerUnitsValue;
|
||||
const double unitsScore = (unitsDiff / reference) * UNITS_SCORE_SCALE;
|
||||
const double victoryScore = (victoryConditionScore / reference) * VICTORY_SCORE_SCALE;
|
||||
|
||||
const double unitsMultiplier =
|
||||
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
|
||||
|
||||
return unitsMultiplier * unitsScore + victoryScore;
|
||||
}
|
||||
```
|
||||
|
||||
### Terminal States
|
||||
|
||||
```cpp
|
||||
auto InterpretAttackerOutcome(GameOutcome outcome) const -> ScoreValue {
|
||||
switch (outcome) {
|
||||
case GameOutcome::ATTACKER_VICTORY: return 1000.0; // Large but bounded
|
||||
case GameOutcome::DEFENDER_VICTORY: return -1000.0;
|
||||
case GameOutcome::DRAW: return 0.0;
|
||||
case GameOutcome::FLEE_OUTCOME: return 0.0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: Using bounded values (±1000) instead of INT_MAX/MIN ensures numerical stability in MCTS and clearer signal that these are terminal states.
|
||||
|
||||
## Example Score Traces
|
||||
|
||||
### Large Battle Scenarios (10v10, ~40000 total army)
|
||||
|
||||
#### Scenario 1: Even armies, attacker needs to capture 3 castles
|
||||
- Units: attacker 20000, defender 20000 (total: 40000)
|
||||
- Victory: -2850 (3 castles * 950 each, medium distance)
|
||||
- Rounds: 15/30 remaining
|
||||
|
||||
Score:
|
||||
- reference = 40000
|
||||
- unitsDiff = 0
|
||||
- unitsScore = 0
|
||||
- victoryScore = (-2850 / 40000) * 400.0 = -28.5
|
||||
- unitsMultiplier = 0.5
|
||||
- **total = 0.5 * 0 + (-28.5) = -28.5**
|
||||
|
||||
#### Scenario 2: Slight attacker advantage (10%)
|
||||
- Units: attacker 22000, defender 18000 (diff: +4000, total: 40000)
|
||||
- Victory: -2850
|
||||
- Rounds: 15/30
|
||||
|
||||
Score:
|
||||
- unitsScore = (4000 / 40000) * 80.0 = 8.0
|
||||
- victoryScore = -28.5
|
||||
- unitsMultiplier = 0.5
|
||||
- **total = 0.5 * 8.0 + (-28.5) = -24.5**
|
||||
- **Difference from Scenario 1: 4.0** ✓
|
||||
|
||||
#### Scenario 3: Large attacker advantage (30%)
|
||||
- Units: attacker 26000, defender 14000 (diff: +12000, total: 40000)
|
||||
- Victory: -2850
|
||||
- Rounds: 15/30
|
||||
|
||||
Score:
|
||||
- unitsScore = (12000 / 40000) * 80.0 = 24.0
|
||||
- victoryScore = -28.5
|
||||
- unitsMultiplier = 0.5
|
||||
- **total = 0.5 * 24.0 + (-28.5) = -16.5**
|
||||
- **Difference from Scenario 2: 8.0** ✓
|
||||
|
||||
### Small Battle Scenarios (2v2, ~4000 total army)
|
||||
|
||||
#### Scenario 4: Even small armies, 1 castle
|
||||
- Units: attacker 2000, defender 2000 (total: 4000)
|
||||
- Victory: -475 (1 castle * 500 * 0.95 distance)
|
||||
- Rounds: 15/30
|
||||
|
||||
Score:
|
||||
- reference = 4000
|
||||
- unitsScore = 0
|
||||
- victoryScore = (-475 / 4000) * 400.0 = -47.5
|
||||
- **total = 0.5 * 0 + (-47.5) = -47.5**
|
||||
|
||||
#### Scenario 5: Slight advantage in small battle (10%)
|
||||
- Units: attacker 2200, defender 1800 (diff: +400, total: 4000)
|
||||
- Victory: -475
|
||||
- Rounds: 15/30
|
||||
|
||||
Score:
|
||||
- unitsScore = (400 / 4000) * 80.0 = 8.0
|
||||
- victoryScore = -47.5
|
||||
- **total = 0.5 * 8.0 + (-47.5) = -43.5**
|
||||
- **Difference from Scenario 4: 4.0** ✓
|
||||
|
||||
### Early Game Scenario: Single unit movement
|
||||
|
||||
#### Scenario 6: Early game, single unit advances toward castle
|
||||
- Units: attacker 20000, defender 20000 (total: 40000)
|
||||
- Victory before: -2850 (distance debuf = 0.95)
|
||||
- Victory after: -2829 (distance debuf = 0.943, one unit moved closer)
|
||||
- Change in victory score: +21
|
||||
- Rounds: 28/30 (early game)
|
||||
|
||||
Score change:
|
||||
- victoryScoreChange = (21 / 40000) * 400.0 = 0.21
|
||||
- Additionally, the moving unit (value 2000) gets better distance multiplier:
|
||||
- Before: 2000 * 0.25 = 500
|
||||
- After: 2000 * 0.279 = 558
|
||||
- Diff = 58, normalized: (58 / 40000) * 80.0 = 0.116
|
||||
- unitsMultiplier = 28/30 = 0.933
|
||||
- **Total improvement: 0.21 + 0.933 * 0.116 = 0.32** ✓
|
||||
|
||||
With exploration term ~0.42, this gives exploitation/exploration ratio of **0.76** - still below 1.0 but much better than before (was 0.05). MCTS will slightly prefer better moves while still exploring alternatives.
|
||||
|
||||
### Scale Consistency Verification
|
||||
|
||||
Comparing **10% advantage** in both battle sizes:
|
||||
- Large battle (Scenario 2): diff = **4.0**
|
||||
- Small battle (Scenario 5): diff = **4.0**
|
||||
|
||||
**Perfect scaling!** Same proportional advantage → same score difference, regardless of battle size.
|
||||
|
||||
Early game tactical moves now produce meaningful signals (0.3-0.5 range) that guide MCTS while still allowing healthy exploration.
|
||||
|
||||
## MCTS Behavior Verification
|
||||
|
||||
After 100 visits with C=1.41, exploration term ~0.42:
|
||||
|
||||
**Early game (single unit tactical moves):**
|
||||
- Good positioning move: **0.32** (ratio 0.76x exploration)
|
||||
- MCTS explores broadly but slightly favors better moves
|
||||
|
||||
**Mid game (unit advantages matter):**
|
||||
- 10% army advantage: **4.0** (ratio 9.5x exploration)
|
||||
- 30% army advantage: **8.0** (ratio 19x exploration)
|
||||
- MCTS strongly commits to maintaining/increasing army advantage
|
||||
|
||||
**Late game (large differences):**
|
||||
- Major strategic advantages: **10-40** (ratio 24-95x exploration)
|
||||
- MCTS decisively exploits winning positions
|
||||
|
||||
This progression is ideal:
|
||||
- **Early game**: Healthy exploration (ratio < 1.0) when moves are genuinely similar
|
||||
- **Mid game**: Strong exploitation (ratio 9-19x) when clear advantages exist
|
||||
- **Late game**: Decisive exploitation (ratio > 20x) to close out wins
|
||||
|
||||
This avoids both pathologies:
|
||||
- Not over-exploiting (like Standard scorer which overcommitted to tiny early differences)
|
||||
- Not over-exploring (like Normalized scorer which explored equally even with large advantages)
|
||||
|
||||
## Why MCTS Needs Stronger Signal Than Minimax
|
||||
|
||||
**Iterative Deepening (minimax)** works fine with tiny score differences (0.01-0.1) because:
|
||||
- It explores all moves to the same depth
|
||||
- It simply picks `argmax(scores)`
|
||||
- Even a 0.01 difference causes it to prefer the better move
|
||||
|
||||
**MCTS** needs much larger differences (0.3-4.0) because:
|
||||
- It uses UCB formula: `score/visits + C*sqrt(ln(parent_visits)/visits)`
|
||||
- The exploration term (~0.4) can dominate small exploitation differences
|
||||
- With differences < 0.1, MCTS explores all moves almost equally (over-exploration)
|
||||
- With differences > 10.0, MCTS commits too early (over-exploitation)
|
||||
|
||||
**Solution**: Use 10x larger scale constants than initially designed, specifically tuned so that:
|
||||
- Early game tactical moves (positioning, distance) produce 0.3-0.5 differences
|
||||
- Mid game advantages (10-30% army strength) produce 4.0-8.0 differences
|
||||
- Late game crushing advantages produce 10-40 differences
|
||||
|
||||
This gives MCTS the right balance: explore when moves are similar, exploit when advantages are clear.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. **Use same calculation structure**: Inherit from AbstractAIScoreCalculator like Standard and Normalized
|
||||
2. **Reuse unit scoring**: Use existing CalculateUnitsScoreComponents and victory condition calculators
|
||||
3. **Only change combination**: Override CombineAttackerScores, CombineDefenderScores, etc.
|
||||
4. **Bounded terminals**: Use ±1000 instead of INT_MAX/MIN for numerical stability
|
||||
5. **No transformation**: Unlike Normalized, don't apply power transformation - linear scaling is sufficient
|
||||
6. **Scale constants tuned for MCTS**: 10x larger than naive normalization to provide appropriate signal strength
|
||||
|
||||
## Testing with Integration Tests
|
||||
|
||||
Before integrating with MCTS, test the new scorer with **IterativeDeepeningAI** using the AI integration test infrastructure.
|
||||
|
||||
### Integration Test Infrastructure
|
||||
|
||||
The codebase now has comprehensive AI integration tests in `src/test/cpp/net/eagle0/shardok/ai/AIIntegrationTest.cpp` that use:
|
||||
|
||||
1. **AIPerformanceTestHelpers** (`src/test/cpp/net/eagle0/shardok/library/AIPerformanceTestHelpers.{cpp,hpp}`):
|
||||
- `CreatePerfTestGameState(settings, defenderToggle)` creates a 6v6 scenario on the Alah map
|
||||
- Properly initializes units with correct battalion sizes (800 for longbowmen, capacity-based for others)
|
||||
- Handles both attacker and defender perspectives
|
||||
- Returns GameStateW in SETUP phase with 6 units per player in reserve
|
||||
|
||||
2. **ShardokAIClient** integration:
|
||||
- Tests use the full AI client interface, not just the search algorithm
|
||||
- Time budgets set to 3s for reasonable test execution time
|
||||
- Handles both setup phase placement and first turn movement
|
||||
|
||||
3. **Acceptable Position Sets** for handling AI non-determinism:
|
||||
- AI decisions may vary due to internal tie-breaking and search order
|
||||
- Tests define sets of acceptable positions for each unit
|
||||
- Example from AttackerAI_Setup_PlacesUnitsCorrectly:
|
||||
```cpp
|
||||
std::set<net::eagle0::shardok::storage::fb::Coords> acceptablePositions{
|
||||
net::eagle0::shardok::storage::fb::Coords(0, 11),
|
||||
net::eagle0::shardok::storage::fb::Coords(1, 10),
|
||||
// ... more acceptable positions
|
||||
};
|
||||
```
|
||||
|
||||
### Adding Tests for New Scorers
|
||||
|
||||
To test MCTSOptimizedAIScoreCalculator (or any new scorer) with IterativeDeepeningAI:
|
||||
|
||||
1. **Add test cases following the existing pattern** in `AIIntegrationTest.cpp`:
|
||||
```cpp
|
||||
TEST(MCTSOptimizedScorerTest, AttackerAI_Setup_PlacesUnitsCorrectly) {
|
||||
auto settings = GetDefaultGameSettingsForTest();
|
||||
auto gameStateW = CreatePerfTestGameState(settings, /*defenderToggle=*/false);
|
||||
auto hexMap = gameStateW.GetHexMap().ToProto();
|
||||
|
||||
// Use MCTSOptimizedAIScoreCalculator instead of StandardAIScoreCalculator
|
||||
auto scoreCalculator = std::make_shared<MCTSOptimizedAIScoreCalculator>(
|
||||
/*playerId=*/0, /*isDefender=*/false, hexMap, settings->GetGetter());
|
||||
|
||||
ShardokAIClient client(
|
||||
/*playerId=*/0, /*isDefender=*/false, hexMap, settings,
|
||||
scoreCalculator, std::chrono::milliseconds(3000));
|
||||
|
||||
// ... rest of test follows existing pattern
|
||||
}
|
||||
```
|
||||
|
||||
2. **Update BUILD.bazel** to add the new scorer as a dependency:
|
||||
```bazel
|
||||
deps = [
|
||||
# ... existing deps ...
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:mcts_optimized_ai_score_calculator",
|
||||
]
|
||||
```
|
||||
|
||||
3. **Test patterns to implement**:
|
||||
- **Setup Phase Tests**: Verify AI places units in reasonable starting positions
|
||||
- `AttackerAI_Setup_PlacesUnitsCorrectly`: Attacker should place at start zone (0,11)-(1,13)
|
||||
- `DefenderAI_Setup_OccupiesCastles`: Defender should occupy castle tiles
|
||||
- **First Turn Tests**: Verify AI makes sensible initial moves
|
||||
- `AttackerAI_FirstTurn_MovesUnitsCorrectly`: Attacker should advance toward objectives
|
||||
- Use acceptable position sets to handle non-determinism
|
||||
- **Score Range Verification**: Add assertions to verify scores are in expected ranges
|
||||
```cpp
|
||||
// Example: verify scores are bounded as expected
|
||||
auto searchResult = client.GetBestCommand(gameStateW);
|
||||
EXPECT_GE(searchResult.score, -50.0); // Reasonable lower bound
|
||||
EXPECT_LE(searchResult.score, 50.0); // Reasonable upper bound
|
||||
```
|
||||
|
||||
4. **Performance Regression Testing**:
|
||||
- Run `./scripts/ai_perf_test.sh` to verify the new scorer doesn't cause performance degradation
|
||||
- Compare commands evaluated at each depth vs. StandardAIScoreCalculator
|
||||
- See CLAUDE.md "Performance Testing" section for detailed instructions
|
||||
|
||||
### Why Test with IterativeDeepeningAI First
|
||||
|
||||
The new scoring algorithm should work with **both** IterativeDeepeningAI and MCTS:
|
||||
- If it fails with IterativeDeepeningAI, the scoring logic itself is broken
|
||||
- If it passes with IterativeDeepeningAI but fails with MCTS, the issue is MCTS-specific
|
||||
- This allows incremental testing and debugging
|
||||
|
||||
Once the scorer passes integration tests with IterativeDeepeningAI, then integrate with MCTS and compare behavior.
|
||||
|
||||
## Alternative Names
|
||||
|
||||
- `BoundedLinearAIScoreCalculator`
|
||||
- `MCTSOptimizedAIScoreCalculator`
|
||||
- `LinearNormalizedAIScoreCalculator`
|
||||
|
||||
Recommend: **`MCTSOptimizedAIScoreCalculator`** to clearly indicate purpose.
|
||||
@@ -0,0 +1,252 @@
|
||||
//
|
||||
// Normalized [0,1] implementation of AIScoreCalculator
|
||||
//
|
||||
|
||||
#include "NormalizedAIScoreCalculator.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "private/AIScoreCalculatorSharedUtilities.hpp"
|
||||
#include "private/AbstractAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
using net::eagle0::shardok::storage::fb::BattalionTypeId;
|
||||
|
||||
// Bring shared utilities into scope
|
||||
using score_calculator_internal::UnitsScoreComponents;
|
||||
|
||||
/// Normalized implementation of AIScoreCalculator that produces scores in [0, 1] range.
|
||||
/// Inherits from AbstractAIScoreCalculator to share common functionality.
|
||||
class NormalizedAIScoreCalculator : public AbstractAIScoreCalculator {
|
||||
public:
|
||||
NormalizedAIScoreCalculator(
|
||||
int maxRounds,
|
||||
ActionPoints braveWaterCost,
|
||||
int meteorRange,
|
||||
double meteorCastVigorCost,
|
||||
int minimumFleeOddsThreshold,
|
||||
int desperateFleeThreshold,
|
||||
std::vector<BattalionTypeSPtr> battalionTypes,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache)
|
||||
: AbstractAIScoreCalculator(
|
||||
maxRounds,
|
||||
braveWaterCost,
|
||||
meteorRange,
|
||||
meteorCastVigorCost,
|
||||
minimumFleeOddsThreshold,
|
||||
desperateFleeThreshold,
|
||||
std::move(battalionTypes),
|
||||
apdCache,
|
||||
alCache) {}
|
||||
|
||||
[[nodiscard]] auto GuessedStateScore(
|
||||
bool isDefender,
|
||||
const GameStateW &state,
|
||||
const AIStrategy &aiStrategy,
|
||||
const CoordsSet &allCastleCoords) const -> ScoreValue override;
|
||||
|
||||
// Implement pure virtual methods from AbstractAIScoreCalculator
|
||||
[[nodiscard]] auto InterpretDefenderOutcome(GameOutcome outcome) const -> ScoreValue override;
|
||||
[[nodiscard]] auto InterpretAttackerOutcome(GameOutcome outcome) const -> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto AttackerFleeStrategyScoreForState(const GameStateW &gameState) const
|
||||
-> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto CombineAttackerScores(
|
||||
const UnitsScoreComponents &components,
|
||||
double victoryConditionScore,
|
||||
int roundsRemaining) const -> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto CombineDefenderScatterScores(const UnitsScoreComponents &components) const
|
||||
-> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto CombineDefenderHoldCastlesScores(
|
||||
const UnitsScoreComponents &components,
|
||||
double victoryConditionScore,
|
||||
int roundsRemaining) const -> ScoreValue override;
|
||||
|
||||
private:
|
||||
[[nodiscard]] auto DefenderFleeStrategyScoreForState(const GameStateW &gameState) const
|
||||
-> ScoreValue override;
|
||||
|
||||
/// Applies power transformation to spread out compressed scores for MCTS.
|
||||
/// Maps [0,1] → [0,1] but pushes values away from 0.5 toward the extremes.
|
||||
/// Terminal states (0.0, 1.0) are unchanged.
|
||||
[[nodiscard]] auto TransformForMCTS(ScoreValue score) const -> ScoreValue;
|
||||
};
|
||||
|
||||
// Implementation of NormalizedAIScoreCalculator methods
|
||||
|
||||
auto NormalizedAIScoreCalculator::InterpretDefenderOutcome(GameOutcome outcome) const
|
||||
-> ScoreValue {
|
||||
switch (outcome) {
|
||||
case GameOutcome::DEFENDER_VICTORY: return 1.0;
|
||||
case GameOutcome::ATTACKER_VICTORY: return 0.0;
|
||||
case GameOutcome::DRAW: return 0.5;
|
||||
case GameOutcome::FLEE_OUTCOME: return 0.5;
|
||||
}
|
||||
throw ShardokInternalErrorException("Unknown GameOutcome");
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::InterpretAttackerOutcome(GameOutcome outcome) const
|
||||
-> ScoreValue {
|
||||
switch (outcome) {
|
||||
case GameOutcome::ATTACKER_VICTORY: return 1.0;
|
||||
case GameOutcome::DEFENDER_VICTORY: return 0.0;
|
||||
case GameOutcome::DRAW: return 0.5;
|
||||
case GameOutcome::FLEE_OUTCOME: return 0.5;
|
||||
}
|
||||
throw ShardokInternalErrorException("Unknown GameOutcome");
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::CombineDefenderScatterScores(
|
||||
const UnitsScoreComponents &components) const -> ScoreValue {
|
||||
// For defender, we flip the perspective: defenderValue is (1), attackerValue is (2)
|
||||
const double defenderValue = components.defenderUnitsValue;
|
||||
const double attackerValue = components.attackerUnitsValue;
|
||||
|
||||
// No victory condition for scatter strategy
|
||||
const double denominator = defenderValue + attackerValue;
|
||||
if (denominator == 0.0) { return 0.5; }
|
||||
|
||||
return defenderValue / denominator;
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::CombineDefenderHoldCastlesScores(
|
||||
const UnitsScoreComponents &components,
|
||||
const double victoryConditionScore,
|
||||
const int /*roundsRemaining*/) const -> ScoreValue {
|
||||
// For defender, flip perspective
|
||||
const double defenderValue = components.defenderUnitsValue;
|
||||
const double attackerValue = components.attackerUnitsValue;
|
||||
|
||||
// Apply normalization
|
||||
double numerator;
|
||||
double denominator;
|
||||
|
||||
if (victoryConditionScore >= 0) {
|
||||
numerator = defenderValue + victoryConditionScore;
|
||||
denominator = defenderValue + attackerValue + victoryConditionScore;
|
||||
} else {
|
||||
numerator = defenderValue;
|
||||
denominator = defenderValue + attackerValue - victoryConditionScore;
|
||||
}
|
||||
|
||||
if (denominator == 0.0) { return 0.5; }
|
||||
|
||||
return numerator / denominator;
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::DefenderFleeStrategyScoreForState(
|
||||
const GameStateW & /*gameState*/) const -> ScoreValue {
|
||||
// FLEE strategy doesn't fit the [0,1] model well - return 0.5
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::AttackerFleeStrategyScoreForState(
|
||||
const GameStateW & /*gameState*/) const -> ScoreValue {
|
||||
// FLEE strategy doesn't fit the [0,1] model well - return 0.5
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::CombineAttackerScores(
|
||||
const UnitsScoreComponents &components,
|
||||
const double victoryConditionScore,
|
||||
const int /*roundsRemaining*/) const -> ScoreValue {
|
||||
const double attackerUnitsValue = components.attackerUnitsValue;
|
||||
const double defenderUnitsValue = components.defenderUnitsValue;
|
||||
|
||||
// Apply normalization formula
|
||||
double numerator;
|
||||
double denominator;
|
||||
|
||||
if (victoryConditionScore >= 0) {
|
||||
// Positive victory condition: add to numerator
|
||||
numerator = attackerUnitsValue + victoryConditionScore;
|
||||
denominator = attackerUnitsValue + defenderUnitsValue + victoryConditionScore;
|
||||
} else {
|
||||
// Negative victory condition: subtract from denominator (making it larger)
|
||||
numerator = attackerUnitsValue;
|
||||
denominator = attackerUnitsValue + defenderUnitsValue - victoryConditionScore;
|
||||
}
|
||||
|
||||
// Handle edge case of all zeros
|
||||
if (denominator == 0.0) { return 0.5; }
|
||||
|
||||
return numerator / denominator;
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::TransformForMCTS(ScoreValue score) const -> ScoreValue {
|
||||
// Power transformation exponent - lower values spread scores more toward extremes
|
||||
// Tuned for MCTS: balances exploration vs exploitation
|
||||
// - Too low (e.g., 0.3): over-exploitation like standard scorer
|
||||
// - Too high (e.g., 0.9): over-exploration like untransformed normalized
|
||||
// - 0.6-0.7: sweet spot for MCTS
|
||||
constexpr double EXPONENT = 0.1;
|
||||
|
||||
if (score > 0.5) {
|
||||
// Map [0.5, 1.0] → [0.5, 1.0] with power curve
|
||||
// (score - 0.5) * 2.0 maps to [0, 1], apply power, then scale back
|
||||
return 0.5 + 0.5 * std::pow((score - 0.5) * 2.0, EXPONENT);
|
||||
} else {
|
||||
// Map [0.0, 0.5] → [0.0, 0.5] with power curve (symmetric)
|
||||
return 0.5 - 0.5 * std::pow((0.5 - score) * 2.0, EXPONENT);
|
||||
}
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::GuessedStateScore(
|
||||
const bool isDefender,
|
||||
const GameStateW &state,
|
||||
const AIStrategy &aiStrategy,
|
||||
const CoordsSet &allCastleCoords) const -> ScoreValue {
|
||||
const int roundsRemaining = GetMaxRounds() - state->current_round();
|
||||
|
||||
ScoreValue rawScore;
|
||||
if (isDefender) {
|
||||
rawScore = DefenderScoreForState(state, aiStrategy, allCastleCoords, roundsRemaining);
|
||||
} else {
|
||||
rawScore = AttackerScoreForState(state, aiStrategy, allCastleCoords, roundsRemaining);
|
||||
}
|
||||
|
||||
// Apply power transformation to spread out scores for MCTS
|
||||
return TransformForMCTS(rawScore);
|
||||
}
|
||||
|
||||
// Factory function implementation
|
||||
auto MakeNormalizedAIScoreCalculator(
|
||||
const SettingsGetter &settingsGetter,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> std::unique_ptr<AIScoreCalculator> {
|
||||
// Extract all battalion types into a vector indexed by BattalionTypeId
|
||||
std::vector<BattalionTypeSPtr> battalionTypes(BattalionTypeId::BattalionTypeId_MAX + 1);
|
||||
for (int typeId = BattalionTypeId::BattalionTypeId_MIN;
|
||||
typeId <= BattalionTypeId::BattalionTypeId_MAX;
|
||||
typeId++) {
|
||||
auto battalionTypeId = static_cast<BattalionTypeId>(typeId);
|
||||
battalionTypes[battalionTypeId] = settingsGetter.GetBattalionType(battalionTypeId);
|
||||
}
|
||||
|
||||
return std::make_unique<NormalizedAIScoreCalculator>(
|
||||
settingsGetter.Backing().max_rounds(),
|
||||
settingsGetter.Backing().brave_water_action_point_cost(),
|
||||
settingsGetter.Backing().meteor_range(),
|
||||
settingsGetter.Backing().meteor_cast_vigor_cost(),
|
||||
settingsGetter.Backing().ai_minimum_flee_odds_threshold(),
|
||||
settingsGetter.Backing().ai_desperate_flee_threshold(),
|
||||
std::move(battalionTypes),
|
||||
apdCache,
|
||||
alCache);
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// Normalized [0,1] implementation of AIScoreCalculator
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_NORMALIZEDAISCORECALCULATOR_HPP
|
||||
#define EAGLE0_NORMALIZEDAISCORECALCULATOR_HPP
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
// Forward declarations
|
||||
class AIScoreCalculator;
|
||||
|
||||
using APDCache = std::shared_ptr<ActionPointDistancesCache>;
|
||||
using ALCache = std::unique_ptr<AttackLocationsCache>;
|
||||
|
||||
/// Factory function to create a NormalizedAIScoreCalculator.
|
||||
/// Returns a unique_ptr to AIScoreCalculator to hide the implementation.
|
||||
///
|
||||
/// The normalized scorer produces scores in the range [0, 1] where:
|
||||
/// - 0.0 = complete defender victory
|
||||
/// - 1.0 = complete attacker victory
|
||||
/// - 0.5 = neutral/draw state
|
||||
///
|
||||
/// Terminal states (victory/defeat) always return 1.0 or 0.0.
|
||||
/// Non-terminal states use asymmetric normalization:
|
||||
/// - If victory condition >= 0:
|
||||
/// score = (attackerUnits + victoryCondition) / (attackerUnits + defenderUnits +
|
||||
/// victoryCondition)
|
||||
/// - If victory condition < 0:
|
||||
/// score = attackerUnits / (attackerUnits + defenderUnits - victoryCondition)
|
||||
[[nodiscard]] auto MakeNormalizedAIScoreCalculator(
|
||||
const SettingsGetter& settingsGetter,
|
||||
const APDCache& apdCache,
|
||||
const ALCache& alCache) -> std::unique_ptr<AIScoreCalculator>;
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_NORMALIZEDAISCORECALCULATOR_HPP
|
||||
@@ -0,0 +1,281 @@
|
||||
//
|
||||
// Standard implementation of AIScoreCalculator
|
||||
//
|
||||
|
||||
#include "StandardAIScoreCalculator.hpp"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "private/AIScoreCalculatorSharedUtilities.hpp"
|
||||
#include "private/AbstractAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackGroups.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
using net::eagle0::shardok::storage::fb::BattalionTypeId;
|
||||
|
||||
// Bring shared utilities into scope
|
||||
using score_calculator_internal::AttackerMultiplierForTargetDistance;
|
||||
using score_calculator_internal::CAPTURED_UNIT_SCORE;
|
||||
using score_calculator_internal::CAPTURED_VIP_SCORE;
|
||||
using score_calculator_internal::EffectiveDistanceCache;
|
||||
using score_calculator_internal::FleeStrategyScoreForState;
|
||||
using score_calculator_internal::UNITS_BASE_MULTIPLIER;
|
||||
|
||||
// Forward declare the implementation class
|
||||
class StandardAIScoreCalculator;
|
||||
|
||||
// Anonymous namespace for helper functions that don't need access to scorer
|
||||
namespace {
|
||||
|
||||
#define LOGGING_ 0
|
||||
#define PERFORMANCE_LOGGING_ 0
|
||||
|
||||
// Performance logging for AttackerScoreForState
|
||||
struct AttackerScorePerformanceLogger {
|
||||
static constexpr int LOG_INTERVAL = 100000;
|
||||
|
||||
static std::atomic<int> callCount;
|
||||
static std::atomic<double> intervalTime;
|
||||
static std::atomic<double> totalTime;
|
||||
|
||||
static void LogCall(double duration) {
|
||||
callCount.fetch_add(1);
|
||||
intervalTime.fetch_add(duration);
|
||||
totalTime.fetch_add(duration);
|
||||
|
||||
if (callCount.load() % LOG_INTERVAL == 0) {
|
||||
double intervalAvg = intervalTime.load() / LOG_INTERVAL;
|
||||
double overallAvg = totalTime.load() / callCount.load();
|
||||
printf("AttackerScoreForState: %d calls, last %d avg: %.1f µs, overall avg: %.1f µs\n",
|
||||
callCount.load(),
|
||||
LOG_INTERVAL,
|
||||
intervalAvg * 1000000.0,
|
||||
overallAvg * 1000000.0);
|
||||
intervalTime.store(0.0); // Reset for next interval
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
std::atomic<int> AttackerScorePerformanceLogger::callCount{0};
|
||||
std::atomic<double> AttackerScorePerformanceLogger::intervalTime{0.0};
|
||||
std::atomic<double> AttackerScorePerformanceLogger::totalTime{0.0};
|
||||
|
||||
// RAII timer for automatic performance logging
|
||||
class AttackerScoreTimer {
|
||||
private:
|
||||
std::chrono::high_resolution_clock::time_point startTime;
|
||||
|
||||
public:
|
||||
AttackerScoreTimer() : startTime(std::chrono::high_resolution_clock::now()) {}
|
||||
|
||||
~AttackerScoreTimer() {
|
||||
auto endTime = std::chrono::high_resolution_clock::now();
|
||||
auto duration =
|
||||
std::chrono::duration_cast<std::chrono::duration<double>>(endTime - startTime);
|
||||
AttackerScorePerformanceLogger::LogCall(duration.count());
|
||||
}
|
||||
};
|
||||
} // anonymous namespace
|
||||
|
||||
/// Standard implementation of AIScoreCalculator that uses the default scoring algorithm.
|
||||
/// Inherits from AbstractAIScoreCalculator to share common functionality.
|
||||
class StandardAIScoreCalculator : public AbstractAIScoreCalculator {
|
||||
public:
|
||||
StandardAIScoreCalculator(
|
||||
int maxRounds,
|
||||
ActionPoints braveWaterCost,
|
||||
int meteorRange,
|
||||
double meteorCastVigorCost,
|
||||
int minimumFleeOddsThreshold,
|
||||
int desperateFleeThreshold,
|
||||
std::vector<BattalionTypeSPtr> battalionTypes,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache)
|
||||
: AbstractAIScoreCalculator(
|
||||
maxRounds,
|
||||
braveWaterCost,
|
||||
meteorRange,
|
||||
meteorCastVigorCost,
|
||||
minimumFleeOddsThreshold,
|
||||
desperateFleeThreshold,
|
||||
std::move(battalionTypes),
|
||||
apdCache,
|
||||
alCache) {}
|
||||
|
||||
[[nodiscard]] auto GuessedStateScore(
|
||||
bool isDefender,
|
||||
const GameStateW &state,
|
||||
const AIStrategy &aiStrategy,
|
||||
const CoordsSet &allCastleCoords) const -> ScoreValue override;
|
||||
|
||||
// Implement pure virtual methods from AbstractAIScoreCalculator
|
||||
[[nodiscard]] auto InterpretDefenderOutcome(GameOutcome outcome) const -> ScoreValue override;
|
||||
[[nodiscard]] auto InterpretAttackerOutcome(GameOutcome outcome) const -> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto AttackerFleeStrategyScoreForState(const GameStateW &gameState) const
|
||||
-> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto CombineAttackerScores(
|
||||
const UnitsScoreComponents &components,
|
||||
double victoryConditionScore,
|
||||
int roundsRemaining) const -> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto CombineDefenderScatterScores(const UnitsScoreComponents &components) const
|
||||
-> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto CombineDefenderHoldCastlesScores(
|
||||
const UnitsScoreComponents &components,
|
||||
double victoryConditionScore,
|
||||
int roundsRemaining) const -> ScoreValue override;
|
||||
|
||||
private:
|
||||
// Implementation methods (converted from internal namespace functions)
|
||||
[[nodiscard]] auto AttackerUnitsScore(
|
||||
const GameStateW &gameState,
|
||||
int roundsRemaining,
|
||||
bool attackerWantsCastles,
|
||||
bool defenderShouldScatter,
|
||||
const vector<TargetPriorityList> &attackerTargetPriorities,
|
||||
const MapId &mapId) const -> ScoreValue;
|
||||
|
||||
[[nodiscard]] auto DefenderFleeStrategyScoreForState(const GameStateW &gameState) const
|
||||
-> ScoreValue override;
|
||||
};
|
||||
|
||||
// Implementation of StandardAIScoreCalculator methods
|
||||
|
||||
auto StandardAIScoreCalculator::InterpretDefenderOutcome(GameOutcome outcome) const -> ScoreValue {
|
||||
switch (outcome) {
|
||||
case GameOutcome::DEFENDER_VICTORY: return INT_MAX;
|
||||
case GameOutcome::ATTACKER_VICTORY: return INT_MIN;
|
||||
case GameOutcome::DRAW: return 0;
|
||||
case GameOutcome::FLEE_OUTCOME: return 0;
|
||||
}
|
||||
throw ShardokInternalErrorException("Unknown GameOutcome");
|
||||
}
|
||||
|
||||
auto StandardAIScoreCalculator::InterpretAttackerOutcome(GameOutcome outcome) const -> ScoreValue {
|
||||
switch (outcome) {
|
||||
case GameOutcome::ATTACKER_VICTORY: return INT_MAX;
|
||||
case GameOutcome::DEFENDER_VICTORY: return INT_MIN;
|
||||
case GameOutcome::DRAW: return 0;
|
||||
case GameOutcome::FLEE_OUTCOME: return 0;
|
||||
}
|
||||
throw ShardokInternalErrorException("Unknown GameOutcome");
|
||||
}
|
||||
|
||||
auto StandardAIScoreCalculator::AttackerUnitsScore(
|
||||
const GameStateW &gameState,
|
||||
int roundsRemaining,
|
||||
bool attackerWantsCastles,
|
||||
bool defenderShouldScatter,
|
||||
const vector<TargetPriorityList> &attackerTargetPriorities,
|
||||
const MapId &mapId) const -> ScoreValue {
|
||||
// Use the base class implementation to get separated attacker/defender values
|
||||
const auto components = CalculateUnitsScoreComponents(
|
||||
gameState,
|
||||
roundsRemaining,
|
||||
attackerWantsCastles,
|
||||
defenderShouldScatter,
|
||||
attackerTargetPriorities,
|
||||
mapId);
|
||||
|
||||
// Standard scorer returns the difference (attacker - defender)
|
||||
return components.attackerUnitsValue - components.defenderUnitsValue;
|
||||
}
|
||||
|
||||
auto StandardAIScoreCalculator::CombineDefenderScatterScores(
|
||||
const UnitsScoreComponents &components) const -> ScoreValue {
|
||||
// For defender scatter, we want to maximize defender units and minimize attacker units
|
||||
// From defender's perspective: negate the attacker-defender difference
|
||||
return components.defenderUnitsValue - components.attackerUnitsValue;
|
||||
}
|
||||
|
||||
auto StandardAIScoreCalculator::CombineDefenderHoldCastlesScores(
|
||||
const UnitsScoreComponents &components,
|
||||
const double victoryConditionScore,
|
||||
const int roundsRemaining) const -> ScoreValue {
|
||||
// From defender's perspective: negate the attacker-defender difference
|
||||
const double unitsDifference = components.defenderUnitsValue - components.attackerUnitsValue;
|
||||
const double unitsMultiplier =
|
||||
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
|
||||
return UNITS_BASE_MULTIPLIER * unitsMultiplier * unitsDifference + victoryConditionScore;
|
||||
}
|
||||
|
||||
auto StandardAIScoreCalculator::DefenderFleeStrategyScoreForState(const GameStateW &gameState) const
|
||||
-> ScoreValue {
|
||||
for (const auto *pi : *gameState->player_infos()) {
|
||||
if (pi->is_defender()) { return FleeStrategyScoreForState(gameState, pi->player_id()); }
|
||||
}
|
||||
throw ShardokInternalErrorException("Unable to find defender for FleeStrategy");
|
||||
}
|
||||
|
||||
auto StandardAIScoreCalculator::AttackerFleeStrategyScoreForState(const GameStateW &gameState) const
|
||||
-> ScoreValue {
|
||||
for (const PlayerInfo *pi : *gameState->player_infos()) {
|
||||
if (!pi->is_defender()) { return FleeStrategyScoreForState(gameState, pi->player_id()); }
|
||||
}
|
||||
throw ShardokInternalErrorException("Unable to find attacker for FleeStrategy");
|
||||
}
|
||||
|
||||
auto StandardAIScoreCalculator::CombineAttackerScores(
|
||||
const UnitsScoreComponents &components,
|
||||
const double victoryConditionScore,
|
||||
const int roundsRemaining) const -> ScoreValue {
|
||||
const double unitsDifference = components.attackerUnitsValue - components.defenderUnitsValue;
|
||||
const double unitsMultiplier =
|
||||
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
|
||||
return UNITS_BASE_MULTIPLIER * unitsMultiplier * unitsDifference + victoryConditionScore;
|
||||
}
|
||||
|
||||
auto StandardAIScoreCalculator::GuessedStateScore(
|
||||
const bool isDefender,
|
||||
const GameStateW &state,
|
||||
const AIStrategy &aiStrategy,
|
||||
const CoordsSet &allCastleCoords) const -> ScoreValue {
|
||||
const int roundsRemaining = GetMaxRounds() - state->current_round();
|
||||
|
||||
if (isDefender) {
|
||||
return DefenderScoreForState(state, aiStrategy, allCastleCoords, roundsRemaining);
|
||||
}
|
||||
return AttackerScoreForState(state, aiStrategy, allCastleCoords, roundsRemaining);
|
||||
}
|
||||
|
||||
// Factory function implementation
|
||||
auto MakeStandardAIScoreCalculator(
|
||||
const SettingsGetter &settingsGetter,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> std::unique_ptr<AIScoreCalculator> {
|
||||
// Extract all battalion types into a vector indexed by BattalionTypeId
|
||||
std::vector<BattalionTypeSPtr> battalionTypes(BattalionTypeId::BattalionTypeId_MAX + 1);
|
||||
for (int typeId = BattalionTypeId::BattalionTypeId_MIN;
|
||||
typeId <= BattalionTypeId::BattalionTypeId_MAX;
|
||||
typeId++) {
|
||||
auto battalionTypeId = static_cast<BattalionTypeId>(typeId);
|
||||
battalionTypes[battalionTypeId] = settingsGetter.GetBattalionType(battalionTypeId);
|
||||
}
|
||||
|
||||
return std::make_unique<StandardAIScoreCalculator>(
|
||||
settingsGetter.Backing().max_rounds(),
|
||||
settingsGetter.Backing().brave_water_action_point_cost(),
|
||||
settingsGetter.Backing().meteor_range(),
|
||||
settingsGetter.Backing().meteor_cast_vigor_cost(),
|
||||
settingsGetter.Backing().ai_minimum_flee_odds_threshold(),
|
||||
settingsGetter.Backing().ai_desperate_flee_threshold(),
|
||||
std::move(battalionTypes),
|
||||
apdCache,
|
||||
alCache);
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
//
|
||||
// Shared utilities for AI score calculators - implementation
|
||||
//
|
||||
|
||||
#include "AIScoreCalculatorSharedUtilities.hpp"
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
|
||||
|
||||
namespace shardok {
|
||||
namespace score_calculator_internal {
|
||||
|
||||
auto EffectiveDistanceCache::GetOrCompute(
|
||||
const Unit* unit,
|
||||
const Coords& target,
|
||||
const ActionPointDistances* notBravingApd,
|
||||
const ActionPointDistances* bravingApd,
|
||||
const HexMap* hexMap) const -> DIST_T {
|
||||
CacheKey key{unit->unit_id(), target};
|
||||
auto it = cache.find(key);
|
||||
if (it != cache.end()) { return it->second; }
|
||||
|
||||
CoordsSet targetSet(hexMap);
|
||||
targetSet.Add(target);
|
||||
|
||||
DIST_T result = EffectiveDistance(unit, notBravingApd, bravingApd, targetSet);
|
||||
cache[key] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
auto FleeStrategyScoreForState(const GameStateW& gameState, const PlayerId playerId) -> ScoreValue {
|
||||
ScoreValue scoreValue = 0.0;
|
||||
|
||||
const auto* gameStatePtr = gameState.Get();
|
||||
const auto* units = gameStatePtr->units();
|
||||
|
||||
for (const auto* unit : *units) {
|
||||
if (unit->status() != net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) continue;
|
||||
|
||||
if (unit->player_id() == playerId &&
|
||||
unit->battalion().type() != net::eagle0::shardok::storage::fb::BattalionTypeId_UNDEAD) {
|
||||
scoreValue += FLEE_UNIT_SCORE;
|
||||
|
||||
if (unit->has_attached_hero() &&
|
||||
unit->attached_hero().control_info().controlled_unit_id() != -1) {
|
||||
scoreValue += FLEE_CONTROLLING_UNIT_SCORE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scoreValue;
|
||||
}
|
||||
|
||||
// Forward declaration for recursive helper
|
||||
static auto RecursiveAttackerMultiplierForTargetDistance(
|
||||
const Unit* attackingUnit,
|
||||
std::vector<TargetAndAttackLocations>::const_iterator& priorityListNext,
|
||||
const std::vector<TargetAndAttackLocations>::const_iterator& priorityListEnd,
|
||||
const std::vector<const Unit*>& occupants,
|
||||
const HexMap* map,
|
||||
const BattalionTypeSPtr& battType,
|
||||
const ActionPointDistances* notBravingApd,
|
||||
const ActionPointDistances* bravingApd,
|
||||
bool isLateGame) -> double;
|
||||
|
||||
static auto RecursiveAttackerMultiplierForTargetDistance(
|
||||
const Unit* attackingUnit,
|
||||
std::vector<TargetAndAttackLocations>::const_iterator& priorityListNext,
|
||||
const std::vector<TargetAndAttackLocations>::const_iterator& priorityListEnd,
|
||||
const std::vector<const Unit*>& occupants,
|
||||
const HexMap* map,
|
||||
const BattalionTypeSPtr& battType,
|
||||
const ActionPointDistances* notBravingApd,
|
||||
const ActionPointDistances* bravingApd,
|
||||
const bool isLateGame) -> double {
|
||||
if (priorityListNext == priorityListEnd) return 1.0;
|
||||
|
||||
const auto& [target, attackLocations] = *priorityListNext;
|
||||
const Coords& topPriorityTarget = target;
|
||||
|
||||
// If the target is unoccupied or is occupied by this player, give the maximum multiplier, but
|
||||
// also add the bonus for the next up in the priority list
|
||||
if (const Unit* occupant = occupants
|
||||
[topPriorityTarget.row() * map->column_count() + topPriorityTarget.column()];
|
||||
!occupant || occupant->player_id() == attackingUnit->player_id()) {
|
||||
return kMaxProximityBuf + RecursiveAttackerMultiplierForTargetDistance(
|
||||
attackingUnit,
|
||||
++priorityListNext,
|
||||
priorityListEnd,
|
||||
occupants,
|
||||
map,
|
||||
battType,
|
||||
notBravingApd,
|
||||
bravingApd,
|
||||
isLateGame);
|
||||
}
|
||||
|
||||
// Use optimized EffectiveDistance with pre-computed ActionPointDistances
|
||||
// attackLocations is already the CoordsSet of attack locations for this target
|
||||
const DIST_T distance =
|
||||
EffectiveDistance(attackingUnit, notBravingApd, bravingApd, attackLocations);
|
||||
|
||||
return kMaxProximityBuf / (1 + distance / kDistanceDebufRatio);
|
||||
}
|
||||
|
||||
auto AttackerMultiplierForTargetDistance(
|
||||
const Unit* attackingUnit,
|
||||
const std::vector<TargetAndAttackLocations>& priorityList,
|
||||
const std::vector<const Unit*>& occupants,
|
||||
const HexMap* map,
|
||||
const BattalionTypeSPtr& battType,
|
||||
const ActionPointDistances* notBravingApd,
|
||||
const ActionPointDistances* bravingApd,
|
||||
const bool isLateGame) -> double {
|
||||
auto iter = std::begin(priorityList);
|
||||
return RecursiveAttackerMultiplierForTargetDistance(
|
||||
attackingUnit,
|
||||
iter,
|
||||
std::end(priorityList),
|
||||
occupants,
|
||||
map,
|
||||
battType,
|
||||
notBravingApd,
|
||||
bravingApd,
|
||||
isLateGame);
|
||||
}
|
||||
|
||||
} // namespace score_calculator_internal
|
||||
} // namespace shardok
|
||||
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// Shared utilities for AI score calculators
|
||||
// This file is private to the ai/score package
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_AI_SCORE_CALCULATOR_SHARED_UTILITIES_HPP
|
||||
#define EAGLE0_AI_SCORE_CALCULATOR_SHARED_UTILITIES_HPP
|
||||
|
||||
#include <vector>
|
||||
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wthread-safety-analysis"
|
||||
#pragma GCC diagnostic ignored "-Wunused-result"
|
||||
#include <gtl/phmap.hpp>
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackGroups.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/BattalionType.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/action_point_distances/ActionPointDistances.hpp"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state_generated.h"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/hex_map_generated.h"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit_generated.h"
|
||||
|
||||
namespace shardok {
|
||||
namespace score_calculator_internal {
|
||||
|
||||
using HexMap = net::eagle0::shardok::storage::fb::HexMap;
|
||||
using Unit = net::eagle0::shardok::storage::fb::Unit;
|
||||
|
||||
// Scoring constants shared across all calculators
|
||||
constexpr double UNITS_BASE_MULTIPLIER = 0.05;
|
||||
constexpr double FLEE_UNIT_SCORE = -10000;
|
||||
constexpr double FLEE_CONTROLLING_UNIT_SCORE = -10000;
|
||||
constexpr double CAPTURED_UNIT_SCORE = -10000;
|
||||
constexpr double CAPTURED_VIP_SCORE = -25000;
|
||||
constexpr double kMaxProximityBuf = 1.5;
|
||||
constexpr double kDistanceDebufRatio = 8.0;
|
||||
|
||||
/// Structure to hold separated attacker/defender unit scores
|
||||
/// Used by NormalizedAIScoreCalculator to apply asymmetric normalization
|
||||
struct UnitsScoreComponents {
|
||||
double attackerUnitsValue;
|
||||
double defenderUnitsValue;
|
||||
};
|
||||
|
||||
/// Memoization cache for EffectiveDistance calls
|
||||
struct EffectiveDistanceCache {
|
||||
struct CacheKey {
|
||||
UnitId unitId;
|
||||
Coords target;
|
||||
bool operator==(const CacheKey& other) const {
|
||||
return unitId == other.unitId && target == other.target;
|
||||
}
|
||||
};
|
||||
|
||||
struct CacheKeyHash {
|
||||
size_t operator()(const CacheKey& key) const {
|
||||
return std::hash<UnitId>{}(key.unitId) ^ (std::hash<int>{}(key.target.row()) << 1) ^
|
||||
(std::hash<int>{}(key.target.column()) << 2);
|
||||
}
|
||||
};
|
||||
|
||||
mutable gtl::flat_hash_map<CacheKey, DIST_T, CacheKeyHash> cache;
|
||||
|
||||
DIST_T GetOrCompute(
|
||||
const Unit* unit,
|
||||
const Coords& target,
|
||||
const ActionPointDistances* notBravingApd,
|
||||
const ActionPointDistances* bravingApd,
|
||||
const HexMap* hexMap) const;
|
||||
};
|
||||
|
||||
/// Calculate score for FLEE strategy
|
||||
/// Returns negative score based on fleeing units
|
||||
auto FleeStrategyScoreForState(const GameStateW& gameState, PlayerId playerId) -> ScoreValue;
|
||||
|
||||
/// Calculate attacker multiplier based on distance to priority targets
|
||||
/// This is used to weight attacker units by their proximity to objectives
|
||||
auto AttackerMultiplierForTargetDistance(
|
||||
const Unit* attackingUnit,
|
||||
const std::vector<TargetAndAttackLocations>& priorityList,
|
||||
const std::vector<const Unit*>& occupants,
|
||||
const HexMap* map,
|
||||
const BattalionTypeSPtr& battType,
|
||||
const ActionPointDistances* notBravingApd,
|
||||
const ActionPointDistances* bravingApd,
|
||||
bool isLateGame) -> double;
|
||||
|
||||
} // namespace score_calculator_internal
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_AI_SCORE_CALCULATOR_SHARED_UTILITIES_HPP
|
||||
@@ -0,0 +1,514 @@
|
||||
//
|
||||
// Abstract base class for AI score calculator implementations
|
||||
//
|
||||
|
||||
#include "AbstractAIScoreCalculator.hpp"
|
||||
|
||||
#include "AIScoreCalculatorSharedUtilities.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIVictoryConditionScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
using net::eagle0::shardok::storage::fb::BattalionTypeId;
|
||||
using net::eagle0::shardok::storage::fb::Unit;
|
||||
|
||||
using score_calculator_internal::AttackerMultiplierForTargetDistance;
|
||||
using score_calculator_internal::CAPTURED_UNIT_SCORE;
|
||||
using score_calculator_internal::CAPTURED_VIP_SCORE;
|
||||
using score_calculator_internal::EffectiveDistanceCache;
|
||||
using score_calculator_internal::UnitsScoreComponents;
|
||||
|
||||
auto AbstractAIScoreCalculator::CalculateUnitsScoreComponents(
|
||||
const GameStateW &gameState,
|
||||
int roundsRemaining,
|
||||
bool attackerWantsCastles,
|
||||
bool defenderShouldScatter,
|
||||
const vector<TargetPriorityList> &attackerTargetPriorities,
|
||||
const MapId &mapId) const -> UnitsScoreComponents {
|
||||
// Cache frequently accessed FlatBuffer fields to avoid repeated offset calculations
|
||||
const auto *gameStateRawPtr = gameState.Get();
|
||||
const auto *cachedUnits = gameStateRawPtr->units();
|
||||
const auto *cachedHexMap = gameStateRawPtr->hex_map();
|
||||
|
||||
const int16_t cachedRowCount = cachedHexMap->row_count();
|
||||
const int16_t cachedColumnCount = cachedHexMap->column_count();
|
||||
const int cachedCurrentRound = gameStateRawPtr->current_round();
|
||||
|
||||
bool isLateGame = cachedCurrentRound > 18; // Inline IsLateGame for efficiency
|
||||
|
||||
// APDCache now has built-in thread-local caching - no need for PreCachedAPDs
|
||||
ActionPoints braveWaterCost = GetBraveWaterCost();
|
||||
|
||||
// Memoization cache for EffectiveDistance calls
|
||||
EffectiveDistanceCache distanceCache;
|
||||
|
||||
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;
|
||||
|
||||
// Early return for empty game states
|
||||
if (cachedUnits->size() == 0) { return UnitsScoreComponents{0.0, 0.0}; }
|
||||
|
||||
auto occupants = Occupants(*cachedUnits, cachedRowCount, cachedColumnCount);
|
||||
|
||||
for (const Unit *unit : *cachedUnits) {
|
||||
const auto *pi = PlayerInfoForPid(gameState, unit->player_id());
|
||||
if (pi == nullptr) { continue; }
|
||||
|
||||
switch (unit->status()) {
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT: {
|
||||
if (pi->is_defender()) {
|
||||
defenderUnits.push_back(unit);
|
||||
} else {
|
||||
attackerUnits.push_back(unit);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_CAPTURED_UNIT: {
|
||||
double thisScore = unit->has_attached_hero() && unit->attached_hero().is_vip()
|
||||
? CAPTURED_VIP_SCORE
|
||||
: CAPTURED_UNIT_SCORE;
|
||||
if (pi->is_defender()) {
|
||||
defenderUnitsValue += thisScore;
|
||||
} else {
|
||||
attackerUnitsValue += thisScore;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_DESTROYED_SUMMONED_UNIT:
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_FLED_UNIT:
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_NEVER_ENTERED_UNIT:
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_OUTLAWED_UNIT:
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT:
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_RETREATED_UNIT:
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT: break;
|
||||
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_UNKNOWN_UNIT:
|
||||
throw ShardokInternalErrorException("Unknown unit status");
|
||||
}
|
||||
}
|
||||
|
||||
double defenderAdvantage = 1.0 + static_cast<double>(cachedCurrentRound) / 31.0;
|
||||
|
||||
// Can we cache this somehow, it won't usually change within your turn
|
||||
auto attackLocationsForAttacker = GetAlCache()->CachedLocations(defenderUnits, isLateGame);
|
||||
const auto &locationsCausingDanger = attackLocationsForAttacker.AllLocations();
|
||||
|
||||
// Process attacker units using cached ActionPointDistances
|
||||
for (const Unit *unit : attackerUnits) {
|
||||
const int battTypeId = unit->battalion().type();
|
||||
// Cache battalion type reference to avoid shared_ptr atomic operations
|
||||
const auto &battalionType = GetBattalionType(static_cast<BattalionTypeId>(battTypeId));
|
||||
|
||||
// Cache APD lookups - same battalion type is used multiple times below
|
||||
const auto *notBravingApd =
|
||||
GetApdCache()->GetRaw(cachedHexMap, mapId, battalionType, false);
|
||||
const auto *bravingApd = battalionType->allowsBraveWater ? GetApdCache()->GetRaw(
|
||||
cachedHexMap,
|
||||
mapId,
|
||||
battalionType,
|
||||
true,
|
||||
braveWaterCost)
|
||||
: nullptr;
|
||||
|
||||
const auto &priorityList = std::ranges::find_if(
|
||||
attackerTargetPriorities,
|
||||
[&unit](const TargetPriorityList &tpl) {
|
||||
return tpl.attackingUnitId == unit->unit_id();
|
||||
});
|
||||
|
||||
// If there are any tiles being targeted, give this unit a multiplier based on how close
|
||||
// they are to being able to attack it
|
||||
double distanceMultiplier = priorityList == end(attackerTargetPriorities)
|
||||
? 1.0
|
||||
: AttackerMultiplierForTargetDistance(
|
||||
unit,
|
||||
priorityList->priorityOrder,
|
||||
occupants,
|
||||
cachedHexMap,
|
||||
battalionType,
|
||||
notBravingApd,
|
||||
bravingApd,
|
||||
isLateGame);
|
||||
|
||||
auto uv = UnitValue(
|
||||
unit,
|
||||
true,
|
||||
attackerUnits,
|
||||
attackerWantsCastles,
|
||||
/* includeCastleBonus=*/true,
|
||||
defenderUnits,
|
||||
cachedHexMap,
|
||||
roundsRemaining,
|
||||
attackLocationsForAttacker,
|
||||
locationsCausingDanger,
|
||||
notBravingApd,
|
||||
GetMeteorRange(),
|
||||
GetMeteorCastVigorCost());
|
||||
|
||||
attackerUnitsValue += distanceMultiplier * uv;
|
||||
}
|
||||
|
||||
auto attackLocationsForDefender = GetAlCache()->CachedLocations(attackerUnits, isLateGame);
|
||||
const auto &locationsCausingDangerForAttacker = attackLocationsForDefender.AllLocations();
|
||||
|
||||
for (const Unit *unit : defenderUnits) {
|
||||
auto defenderUnitId = unit->unit_id();
|
||||
const int battTypeId = unit->battalion().type();
|
||||
// Cache battalion type reference to avoid shared_ptr atomic operations
|
||||
const auto &battalionType = GetBattalionType(static_cast<BattalionTypeId>(battTypeId));
|
||||
|
||||
// Cache APD lookups for this defender unit
|
||||
const auto *defenderNotBravingApd =
|
||||
GetApdCache()->GetRaw(cachedHexMap, mapId, battalionType, false);
|
||||
|
||||
auto dv = UnitValue(
|
||||
unit,
|
||||
false,
|
||||
attackerUnits,
|
||||
attackerWantsCastles,
|
||||
/* includeCastleBonus=*/!defenderShouldScatter,
|
||||
defenderUnits,
|
||||
cachedHexMap,
|
||||
roundsRemaining,
|
||||
attackLocationsForDefender,
|
||||
locationsCausingDangerForAttacker,
|
||||
defenderNotBravingApd,
|
||||
GetMeteorRange(),
|
||||
GetMeteorCastVigorCost());
|
||||
|
||||
double distanceMultiplier = 1.0;
|
||||
|
||||
// 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);
|
||||
myLocationSet.Add(unit->location());
|
||||
|
||||
DIST_T closestDistanceToEnemy = 999;
|
||||
for (const auto &attackerUnit : attackerUnits) {
|
||||
const int attackerBattTypeId = attackerUnit->battalion().type();
|
||||
// Cache attacker battalion type reference in nested loop
|
||||
const auto &attackerBattalionType =
|
||||
GetBattalionType(static_cast<BattalionTypeId>(attackerBattTypeId));
|
||||
const DIST_T thisDistance = distanceCache.GetOrCompute(
|
||||
attackerUnit,
|
||||
unit->location(),
|
||||
GetApdCache()->GetRaw(cachedHexMap, mapId, attackerBattalionType, false),
|
||||
attackerBattalionType->allowsBraveWater ? GetApdCache()->GetRaw(
|
||||
cachedHexMap,
|
||||
mapId,
|
||||
attackerBattalionType,
|
||||
true,
|
||||
braveWaterCost)
|
||||
: nullptr,
|
||||
cachedHexMap);
|
||||
if (thisDistance < closestDistanceToEnemy) {
|
||||
closestDistanceToEnemy = thisDistance;
|
||||
}
|
||||
}
|
||||
|
||||
// If the best we can do puts us very close to the enemy, and the unit is almost
|
||||
// destroyed, return a negative value; better to flee
|
||||
if (unit->can_flee() && closestDistanceToEnemy < 5 && unit->battalion().size() < 10) {
|
||||
distanceMultiplier = -1;
|
||||
} else {
|
||||
DIST_T closestDistanceToFriendly = 1;
|
||||
if (defenderUnits.size() > 1) {
|
||||
for (const auto &defenderUnit : defenderUnits) {
|
||||
if (defenderUnit->unit_id() != defenderUnitId) {
|
||||
const int defenderBattTypeId = defenderUnit->battalion().type();
|
||||
// Cache defender battalion type reference in nested loop
|
||||
const auto &defenderBattalionType = GetBattalionType(
|
||||
static_cast<BattalionTypeId>(defenderBattTypeId));
|
||||
const DIST_T thisDistance = distanceCache.GetOrCompute(
|
||||
defenderUnit,
|
||||
unit->location(),
|
||||
GetApdCache()->GetRaw(
|
||||
cachedHexMap,
|
||||
mapId,
|
||||
defenderBattalionType,
|
||||
false),
|
||||
defenderBattalionType->allowsBraveWater
|
||||
? GetApdCache()->GetRaw(
|
||||
cachedHexMap,
|
||||
mapId,
|
||||
defenderBattalionType,
|
||||
true,
|
||||
braveWaterCost)
|
||||
: nullptr,
|
||||
cachedHexMap);
|
||||
if (thisDistance < closestDistanceToEnemy) {
|
||||
closestDistanceToFriendly = thisDistance;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
distanceMultiplier =
|
||||
(closestDistanceToEnemy + closestDistanceToFriendly / 5.0) / 5.0;
|
||||
}
|
||||
}
|
||||
|
||||
defenderUnitsValue += distanceMultiplier * dv;
|
||||
}
|
||||
|
||||
defenderUnitsValue *= defenderAdvantage;
|
||||
|
||||
return UnitsScoreComponents{attackerUnitsValue, defenderUnitsValue};
|
||||
}
|
||||
|
||||
auto AbstractAIScoreCalculator::FindDefenderPlayerInfo(const GameStateW &gameState) const
|
||||
-> const net::eagle0::shardok::storage::fb::PlayerInfo * {
|
||||
const auto *playerInfos = gameState->player_infos();
|
||||
if (playerInfos == nullptr) { return nullptr; }
|
||||
|
||||
for (const net::eagle0::shardok::storage::fb::PlayerInfo *pi : *playerInfos) {
|
||||
if (pi->is_defender()) return pi;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto AbstractAIScoreCalculator::CalculateAttackerVictoryConditionScore(
|
||||
const GameStateW &gameState,
|
||||
const AIStrategy &attackerStrategy,
|
||||
const CoordsSet &castleCoords) const -> ScoreValue {
|
||||
ScoreValue victoryConditionTotal = 0.0;
|
||||
|
||||
const auto *playerInfos = gameState->player_infos();
|
||||
if (playerInfos == nullptr) { return 0.0; }
|
||||
|
||||
for (const net::eagle0::shardok::storage::fb::PlayerInfo *pi : *playerInfos) {
|
||||
if (pi->is_defender()) continue;
|
||||
|
||||
switch (attackerStrategy.strategyType) {
|
||||
case AIStrategy::STRATEGY_CROSS_RIVERS:
|
||||
victoryConditionTotal += WaterCrossingScore(
|
||||
pi->player_id(),
|
||||
[this](BattalionTypeId typeId) { return GetBattalionType(typeId); },
|
||||
gameState,
|
||||
castleCoords,
|
||||
attackerStrategy.targetLocations,
|
||||
GetApdCache());
|
||||
break;
|
||||
|
||||
case AIStrategy::STRATEGY_ATTACK_CASTLES:
|
||||
case AIStrategy::STRATEGY_ATTACK_UNITS:
|
||||
// already factored into AttackerUnitsScore
|
||||
break;
|
||||
|
||||
case AIStrategy::STRATEGY_HOLD_CASTLES:
|
||||
victoryConditionTotal += AttackerHoldsCriticalTilesVictoryScore(
|
||||
gameState,
|
||||
castleCoords,
|
||||
pi,
|
||||
GetApdCache(),
|
||||
GetAlCache(),
|
||||
[this](BattalionTypeId typeId) { return GetBattalionType(typeId); },
|
||||
GetBraveWaterCost());
|
||||
break;
|
||||
|
||||
case AIStrategy::STRATEGY_SCATTER:
|
||||
throw ShardokInternalErrorException("Attacker cannot use ScatterStrategy");
|
||||
|
||||
case AIStrategy::STRATEGY_FLEE:
|
||||
// FLEE strategy is handled specially by each subclass
|
||||
// Return 0 here and let the caller handle it
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
return victoryConditionTotal;
|
||||
}
|
||||
|
||||
auto AbstractAIScoreCalculator::DefenderScoreForState(
|
||||
const GameStateW &gameState,
|
||||
const AIStrategy &defenderStrategy,
|
||||
const CoordsSet &castleCoords,
|
||||
const int roundsRemaining) const -> ScoreValue {
|
||||
// Check for terminal states
|
||||
if (gameState->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
|
||||
const auto *winningIds = gameState->status()->winning_shardok_ids();
|
||||
const auto *playerInfos = gameState->player_infos();
|
||||
if (winningIds != nullptr && playerInfos != nullptr) {
|
||||
for (const PlayerId winningPid : *winningIds) {
|
||||
if (winningPid < 0) continue;
|
||||
if (defenderStrategy.strategyType == AIStrategy::STRATEGY_FLEE) {
|
||||
return InterpretDefenderOutcome(GameOutcome::FLEE_OUTCOME);
|
||||
}
|
||||
if (playerInfos->Get(winningPid)->is_defender()) {
|
||||
return InterpretDefenderOutcome(GameOutcome::DEFENDER_VICTORY);
|
||||
}
|
||||
return InterpretDefenderOutcome(GameOutcome::ATTACKER_VICTORY);
|
||||
}
|
||||
}
|
||||
return InterpretDefenderOutcome(GameOutcome::ATTACKER_VICTORY);
|
||||
}
|
||||
if (gameState->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW) {
|
||||
return InterpretDefenderOutcome(GameOutcome::DRAW);
|
||||
}
|
||||
|
||||
// Delegate to strategy-specific methods (implemented by subclasses)
|
||||
switch (defenderStrategy.strategyType) {
|
||||
case AIStrategy::STRATEGY_ATTACK_CASTLES:
|
||||
throw ShardokInternalErrorException("Defender cannot use AttackCastlesStrategy");
|
||||
case AIStrategy::STRATEGY_ATTACK_UNITS:
|
||||
throw ShardokInternalErrorException("Defender cannot use AttackUnitsStrategy");
|
||||
case AIStrategy::STRATEGY_CROSS_RIVERS:
|
||||
throw ShardokInternalErrorException("Defender cannot use CrossRiversStrategy");
|
||||
case AIStrategy::STRATEGY_HOLD_CASTLES:
|
||||
return DefenderHoldCastlesStrategyScoreForState(
|
||||
gameState,
|
||||
castleCoords,
|
||||
roundsRemaining);
|
||||
case AIStrategy::STRATEGY_SCATTER:
|
||||
return DefenderScatterStrategyScoreForState(gameState, roundsRemaining);
|
||||
case AIStrategy::STRATEGY_FLEE: return DefenderFleeStrategyScoreForState(gameState);
|
||||
}
|
||||
throw ShardokInternalErrorException("Escaped AIStrategy switch");
|
||||
}
|
||||
|
||||
auto AbstractAIScoreCalculator::DefenderScatterStrategyScoreForState(
|
||||
const GameStateW &gameState,
|
||||
const int roundsRemaining) const -> ScoreValue {
|
||||
// Check for terminal states
|
||||
if (gameState->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
|
||||
const auto *winningIds = gameState->status()->winning_shardok_ids();
|
||||
const auto *playerInfos = gameState->player_infos();
|
||||
if (winningIds != nullptr && playerInfos != nullptr) {
|
||||
for (const PlayerId winningPid : *winningIds) {
|
||||
if (winningPid < 0) continue;
|
||||
if (playerInfos->Get(winningPid)->is_defender()) {
|
||||
return InterpretDefenderOutcome(GameOutcome::DEFENDER_VICTORY);
|
||||
}
|
||||
return InterpretDefenderOutcome(GameOutcome::ATTACKER_VICTORY);
|
||||
}
|
||||
}
|
||||
return InterpretDefenderOutcome(GameOutcome::DEFENDER_VICTORY);
|
||||
}
|
||||
if (gameState->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW) {
|
||||
return InterpretDefenderOutcome(GameOutcome::DRAW);
|
||||
}
|
||||
|
||||
// Get units score components
|
||||
const auto mapId = ActionPointDistancesCache::GetMapId(gameState->hex_map());
|
||||
const auto components = CalculateUnitsScoreComponents(
|
||||
gameState,
|
||||
roundsRemaining,
|
||||
/* attackerWantsCastles=*/false,
|
||||
/* defenderShouldScatter=*/true,
|
||||
{},
|
||||
mapId);
|
||||
|
||||
// Combine using subclass-specific logic
|
||||
return CombineDefenderScatterScores(components);
|
||||
}
|
||||
|
||||
auto AbstractAIScoreCalculator::DefenderHoldCastlesStrategyScoreForState(
|
||||
const GameStateW &gameState,
|
||||
const CoordsSet &castleCoords,
|
||||
const int roundsRemaining) const -> ScoreValue {
|
||||
// Get units score components
|
||||
const auto components = CalculateUnitsScoreComponents(
|
||||
gameState,
|
||||
roundsRemaining,
|
||||
/* attackerWantsCastles=*/true,
|
||||
/* defenderShouldScatter=*/false,
|
||||
{},
|
||||
ActionPointDistancesCache::GetMapId(gameState->hex_map()));
|
||||
|
||||
// Get victory condition score from defender's perspective
|
||||
const PlayerInfo *defenderPi = FindDefenderPlayerInfo(gameState);
|
||||
|
||||
// Handle null defenderPi gracefully
|
||||
if (defenderPi == nullptr) {
|
||||
// No defender player found - return neutral score using components only
|
||||
return CombineDefenderHoldCastlesScores(components, 0.0, roundsRemaining);
|
||||
}
|
||||
|
||||
const double victoryConditionScore =
|
||||
DefenderHoldsCriticalTilesVictoryScore(gameState, castleCoords, defenderPi);
|
||||
|
||||
// Combine using subclass-specific logic
|
||||
return CombineDefenderHoldCastlesScores(components, victoryConditionScore, roundsRemaining);
|
||||
}
|
||||
|
||||
auto AbstractAIScoreCalculator::AttackerScoreForState(
|
||||
const GameStateW &gameState,
|
||||
const AIStrategy &attackerStrategy,
|
||||
const CoordsSet &castleCoords,
|
||||
const int roundsRemaining) const -> ScoreValue {
|
||||
// Check for terminal states
|
||||
if (gameState->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
|
||||
const auto *winningIds = gameState->status()->winning_shardok_ids();
|
||||
const auto *playerInfos = gameState->player_infos();
|
||||
if (winningIds != nullptr && playerInfos != nullptr) {
|
||||
for (const PlayerId winningPid : *winningIds) {
|
||||
if (winningPid < 0) continue;
|
||||
if (attackerStrategy.strategyType == AIStrategy::STRATEGY_FLEE) {
|
||||
return InterpretAttackerOutcome(GameOutcome::FLEE_OUTCOME);
|
||||
}
|
||||
if (playerInfos->Get(winningPid)->is_defender()) {
|
||||
return InterpretAttackerOutcome(GameOutcome::DEFENDER_VICTORY);
|
||||
}
|
||||
return InterpretAttackerOutcome(GameOutcome::ATTACKER_VICTORY);
|
||||
}
|
||||
}
|
||||
return InterpretAttackerOutcome(GameOutcome::ATTACKER_VICTORY);
|
||||
}
|
||||
if (gameState->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW) {
|
||||
return InterpretAttackerOutcome(GameOutcome::DRAW);
|
||||
}
|
||||
|
||||
// Handle FLEE strategy specially
|
||||
if (attackerStrategy.strategyType == AIStrategy::STRATEGY_FLEE) {
|
||||
return AttackerFleeStrategyScoreForState(gameState);
|
||||
}
|
||||
|
||||
// Edge case: no units means call subclass method to handle neutral state
|
||||
// This is defensive - some subclasses may want special handling
|
||||
const auto *units = gameState->units();
|
||||
if (units == nullptr || units->size() == 0) {
|
||||
// Call CombineAttackerScores with all zeros
|
||||
return CombineAttackerScores(UnitsScoreComponents{0.0, 0.0}, 0.0, roundsRemaining);
|
||||
}
|
||||
|
||||
// Get units score components
|
||||
const auto mapId = ActionPointDistancesCache::GetMapId(gameState->hex_map());
|
||||
const auto components = CalculateUnitsScoreComponents(
|
||||
gameState,
|
||||
roundsRemaining,
|
||||
attackerStrategy.strategyType == AIStrategy::STRATEGY_HOLD_CASTLES,
|
||||
/* defenderShouldScatter=*/false,
|
||||
attackerStrategy.targetPriorities,
|
||||
mapId);
|
||||
|
||||
// Calculate victory condition score
|
||||
const ScoreValue victoryConditionTotal =
|
||||
CalculateAttackerVictoryConditionScore(gameState, attackerStrategy, castleCoords);
|
||||
|
||||
// Combine scores using subclass-specific logic
|
||||
// Standard: uses difference and multiplier
|
||||
// Normalized: uses normalization formula
|
||||
return CombineAttackerScores(components, victoryConditionTotal, roundsRemaining);
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
@@ -0,0 +1,176 @@
|
||||
//
|
||||
// Abstract base class for AI score calculator implementations
|
||||
// This file is private to the ai/score package
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_ABSTRACT_AI_SCORE_CALCULATOR_HPP
|
||||
#define EAGLE0_ABSTRACT_AI_SCORE_CALCULATOR_HPP
|
||||
|
||||
#include <memory>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "AIScoreCalculatorSharedUtilities.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackGroups.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/BattalionType.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/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state_generated.h"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
using net::eagle0::shardok::storage::fb::BattalionTypeId;
|
||||
|
||||
using APDCache = std::shared_ptr<ActionPointDistancesCache>;
|
||||
using ALCache = std::unique_ptr<AttackLocationsCache>;
|
||||
using score_calculator_internal::UnitsScoreComponents;
|
||||
|
||||
/// Enum representing terminal game outcomes for score interpretation
|
||||
enum class GameOutcome {
|
||||
ATTACKER_VICTORY,
|
||||
DEFENDER_VICTORY,
|
||||
DRAW,
|
||||
FLEE_OUTCOME // Special outcome for FLEE strategy
|
||||
};
|
||||
|
||||
/// Abstract base class providing shared functionality for AI score calculators.
|
||||
/// Contains common member variables, accessor methods, and shared helper logic.
|
||||
/// This class is private to the ai/score package.
|
||||
class AbstractAIScoreCalculator : public AIScoreCalculator {
|
||||
protected:
|
||||
AbstractAIScoreCalculator(
|
||||
int maxRounds,
|
||||
ActionPoints braveWaterCost,
|
||||
int meteorRange,
|
||||
double meteorCastVigorCost,
|
||||
int minimumFleeOddsThreshold,
|
||||
int desperateFleeThreshold,
|
||||
std::vector<BattalionTypeSPtr> battalionTypes,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache)
|
||||
: maxRounds_(maxRounds),
|
||||
braveWaterCost_(braveWaterCost),
|
||||
meteorRange_(meteorRange),
|
||||
meteorCastVigorCost_(meteorCastVigorCost),
|
||||
minimumFleeOddsThreshold_(minimumFleeOddsThreshold),
|
||||
desperateFleeThreshold_(desperateFleeThreshold),
|
||||
battalionTypes_(std::move(battalionTypes)),
|
||||
apdCache_(apdCache),
|
||||
alCache_(alCache) {}
|
||||
|
||||
// Protected accessor methods available to subclasses
|
||||
[[nodiscard]] inline auto GetBattalionType(BattalionTypeId typeId) const
|
||||
-> const BattalionTypeSPtr & {
|
||||
return battalionTypes_[typeId];
|
||||
}
|
||||
|
||||
[[nodiscard]] auto GetApdCache() const -> const APDCache & { return apdCache_; }
|
||||
[[nodiscard]] auto GetAlCache() const -> const ALCache & { return alCache_; }
|
||||
[[nodiscard]] auto GetBraveWaterCost() const -> ActionPoints { return braveWaterCost_; }
|
||||
[[nodiscard]] auto GetMaxRounds() const -> int { return maxRounds_; }
|
||||
[[nodiscard]] auto GetMeteorRange() const -> int { return meteorRange_; }
|
||||
[[nodiscard]] auto GetMeteorCastVigorCost() const -> double { return meteorCastVigorCost_; }
|
||||
[[nodiscard]] auto GetMinimumFleeOddsThreshold() const -> int {
|
||||
return minimumFleeOddsThreshold_;
|
||||
}
|
||||
[[nodiscard]] auto GetDesperateFleeThreshold() const -> int { return desperateFleeThreshold_; }
|
||||
|
||||
// Protected helper method that both subclasses can use
|
||||
// Returns separate attacker and defender unit values
|
||||
[[nodiscard]] auto CalculateUnitsScoreComponents(
|
||||
const GameStateW &gameState,
|
||||
int roundsRemaining,
|
||||
bool attackerWantsCastles,
|
||||
bool defenderShouldScatter,
|
||||
const vector<TargetPriorityList> &attackerTargetPriorities,
|
||||
const MapId &mapId) const -> UnitsScoreComponents;
|
||||
|
||||
// Helper to find the defender PlayerInfo
|
||||
[[nodiscard]] auto FindDefenderPlayerInfo(const GameStateW &gameState) const
|
||||
-> const net::eagle0::shardok::storage::fb::PlayerInfo *;
|
||||
|
||||
// Calculate victory condition score for attacker strategies
|
||||
// Returns the raw victory condition score (not yet combined with units score)
|
||||
[[nodiscard]] auto CalculateAttackerVictoryConditionScore(
|
||||
const GameStateW &gameState,
|
||||
const AIStrategy &attackerStrategy,
|
||||
const CoordsSet &castleCoords) const -> ScoreValue;
|
||||
|
||||
// Pure virtual methods for subclasses to interpret game outcomes
|
||||
[[nodiscard]] virtual auto InterpretDefenderOutcome(GameOutcome outcome) const
|
||||
-> ScoreValue = 0;
|
||||
[[nodiscard]] virtual auto InterpretAttackerOutcome(GameOutcome outcome) const
|
||||
-> ScoreValue = 0;
|
||||
|
||||
// Shared implementation of DefenderScoreForState that uses InterpretDefenderOutcome
|
||||
[[nodiscard]] auto DefenderScoreForState(
|
||||
const GameStateW &gameState,
|
||||
const AIStrategy &defenderStrategy,
|
||||
const CoordsSet &castleCoords,
|
||||
int roundsRemaining) const -> ScoreValue;
|
||||
|
||||
// Shared implementations that delegate to pure virtual methods
|
||||
[[nodiscard]] auto DefenderScatterStrategyScoreForState(
|
||||
const GameStateW &gameState,
|
||||
int roundsRemaining) const -> ScoreValue;
|
||||
|
||||
[[nodiscard]] auto DefenderHoldCastlesStrategyScoreForState(
|
||||
const GameStateW &gameState,
|
||||
const CoordsSet &castleCoords,
|
||||
int roundsRemaining) const -> ScoreValue;
|
||||
|
||||
// Pure virtual methods for combining defender scores
|
||||
[[nodiscard]] virtual auto CombineDefenderScatterScores(
|
||||
const UnitsScoreComponents &components) const -> ScoreValue = 0;
|
||||
|
||||
[[nodiscard]] virtual auto CombineDefenderHoldCastlesScores(
|
||||
const UnitsScoreComponents &components,
|
||||
double victoryConditionScore,
|
||||
int roundsRemaining) const -> ScoreValue = 0;
|
||||
|
||||
[[nodiscard]] virtual auto DefenderFleeStrategyScoreForState(const GameStateW &gameState) const
|
||||
-> ScoreValue = 0;
|
||||
|
||||
// Shared implementation of AttackerScoreForState that uses InterpretAttackerOutcome
|
||||
[[nodiscard]] auto AttackerScoreForState(
|
||||
const GameStateW &gameState,
|
||||
const AIStrategy &attackerStrategy,
|
||||
const CoordsSet &castleCoords,
|
||||
int roundsRemaining) const -> ScoreValue;
|
||||
|
||||
// Pure virtual method for attacker flee strategy scoring
|
||||
[[nodiscard]] virtual auto AttackerFleeStrategyScoreForState(const GameStateW &gameState) const
|
||||
-> ScoreValue = 0;
|
||||
|
||||
// Pure virtual method for combining units score and victory condition score
|
||||
// This is where Standard and Normalized diverge in their scoring approach
|
||||
// Standard: uses difference and applies multiplier
|
||||
// Normalized: uses normalization formula with separate attacker/defender values
|
||||
[[nodiscard]] virtual auto CombineAttackerScores(
|
||||
const UnitsScoreComponents &components,
|
||||
double victoryConditionScore,
|
||||
int roundsRemaining) const -> ScoreValue = 0;
|
||||
|
||||
private:
|
||||
// Scalar settings extracted from SettingsGetter
|
||||
int maxRounds_;
|
||||
ActionPoints braveWaterCost_;
|
||||
int meteorRange_;
|
||||
double meteorCastVigorCost_;
|
||||
int minimumFleeOddsThreshold_;
|
||||
int desperateFleeThreshold_;
|
||||
|
||||
// Battalion type lookup vector (indexed by BattalionTypeId)
|
||||
std::vector<BattalionTypeSPtr> battalionTypes_;
|
||||
|
||||
// Caches (stored as references)
|
||||
const APDCache &apdCache_;
|
||||
const ALCache &alCache_;
|
||||
};
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_ABSTRACT_AI_SCORE_CALCULATOR_HPP
|
||||
@@ -0,0 +1,50 @@
|
||||
load("//tools:copts.bzl", "COPTS")
|
||||
|
||||
# Abstract base class for score calculators
|
||||
cc_library(
|
||||
name = "abstract_ai_score_calculator",
|
||||
srcs = ["AbstractAIScoreCalculator.cpp"],
|
||||
hdrs = ["AbstractAIScoreCalculator.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = ["//src/main/cpp/net/eagle0/shardok/ai/score:__pkg__"], # Private to score package
|
||||
deps = [
|
||||
":ai_score_calculator_shared_utilities",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_attack_groups",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_attack_locations",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_utilities",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_strategy",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_unit_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_water_crossing_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_victory_condition_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:battalion_type",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
|
||||
"//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",
|
||||
],
|
||||
)
|
||||
|
||||
# Private utilities shared between score calculators
|
||||
cc_library(
|
||||
name = "ai_score_calculator_shared_utilities",
|
||||
srcs = ["AIScoreCalculatorSharedUtilities.cpp"],
|
||||
hdrs = ["AIScoreCalculatorSharedUtilities.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = ["//src/main/cpp/net/eagle0/shardok/ai/score:__pkg__"], # Private to score package
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_attack_groups",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_utilities",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:battalion_type",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/map:coords_set",
|
||||
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
|
||||
"//src/main/flatbuffer/net/eagle0/shardok/storage:hex_map_cc_fbs",
|
||||
"//src/main/flatbuffer/net/eagle0/shardok/storage:unit_cc_fbs",
|
||||
"@gtl",
|
||||
],
|
||||
)
|
||||
@@ -19,10 +19,10 @@ cc_binary(
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_attacker_strategy_selector",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_defender_strategy_selector",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_iterative_deepening",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_time_budget",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_water_crossing_command_chooser",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:shardok_ai_client",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
|
||||
|
||||
@@ -95,7 +95,7 @@ cc_binary(
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_iterative_deepening",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_attacker_strategy_selector",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_defender_strategy_selector",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_time_budget",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_water_crossing_command_chooser",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
|
||||
@@ -68,6 +68,10 @@ vector<shared_ptr<ShardokAIClient>> ShardokGameController::MakeAIClients(
|
||||
const unique_ptr<ShardokEngine> &e) {
|
||||
vector<shared_ptr<ShardokAIClient>> aic;
|
||||
|
||||
mcts::MCTSConfig mctsConfig;
|
||||
mctsConfig.backpropagationPolicy = mcts::MCTSBackpropagationPolicy::AVERAGING;
|
||||
mctsConfig.maxPlayerFlips = 0;
|
||||
mctsConfig.simulationPolicy = mcts::MCTSSimulationPolicy::WEIGHTED_HEURISTIC;
|
||||
for (const auto &pi : e->GetPlayerInfos()) {
|
||||
if (pi.is_ai()) {
|
||||
auto newClient = std::make_shared<ShardokAIClient>(
|
||||
@@ -75,7 +79,13 @@ vector<shared_ptr<ShardokAIClient>> ShardokGameController::MakeAIClients(
|
||||
pi.is_defender(),
|
||||
e->GetCurrentGameState()->hex_map(),
|
||||
e->GetGameSettings()->GetGetter(),
|
||||
AIAlgorithmType::ITERATIVE_DEEPENING);
|
||||
AIAlgorithmType::ITERATIVE_DEEPENING,
|
||||
ScoringCalculatorType::MCTS_OPTIMIZED,
|
||||
mctsConfig);
|
||||
|
||||
// MCTS config is dynamically adjusted in ShardokAIClient based on proximity:
|
||||
// - Far from enemy: AVERAGING with maxPlayerFlips=0 (natural wandering prevention)
|
||||
// - Close to enemy: MINIMAX with maxPlayerFlips=1 (adversarial lookahead)
|
||||
|
||||
aic.push_back(newClient);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ cc_library(
|
||||
hdrs = ["ActionPointDistances.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__pkg__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/library/action_point_distances:__pkg__",
|
||||
],
|
||||
deps = [
|
||||
@@ -23,8 +24,8 @@ cc_library(
|
||||
hdrs = ["ActionPointDistancesCache.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__pkg__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__pkg__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/library/action_point_distances:__pkg__",
|
||||
],
|
||||
deps = [
|
||||
@@ -44,6 +45,7 @@ cc_library(
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__pkg__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_battle_simulator:__pkg__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/library/action_point_distances:__pkg__",
|
||||
],
|
||||
deps = [
|
||||
|
||||
@@ -28,7 +28,7 @@ using UnitViewProto = net::eagle0::shardok::api::UnitView;
|
||||
using Coords = net::eagle0::shardok::storage::fb::Coords;
|
||||
using Unit = net::eagle0::shardok::storage::fb::Unit;
|
||||
|
||||
constexpr int8_t kGuessedHeroStat = 50;
|
||||
constexpr int8_t kGuessedHeroStat = 75;
|
||||
constexpr int8_t kGuessedBattalionStat = 0;
|
||||
constexpr int8_t kGuessedMorale = 50;
|
||||
|
||||
@@ -363,10 +363,13 @@ auto GuessedUnit(
|
||||
} else {
|
||||
unit.mutate_stun_rounds_remaining(0);
|
||||
}
|
||||
// Volleys: use real value if known, otherwise conservative placeholder
|
||||
if (uv.has_volleys_remaining()) {
|
||||
unit.mutate_volleys_remaining(uv.volleys_remaining().value());
|
||||
} else {
|
||||
unit.mutate_volleys_remaining(0);
|
||||
// Conservative estimate - assume they have volleys if we don't know
|
||||
// This prevents AI from underestimating archery threat
|
||||
unit.mutate_volleys_remaining(5);
|
||||
}
|
||||
|
||||
unit.mutate_has_moved_in_zoc(uv.has_moved_in_zoc());
|
||||
@@ -377,11 +380,39 @@ auto GuessedUnit(
|
||||
unit.mutable_opponent_knowledge()->Mutate(toPid, uv.my_knowledge());
|
||||
|
||||
unit.mutate_can_flee(uv.can_flee());
|
||||
unit.mutate_can_archery(uv.can_archery());
|
||||
unit.mutate_can_start_fire(uv.can_start_fire());
|
||||
|
||||
// For can_archery and can_start_fire:
|
||||
// - If we have battalion stats (armament/training known), trust the proto values
|
||||
// - Otherwise, be conservative and assume true (opponent might be able to)
|
||||
// - Exception: Units without heroes can't archery or start fires
|
||||
const bool haveStats = uv.battalion().has_armament() && uv.battalion().has_training();
|
||||
const bool hasHero = uv.has_attached_hero();
|
||||
|
||||
if (haveStats) {
|
||||
// We know the unit's capabilities, use the actual values
|
||||
unit.mutate_can_archery(uv.can_archery());
|
||||
unit.mutate_can_start_fire(uv.can_start_fire());
|
||||
} else {
|
||||
// We don't know - be conservative (assume they can, so AI doesn't underestimate threat)
|
||||
// But units need heroes for archery and fire
|
||||
unit.mutate_can_archery(hasHero);
|
||||
unit.mutate_can_start_fire(hasHero);
|
||||
}
|
||||
|
||||
unit.mutate_fortified(uv.fortified());
|
||||
unit.mutate_targeted_unit(-1);
|
||||
|
||||
// Food: use real value if known (when we know battalion stats), otherwise guess high to avoid
|
||||
// starvation
|
||||
if (uv.has_food_remaining()) {
|
||||
unit.mutate_food_remaining(static_cast<float>(uv.food_remaining().value()));
|
||||
} else {
|
||||
// Conservative estimate - assume they have enough food
|
||||
// This prevents AI simulation from incorrectly applying starvation (size=0)
|
||||
// Typical daily food cost is ~1-2 per unit, so 100 gives plenty of buffer
|
||||
unit.mutate_food_remaining(100.0f);
|
||||
}
|
||||
|
||||
if (uv.has_starting_position_index()) {
|
||||
unit.mutate_starting_position_index(uv.starting_position_index().value());
|
||||
} else {
|
||||
|
||||
+192
-175
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -50,7 +50,7 @@ func mainBuildFileContents(lines [][]string) string {
|
||||
targets = append(targets, oneTarget(snakeKey, capitalizedKey, capitalizedType, value))
|
||||
}
|
||||
|
||||
return `load(":setting_rule.bzl", "scala_setting_library")` + "\n\n" + strings.Join(targets, "\n\n") + "\n"
|
||||
return `load(":setting_rule.bzl", "scala_setting_library")` + "\n\n" + `exports_files(["BUILD.bazel"])` + "\n\n" + strings.Join(targets, "\n\n") + "\n"
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -215,5 +215,10 @@ holyWaveVigorCost 10 double
|
||||
minLookaheadTurns 1 int8
|
||||
lookaheadTimeBudgetCloseInSeconds 3 double
|
||||
lookaheadTimeBudgetFarInSeconds 1.5 double
|
||||
lookaheadTimeBudgetSetup 0.5 double
|
||||
lookaheadTimeBudgetPerCommandCloseMs 100 double
|
||||
lookaheadTimeBudgetPerCommandFarMs 50 double
|
||||
lookaheadTimeBudgetPerCommandSetupMs 25 double
|
||||
aiMinimumFleeOddsThreshold 30 int16
|
||||
aiDesperateFleeThreshold 10 int16
|
||||
aiDesperateFleeThreshold 10 int16
|
||||
lookaheadTimeBudgetMaximumSeconds 5 double
|
||||
|
+2
-2
@@ -184,7 +184,7 @@ object AllianceResolutionHelpers {
|
||||
of = allianceOffer.originatingFactionId,
|
||||
factions = factions
|
||||
)
|
||||
.copy(relationshipLevel = Ally)
|
||||
.copy(relationshipLevel = Ally, resetDate = None)
|
||||
)
|
||||
),
|
||||
ChangedFactionC(
|
||||
@@ -196,7 +196,7 @@ object AllianceResolutionHelpers {
|
||||
of = allianceOffer.targetFactionId,
|
||||
factions = factions
|
||||
)
|
||||
.copy(relationshipLevel = Ally)
|
||||
.copy(relationshipLevel = Ally, resetDate = None)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
load("@rules_scala//scala:scala.bzl", "scala_library")
|
||||
load(":setting_rule.bzl", "scala_setting_library")
|
||||
|
||||
# Export BUILD.bazel file so it can be used by the settings loader generator
|
||||
exports_files(["BUILD.bazel"])
|
||||
|
||||
scala_setting_library(
|
||||
|
||||
@@ -28,7 +28,7 @@ genrule(
|
||||
|
||||
scala_library(
|
||||
name = "settings_loader",
|
||||
srcs = ["SettingsLoader.scala"],
|
||||
srcs = [":settings_loader_src"],
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle:__subpackages__",
|
||||
"//src/main/scala/net/eagle0/util:__subpackages__",
|
||||
|
||||
+1
@@ -42,6 +42,7 @@ object AttackCommandChooser {
|
||||
): Boolean =
|
||||
destinationFactionId.exists(dfid =>
|
||||
dfid != actingFactionId && !LegacyFactionUtils
|
||||
.hasAlliance(actingFactionId, dfid, gameState) && !LegacyFactionUtils
|
||||
.truceExpirationDate(dfid, actingFactionId, gameState)
|
||||
.exists(_ > gameState.currentDate.get)
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ TEST_F(AbstractMCTSAITest, BasicSearchFindsGoodMove) {
|
||||
auto initialState = std::make_unique<TicTacToeState>();
|
||||
|
||||
// Get legal actions
|
||||
auto actions = engine_->getLegalActions(*initialState);
|
||||
auto actions = engine_->getLegalActions(*initialState, 1, 0, 10);
|
||||
ASSERT_EQ(9, actions.size()); // All positions should be available
|
||||
|
||||
// Run search with 100ms time limit
|
||||
@@ -57,7 +57,7 @@ TEST_F(AbstractMCTSAITest, SearchPrefersCenterSquare) {
|
||||
AbstractMCTSAI ai(1, config_);
|
||||
|
||||
auto initialState = std::make_unique<TicTacToeState>();
|
||||
auto actions = engine_->getLegalActions(*initialState);
|
||||
auto actions = engine_->getLegalActions(*initialState, 1, 0, 10);
|
||||
|
||||
const auto result = ai.Search(*engine_, *initialState, std::chrono::milliseconds(500));
|
||||
|
||||
@@ -86,7 +86,7 @@ TEST_F(AbstractMCTSAITest, SearchBlocksOpponentWin) {
|
||||
auto state = std::make_unique<TicTacToeState>(board, 1);
|
||||
|
||||
AbstractMCTSAI ai(1, config_);
|
||||
auto actions = engine_->getLegalActions(*state);
|
||||
auto actions = engine_->getLegalActions(*state, 1, 0, 10);
|
||||
|
||||
ASSERT_FALSE(actions.empty()) << "No legal actions available";
|
||||
|
||||
@@ -120,7 +120,7 @@ TEST_F(AbstractMCTSAITest, SearchFindsWinningMove) {
|
||||
auto state = std::make_unique<TicTacToeState>(board, 1);
|
||||
|
||||
AbstractMCTSAI ai(1, config_);
|
||||
auto actions = engine_->getLegalActions(*state);
|
||||
auto actions = engine_->getLegalActions(*state, 1, 0, 10);
|
||||
|
||||
const auto result = ai.Search(*engine_, *state, std::chrono::milliseconds(50));
|
||||
|
||||
@@ -133,7 +133,7 @@ TEST_F(AbstractMCTSAITest, SearchFindsWinningMove) {
|
||||
|
||||
TEST_F(AbstractMCTSAITest, DifferentSimulationPolicies) {
|
||||
auto initialState = std::make_unique<TicTacToeState>();
|
||||
auto actions = engine_->getLegalActions(*initialState);
|
||||
auto actions = engine_->getLegalActions(*initialState, 1, 0, 10);
|
||||
|
||||
// Test with RANDOM policy
|
||||
config_.simulationPolicy = MCTSSimulationPolicy::RANDOM;
|
||||
@@ -158,7 +158,7 @@ TEST_F(AbstractMCTSAITest, DifferentSimulationPolicies) {
|
||||
|
||||
TEST_F(AbstractMCTSAITest, ExplorationVsExploitation) {
|
||||
auto initialState = std::make_unique<TicTacToeState>();
|
||||
auto actions = engine_->getLegalActions(*initialState);
|
||||
auto actions = engine_->getLegalActions(*initialState, 1, 0, 10);
|
||||
|
||||
// High exploration
|
||||
config_.explorationConstant = 5.0;
|
||||
@@ -186,7 +186,7 @@ TEST_F(AbstractMCTSAITest, PathCompression) {
|
||||
auto state = std::make_unique<TicTacToeState>(board, 1);
|
||||
|
||||
AbstractMCTSAI ai(1, config_);
|
||||
auto actions = engine_->getLegalActions(*state);
|
||||
auto actions = engine_->getLegalActions(*state, 1, 0, 10);
|
||||
|
||||
// Run search to build tree
|
||||
const auto result = ai.Search(*engine_, *state, std::chrono::milliseconds(100));
|
||||
@@ -202,7 +202,7 @@ TEST_F(AbstractMCTSAITest, EmptyActionListHandling) {
|
||||
auto state = std::make_unique<TicTacToeState>(board, 1);
|
||||
|
||||
AbstractMCTSAI ai(1, config_);
|
||||
auto actions = engine_->getLegalActions(*state);
|
||||
auto actions = engine_->getLegalActions(*state, 1, 0, 10);
|
||||
|
||||
EXPECT_TRUE(actions.empty()); // No legal moves in terminal state
|
||||
|
||||
@@ -246,7 +246,7 @@ TEST_F(AbstractMCTSAITest, MultiplePlayersConsistency) {
|
||||
AbstractMCTSAI aiPlayer2(2, config_);
|
||||
|
||||
// Both should be able to search from same state
|
||||
auto actions = engine_->getLegalActions(*initialState);
|
||||
auto actions = engine_->getLegalActions(*initialState, 1, 0, 10);
|
||||
|
||||
const auto result1 = aiPlayer1.Search(*engine_, *initialState, std::chrono::milliseconds(50));
|
||||
const auto result2 = aiPlayer2.Search(*engine_, *initialState, std::chrono::milliseconds(50));
|
||||
@@ -265,7 +265,7 @@ TEST_F(AbstractMCTSAITest, TimeConstraintRespected) {
|
||||
AbstractMCTSAI ai(1, config_);
|
||||
|
||||
auto initialState = std::make_unique<TicTacToeState>();
|
||||
auto actions = engine_->getLegalActions(*initialState);
|
||||
auto actions = engine_->getLegalActions(*initialState, 1, 0, 10);
|
||||
|
||||
const auto startTime = std::chrono::steady_clock::now();
|
||||
const auto result = ai.Search(*engine_, *initialState, std::chrono::milliseconds(100));
|
||||
|
||||
@@ -26,6 +26,7 @@ protected:
|
||||
config_.simulationPolicy = MCTSSimulationPolicy::RANDOM;
|
||||
config_.useMultithreading = false; // Disable for deterministic unit tests
|
||||
config_.numThreads = 1;
|
||||
config_.maxPlayerFlips = 0; // Single-player MCTS by default (backward compatible)
|
||||
}
|
||||
|
||||
// Helper to play a complete game between two MCTS AIs
|
||||
@@ -40,7 +41,7 @@ protected:
|
||||
MCTSPlayerId currentPlayer = currentState->currentPlayerId();
|
||||
AbstractMCTSAI& currentAI = (currentPlayer == player1) ? ai1 : ai2;
|
||||
|
||||
auto actions = engine_->getLegalActions(*currentState);
|
||||
auto actions = engine_->getLegalActions(*currentState, 1, 0, 10);
|
||||
if (actions.empty()) { break; }
|
||||
|
||||
const auto result = currentAI.Search(*engine_, *currentState, timePerMove);
|
||||
@@ -80,7 +81,7 @@ TEST_F(MCTSIntegrationTest, ConsistentPerformanceVsRandom) {
|
||||
|
||||
int moves = 0;
|
||||
while (!state->isTerminal() && moves < 9) {
|
||||
auto actions = engine_->getLegalActions(*state);
|
||||
auto actions = engine_->getLegalActions(*state, 1, 0, 10);
|
||||
if (actions.empty()) { break; }
|
||||
|
||||
if (state->currentPlayerId() == 1) {
|
||||
@@ -121,7 +122,7 @@ TEST_F(MCTSIntegrationTest, SymmetricPositionConsistency) {
|
||||
|
||||
AbstractMCTSAI ai(1, config_);
|
||||
auto emptyState = std::make_unique<TicTacToeState>();
|
||||
auto actions = engine_->getLegalActions(*emptyState);
|
||||
auto actions = engine_->getLegalActions(*emptyState, 1, 0, 10);
|
||||
|
||||
const auto result = ai.Search(*engine_, *emptyState, std::chrono::milliseconds(200));
|
||||
|
||||
@@ -154,7 +155,7 @@ TEST_F(MCTSIntegrationTest, ProgressiveDeepening) {
|
||||
|
||||
for (auto timeBudgetMs : timeBudgets) {
|
||||
AbstractMCTSAI ai(2, config_);
|
||||
auto actions = engine_->getLegalActions(*state);
|
||||
auto actions = engine_->getLegalActions(*state, 1, 0, 10);
|
||||
|
||||
const auto result = ai.Search(*engine_, *state, std::chrono::milliseconds(timeBudgetMs));
|
||||
|
||||
@@ -170,7 +171,7 @@ TEST_F(MCTSIntegrationTest, ProgressiveDeepening) {
|
||||
// Later searches should have evaluated more nodes
|
||||
AbstractMCTSAI ai1(2, config_);
|
||||
AbstractMCTSAI ai2(2, config_);
|
||||
auto actions = engine_->getLegalActions(*state);
|
||||
auto actions = engine_->getLegalActions(*state, 1, 0, 10);
|
||||
|
||||
const auto result1 = ai1.Search(*engine_, *state, std::chrono::milliseconds(10));
|
||||
const auto result2 = ai2.Search(*engine_, *state, std::chrono::milliseconds(100));
|
||||
@@ -198,8 +199,8 @@ TEST_F(MCTSIntegrationTest, TranspositionHandling) {
|
||||
EXPECT_EQ(state1->hash(), state2->hash());
|
||||
|
||||
// MCTS should evaluate them similarly
|
||||
auto actions1 = engine_->getLegalActions(*state1);
|
||||
auto actions2 = engine_->getLegalActions(*state2);
|
||||
auto actions1 = engine_->getLegalActions(*state1, 1, 0, 10);
|
||||
auto actions2 = engine_->getLegalActions(*state2, 1, 0, 10);
|
||||
|
||||
const auto result1 = ai.Search(*engine_, *state1, std::chrono::milliseconds(50));
|
||||
const auto result2 = ai.Search(*engine_, *state2, std::chrono::milliseconds(50));
|
||||
@@ -211,7 +212,7 @@ TEST_F(MCTSIntegrationTest, TranspositionHandling) {
|
||||
TEST_F(MCTSIntegrationTest, DifferentPoliciesComparison) {
|
||||
// Compare different simulation policies
|
||||
auto initialState = std::make_unique<TicTacToeState>();
|
||||
auto actions = engine_->getLegalActions(*initialState);
|
||||
auto actions = engine_->getLegalActions(*initialState, 1, 0, 10);
|
||||
|
||||
// Test each policy
|
||||
std::vector<MCTSSimulationPolicy> policies = {
|
||||
@@ -247,7 +248,7 @@ TEST_F(MCTSIntegrationTest, MemoryStability) {
|
||||
AbstractMCTSAI ai(1, config_);
|
||||
|
||||
auto state = std::make_unique<TicTacToeState>();
|
||||
auto actions = engine_->getLegalActions(*state);
|
||||
auto actions = engine_->getLegalActions(*state, 1, 0, 10);
|
||||
|
||||
// Should handle large search without issues
|
||||
const auto result = ai.Search(*engine_, *state, std::chrono::milliseconds(200));
|
||||
@@ -267,7 +268,7 @@ TEST_F(MCTSIntegrationTest, AdaptiveExplorationExploitation) {
|
||||
config_.explorationConstant = c;
|
||||
AbstractMCTSAI ai(1, config_);
|
||||
|
||||
auto actions = engine_->getLegalActions(*state);
|
||||
auto actions = engine_->getLegalActions(*state, 1, 0, 10);
|
||||
const auto result = ai.Search(*engine_, *state, std::chrono::milliseconds(100));
|
||||
|
||||
uniqueNodesExplored.push_back(result.nodesEvaluated);
|
||||
@@ -283,7 +284,10 @@ TEST_F(MCTSIntegrationTest, AdaptiveExplorationExploitation) {
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(MCTSIntegrationTest, EarlyTerminationOnWin) {
|
||||
TEST_F(MCTSIntegrationTest, EarlyTerminationOnWin_MaxPlayerFlips0) {
|
||||
// Explicitly set maxPlayerFlips=0 for single-player MCTS
|
||||
config_.maxPlayerFlips = 0;
|
||||
|
||||
// If MCTS finds a guaranteed win, it should recognize it quickly
|
||||
// X X . <- Position 2 wins
|
||||
// O . .
|
||||
@@ -292,7 +296,7 @@ TEST_F(MCTSIntegrationTest, EarlyTerminationOnWin) {
|
||||
auto state = std::make_unique<TicTacToeState>(board, 1);
|
||||
|
||||
AbstractMCTSAI ai(1, config_);
|
||||
auto actions = engine_->getLegalActions(*state);
|
||||
auto actions = engine_->getLegalActions(*state, 1, 0, 10);
|
||||
|
||||
const auto result = ai.Search(*engine_, *state, std::chrono::milliseconds(50));
|
||||
|
||||
@@ -306,6 +310,62 @@ TEST_F(MCTSIntegrationTest, EarlyTerminationOnWin) {
|
||||
EXPECT_NEAR(result.bestScore, 1.0, 0.1); // Should be close to perfect score
|
||||
}
|
||||
|
||||
TEST_F(MCTSIntegrationTest, EarlyTerminationOnWin_MaxPlayerFlips1) {
|
||||
// Explicitly set maxPlayerFlips=1 for two-player adversarial MCTS
|
||||
config_.maxPlayerFlips = 1;
|
||||
|
||||
// If MCTS finds a guaranteed win, it should recognize it quickly
|
||||
// X X . <- Position 2 wins
|
||||
// O . .
|
||||
// . . .
|
||||
std::array<int, 9> board = {1, 1, 0, 2, 0, 0, 0, 0, 0};
|
||||
auto state = std::make_unique<TicTacToeState>(board, 1);
|
||||
|
||||
AbstractMCTSAI ai(1, config_);
|
||||
auto actions = engine_->getLegalActions(*state, 1, 0, 10);
|
||||
|
||||
const auto result = ai.Search(*engine_, *state, std::chrono::milliseconds(50));
|
||||
|
||||
// Should find winning move (may be harder with adversarial search)
|
||||
const auto* chosenAction =
|
||||
dynamic_cast<const TicTacToeAction*>(actions[result.bestActionIndex].get());
|
||||
// Position 2 is the immediate win
|
||||
EXPECT_EQ(chosenAction->getPosition(), 2)
|
||||
<< "Two-player MCTS should still find the winning move";
|
||||
|
||||
// Should recognize it's a win
|
||||
EXPECT_TRUE(result.foundWinningMove);
|
||||
EXPECT_NEAR(result.bestScore, 1.0, 0.1); // Should be close to perfect score
|
||||
}
|
||||
|
||||
TEST_F(MCTSIntegrationTest, EarlyTerminationOnWin_MaxPlayerFlips8) {
|
||||
// Explicitly set maxPlayerFlips=8 for full game tree exploration
|
||||
// (TicTacToe has max 9 moves = 8 player flips)
|
||||
config_.maxPlayerFlips = 8;
|
||||
|
||||
// If MCTS finds a guaranteed win, it should recognize it quickly
|
||||
// X X . <- Position 2 wins
|
||||
// O . .
|
||||
// . . .
|
||||
std::array<int, 9> board = {1, 1, 0, 2, 0, 0, 0, 0, 0};
|
||||
auto state = std::make_unique<TicTacToeState>(board, 1);
|
||||
|
||||
AbstractMCTSAI ai(1, config_);
|
||||
auto actions = engine_->getLegalActions(*state, 1, 0, 10);
|
||||
|
||||
const auto result = ai.Search(*engine_, *state, std::chrono::milliseconds(50));
|
||||
|
||||
// Should find winning move with full tree exploration
|
||||
const auto* chosenAction =
|
||||
dynamic_cast<const TicTacToeAction*>(actions[result.bestActionIndex].get());
|
||||
// Position 2 is the immediate win
|
||||
EXPECT_EQ(chosenAction->getPosition(), 2) << "Full-tree MCTS should find the winning move";
|
||||
|
||||
// Should recognize it's a win
|
||||
EXPECT_TRUE(result.foundWinningMove);
|
||||
EXPECT_NEAR(result.bestScore, 1.0, 0.1); // Should be close to perfect score
|
||||
}
|
||||
|
||||
} // namespace test
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
@@ -36,7 +36,8 @@ TEST_F(MCTSNodeTest, RootNodeCreation) {
|
||||
EXPECT_EQ(root.visitCount, 0);
|
||||
EXPECT_EQ(root.totalReward, 0.0);
|
||||
EXPECT_TRUE(root.children.empty());
|
||||
EXPECT_FALSE(root.fullyExpanded);
|
||||
EXPECT_EQ(root.nextUntriedActionIndex, 0);
|
||||
EXPECT_EQ(root.totalActions, 0);
|
||||
EXPECT_EQ(root.depth, 0);
|
||||
EXPECT_EQ(root.playerId, 1);
|
||||
}
|
||||
@@ -133,17 +134,29 @@ TEST_F(MCTSNodeTest, UCB1Calculation) {
|
||||
EXPECT_GT(ucb1Child1, ucb1Child2);
|
||||
}
|
||||
|
||||
TEST_F(MCTSNodeTest, FullyExpandedCheck) {
|
||||
TEST_F(MCTSNodeTest, CanExpandCheck) {
|
||||
MCTSNode root(initialState_->clone(), 1, 0);
|
||||
|
||||
// Initially not fully expanded
|
||||
EXPECT_FALSE(root.fullyExpanded);
|
||||
// Initially has no actions to expand
|
||||
EXPECT_FALSE(root.CanExpand());
|
||||
EXPECT_EQ(root.nextUntriedActionIndex, 0);
|
||||
EXPECT_EQ(root.totalActions, 0);
|
||||
|
||||
// Mark as fully expanded
|
||||
root.fullyExpanded = true;
|
||||
EXPECT_TRUE(root.fullyExpanded);
|
||||
// Set total actions (simulating action discovery)
|
||||
root.totalActions = 3;
|
||||
EXPECT_TRUE(root.CanExpand());
|
||||
|
||||
// Add children
|
||||
// Simulate expanding actions
|
||||
root.nextUntriedActionIndex = 1;
|
||||
EXPECT_TRUE(root.CanExpand()); // Still have actions 1 and 2
|
||||
|
||||
root.nextUntriedActionIndex = 2;
|
||||
EXPECT_TRUE(root.CanExpand()); // Still have action 2
|
||||
|
||||
root.nextUntriedActionIndex = 3;
|
||||
EXPECT_FALSE(root.CanExpand()); // All actions expanded
|
||||
|
||||
// Add children (expansion results)
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
auto child = std::make_unique<MCTSNode>(std::make_unique<TicTacToeState>(), 2, 1);
|
||||
child->parent = &root;
|
||||
@@ -230,22 +243,26 @@ TEST_F(MCTSNodeTest, TerminalNodeHandling) {
|
||||
EXPECT_FALSE(nonTerminalNode.isTerminal);
|
||||
}
|
||||
|
||||
TEST_F(MCTSNodeTest, UntriedActionIndices) {
|
||||
TEST_F(MCTSNodeTest, ActionExpansionCounter) {
|
||||
MCTSNode root(initialState_->clone(), 1, 0);
|
||||
|
||||
// Add some untried action indices
|
||||
root.untriedActionIndices.push_back(0);
|
||||
root.untriedActionIndices.push_back(1);
|
||||
root.untriedActionIndices.push_back(2);
|
||||
// Set total actions (simulating action discovery)
|
||||
root.totalActions = 3;
|
||||
EXPECT_EQ(root.totalActions, 3);
|
||||
EXPECT_EQ(root.nextUntriedActionIndex, 0);
|
||||
|
||||
EXPECT_EQ(root.untriedActionIndices.size(), 3);
|
||||
EXPECT_EQ(root.untriedActionIndices[0], 0);
|
||||
EXPECT_EQ(root.untriedActionIndices[1], 1);
|
||||
EXPECT_EQ(root.untriedActionIndices[2], 2);
|
||||
// Simulate trying actions sequentially
|
||||
EXPECT_EQ(root.nextUntriedActionIndex++, 0); // Returns 0, then increments to 1
|
||||
EXPECT_EQ(root.nextUntriedActionIndex, 1);
|
||||
|
||||
// Simulate trying an action (remove from untried)
|
||||
root.untriedActionIndices.pop_back();
|
||||
EXPECT_EQ(root.untriedActionIndices.size(), 2);
|
||||
EXPECT_EQ(root.nextUntriedActionIndex++, 1); // Returns 1, then increments to 2
|
||||
EXPECT_EQ(root.nextUntriedActionIndex, 2);
|
||||
|
||||
EXPECT_EQ(root.nextUntriedActionIndex++, 2); // Returns 2, then increments to 3
|
||||
EXPECT_EQ(root.nextUntriedActionIndex, 3);
|
||||
|
||||
// All actions have been tried
|
||||
EXPECT_FALSE(root.CanExpand());
|
||||
}
|
||||
|
||||
} // namespace test
|
||||
|
||||
@@ -148,7 +148,10 @@ public:
|
||||
}
|
||||
|
||||
[[nodiscard]] std::vector<std::unique_ptr<MCTSAction>> getLegalActions(
|
||||
const MCTSGameState& state) const override {
|
||||
const MCTSGameState& state,
|
||||
MCTSPlayerId /*rootPlayerId*/,
|
||||
int /*currentPlayerFlips*/,
|
||||
int /*maxPlayerFlips*/) const override {
|
||||
const auto* tttState = dynamic_cast<const TicTacToeState*>(&state);
|
||||
if (!tttState || tttState->isTerminal()) { return {}; }
|
||||
|
||||
|
||||
@@ -8,10 +8,8 @@
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/StandardAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/profession_info.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/GameSettings_test_utils.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/GameStart_test_data.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/HexMap_test_data.hpp"
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackGroups.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/StandardAIScoreCalculator.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/GameSettings_test_utils.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/GameStart_test_data.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/HexMap_test_data.hpp"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/StandardAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/commands/EndTurnCommand.hpp"
|
||||
@@ -151,7 +151,7 @@ protected:
|
||||
protected:
|
||||
GameStateW gameState;
|
||||
std::shared_ptr<ActionPointDistancesCache> apdCache;
|
||||
shardok::ALCache alCache;
|
||||
ALCache alCache;
|
||||
};
|
||||
|
||||
// Test basic filtering functionality - END_TURN commands should always be allowed
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackGroups.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/StandardAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/GameSettings_test_utils.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/GameStart_test_data.hpp"
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/StandardAIScoreCalculator.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/GameSettings_test_utils.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/HexMap_test_data.hpp"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,15 +17,25 @@ protected:
|
||||
void SetUp() override {
|
||||
InitializeGameSettings();
|
||||
|
||||
// Set up test values for the new time budget settings
|
||||
// Set up test values for the new per-command time budget settings
|
||||
// Using 10 commands as test default, these settings produce:
|
||||
// Close: 500 ms/cmd × 10 = 5000 ms
|
||||
// Far: 300 ms/cmd × 10 = 3000 ms
|
||||
// Setup: 50 ms/cmd × 60 = 3000 ms (for typical setup with ~60 placement options)
|
||||
// Maximum: 10 seconds (generous for tests to explore fully)
|
||||
auto setter = GetGameSettingsSetter();
|
||||
setter.SetInt("minLookaheadTurns", 2);
|
||||
setter.SetDouble("lookaheadTimeBudgetCloseInSeconds", 5.0);
|
||||
setter.SetDouble("lookaheadTimeBudgetFarInSeconds", 3.0);
|
||||
setter.SetDouble("lookaheadTimeBudgetPerCommandCloseMs", 500.0);
|
||||
setter.SetDouble("lookaheadTimeBudgetPerCommandFarMs", 300.0);
|
||||
setter.SetDouble("lookaheadTimeBudgetPerCommandSetupMs", 50.0);
|
||||
setter.SetDouble("lookaheadTimeBudgetMaximumSeconds", 10.0);
|
||||
|
||||
settings = GetGameSettings();
|
||||
}
|
||||
|
||||
// Test default: 10 commands
|
||||
static constexpr size_t kDefaultNumCommands = 10;
|
||||
|
||||
GameSettingsSPtr settings;
|
||||
|
||||
// Helper to create a game state with units at specific positions
|
||||
@@ -48,11 +58,28 @@ protected:
|
||||
AddPlayerInfo(fbb, 1, false, 1000),
|
||||
AddPlayerInfo(fbb, 2, true, 1000)});
|
||||
|
||||
// Create a game status with GAME_RUNNING state
|
||||
auto endGameCondition = net::eagle0::shardok::storage::fb::EndGameCondition();
|
||||
endGameCondition.mutate_victory_type(
|
||||
net::eagle0::shardok::storage::fb::VictoryType_UNKNOWN_VICTORY_TYPE);
|
||||
endGameCondition.mutate_draw_details(
|
||||
net::eagle0::shardok::storage::fb::DrawType_UNKNOWN_DRAW_TYPE);
|
||||
endGameCondition.mutate_victory_details(
|
||||
net::eagle0::shardok::storage::fb::VictoryCondition_UNKNOWN_VICTORY_CONDITION);
|
||||
auto winningIdsVec = std::vector<PlayerId>{-1, -1, -1, -1, -1, -1, -1, -1, -1, -1};
|
||||
auto winningIdsOff = fbb.CreateVector(winningIdsVec);
|
||||
auto statusB = net::eagle0::shardok::storage::fb::GameStatusBuilder(fbb);
|
||||
statusB.add_state(net::eagle0::shardok::storage::fb::GameStatus_::State_GAME_RUNNING);
|
||||
statusB.add_end_game_condition(&endGameCondition);
|
||||
statusB.add_winning_shardok_ids(winningIdsOff);
|
||||
auto statusOff = statusB.Finish();
|
||||
|
||||
net::eagle0::shardok::storage::fb::GameStateBuilder gsb(fbb);
|
||||
gsb.add_units(unitsOffset);
|
||||
gsb.add_hex_map(hexMapOffset);
|
||||
gsb.add_player_infos(playerInfoOffset);
|
||||
gsb.add_current_round(5);
|
||||
gsb.add_status(statusOff);
|
||||
|
||||
fbb.Finish(gsb.Finish());
|
||||
return GameStateW(fbb);
|
||||
@@ -66,7 +93,7 @@ TEST_F(AITimeBudgetTest, FarFromEnemiesAndCastles) {
|
||||
{PlayerId(2), Coords(10, 10)} // Defender at (10,10) - far away
|
||||
});
|
||||
|
||||
auto budget = CalculateTimeBudget(PlayerId(1), settings, gameState);
|
||||
auto budget = CalculateTimeBudget(PlayerId(1), settings, gameState, kDefaultNumCommands);
|
||||
|
||||
EXPECT_EQ(budget.remainingBudget.count(), 3000); // 3 seconds for far
|
||||
EXPECT_EQ(budget.minDepthRequired, 2);
|
||||
@@ -80,7 +107,7 @@ TEST_F(AITimeBudgetTest, CloseToEnemyUnit) {
|
||||
{PlayerId(2), Coords(2, 2)} // Defender at (2,2) - within 4 tiles
|
||||
});
|
||||
|
||||
auto budget = CalculateTimeBudget(PlayerId(1), settings, gameState);
|
||||
auto budget = CalculateTimeBudget(PlayerId(1), settings, gameState, kDefaultNumCommands);
|
||||
|
||||
EXPECT_EQ(budget.remainingBudget.count(), 5000); // 5 seconds for close
|
||||
EXPECT_EQ(budget.minDepthRequired, 2);
|
||||
@@ -93,7 +120,7 @@ TEST_F(AITimeBudgetTest, NoEnemiesOrCastles) {
|
||||
{PlayerId(1), Coords(6, 5)} // Attacker alone
|
||||
});
|
||||
|
||||
auto budget = CalculateTimeBudget(PlayerId(1), settings, gameState);
|
||||
auto budget = CalculateTimeBudget(PlayerId(1), settings, gameState, kDefaultNumCommands);
|
||||
|
||||
EXPECT_EQ(budget.remainingBudget.count(), 3000); // 3 seconds for far
|
||||
EXPECT_EQ(budget.minDepthRequired, 2);
|
||||
@@ -107,7 +134,7 @@ TEST_F(AITimeBudgetTest, DefenderGetsCloseTimeBudgetToo) {
|
||||
{PlayerId(2), Coords(0, 0)} // Defender at (0,0) - within 4 tiles
|
||||
});
|
||||
|
||||
auto budget = CalculateTimeBudget(PlayerId(2), settings, gameState);
|
||||
auto budget = CalculateTimeBudget(PlayerId(2), settings, gameState, kDefaultNumCommands);
|
||||
|
||||
EXPECT_EQ(budget.remainingBudget.count(), 5000); // 5 seconds for close
|
||||
EXPECT_EQ(budget.minDepthRequired, 2);
|
||||
@@ -123,7 +150,7 @@ TEST_F(AITimeBudgetTest, UnplacedUnitsIgnored) {
|
||||
{PlayerId(2), Coords(-1, -1)} // Unplaced defender (should be ignored)
|
||||
});
|
||||
|
||||
auto budget = CalculateTimeBudget(PlayerId(1), settings, gameState);
|
||||
auto budget = CalculateTimeBudget(PlayerId(1), settings, gameState, kDefaultNumCommands);
|
||||
|
||||
EXPECT_EQ(budget.remainingBudget.count(), 3000); // 3 seconds for far
|
||||
EXPECT_FALSE(budget.isCloseToEnemy);
|
||||
@@ -137,7 +164,7 @@ TEST_F(AITimeBudgetTest, MultipleUnitsOneClose) {
|
||||
{PlayerId(2), Coords(1, 1)} // Defender close to second attacker
|
||||
});
|
||||
|
||||
auto budget = CalculateTimeBudget(PlayerId(1), settings, gameState);
|
||||
auto budget = CalculateTimeBudget(PlayerId(1), settings, gameState, kDefaultNumCommands);
|
||||
|
||||
EXPECT_EQ(budget.remainingBudget.count(), 5000); // 5 seconds for close
|
||||
EXPECT_TRUE(budget.isCloseToEnemy);
|
||||
@@ -150,7 +177,7 @@ TEST_F(AITimeBudgetTest, ExactlyFourTilesAway) {
|
||||
{PlayerId(2), Coords(4, 0)} // Defender exactly 4 tiles away
|
||||
});
|
||||
|
||||
auto budget = CalculateTimeBudget(PlayerId(1), settings, gameState);
|
||||
auto budget = CalculateTimeBudget(PlayerId(1), settings, gameState, kDefaultNumCommands);
|
||||
|
||||
EXPECT_EQ(budget.remainingBudget.count(), 5000); // 5 seconds for close (≤4 tiles)
|
||||
EXPECT_TRUE(budget.isCloseToEnemy);
|
||||
@@ -163,7 +190,7 @@ TEST_F(AITimeBudgetTest, FiveTilesAway) {
|
||||
{PlayerId(2), Coords(5, 0)} // Defender 5 tiles away
|
||||
});
|
||||
|
||||
auto budget = CalculateTimeBudget(PlayerId(1), settings, gameState);
|
||||
auto budget = CalculateTimeBudget(PlayerId(1), settings, gameState, kDefaultNumCommands);
|
||||
|
||||
EXPECT_EQ(budget.remainingBudget.count(), 3000); // 3 seconds for far (>4 tiles)
|
||||
EXPECT_FALSE(budget.isCloseToEnemy);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/StandardAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/GameSettings_test_utils.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/GameStart_test_data.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/HexMap_test_data.hpp"
|
||||
|
||||
@@ -7,8 +7,7 @@ cc_test(
|
||||
linkstatic = True,
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_flee_decision_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:standard_ai_score_calculator",
|
||||
"//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",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_settings_test_utils",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_start_test_data",
|
||||
@@ -25,8 +24,6 @@ cc_test(
|
||||
linkstatic = True,
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_attacker_strategy_selector",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:standard_ai_score_calculator",
|
||||
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_settings_test_utils",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_start_test_data",
|
||||
@@ -43,8 +40,8 @@ cc_test(
|
||||
linkstatic = True,
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_command_filter",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:standard_ai_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:standard_ai_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/commands:build_bridge_command",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/commands:end_turn_command",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/commands:extinguish_fire_command",
|
||||
@@ -68,8 +65,6 @@ cc_test(
|
||||
linkstatic = True,
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_attack_groups",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:standard_ai_score_calculator",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_settings_test_utils",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_start_test_data",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:gtest_extensions",
|
||||
@@ -86,8 +81,6 @@ cc_test(
|
||||
linkstatic = True,
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_defender_strategy_selector",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:standard_ai_score_calculator",
|
||||
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_settings_test_utils",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_start_test_data",
|
||||
@@ -104,9 +97,7 @@ cc_test(
|
||||
linkstatic = True,
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_distance_debuf",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_unit_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:standard_ai_score_calculator",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_settings_test_utils",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:hex_map_test_data",
|
||||
"@googletest//:gtest",
|
||||
@@ -114,23 +105,6 @@ cc_test(
|
||||
],
|
||||
)
|
||||
|
||||
cc_test(
|
||||
name = "ai_score_calculator_test",
|
||||
srcs = ["AIScoreCalculator_test.cpp"],
|
||||
copts = TEST_COPTS,
|
||||
linkstatic = True,
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:standard_ai_score_calculator",
|
||||
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_settings_test_utils",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_start_test_data",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:hex_map_test_data",
|
||||
"@googletest//:gtest",
|
||||
"@googletest//:gtest_main",
|
||||
],
|
||||
)
|
||||
|
||||
cc_test(
|
||||
name = "ai_score_utilities_test",
|
||||
srcs = ["AIScoreUtilities_test.cpp"],
|
||||
@@ -162,32 +136,13 @@ cc_test(
|
||||
],
|
||||
)
|
||||
|
||||
cc_test(
|
||||
name = "ai_victory_condition_score_calculator_test",
|
||||
srcs = ["AIVictoryConditionScoreCalculator_test.cpp"],
|
||||
copts = TEST_COPTS,
|
||||
linkstatic = True,
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_victory_condition_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:standard_ai_score_calculator",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_settings_test_utils",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_start_test_data",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:hex_map_test_data",
|
||||
"@googletest//:gtest",
|
||||
"@googletest//:gtest_main",
|
||||
],
|
||||
)
|
||||
|
||||
cc_test(
|
||||
name = "ai_water_crossing_calculator_test",
|
||||
srcs = ["AIWaterCrossingCalculator_test.cpp"],
|
||||
copts = TEST_COPTS,
|
||||
linkstatic = True,
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_water_crossing_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:standard_ai_score_calculator",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_settings_test_utils",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_start_test_data",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:hex_map_test_data",
|
||||
@@ -219,7 +174,7 @@ cc_test(
|
||||
linkstatic = True,
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_iterative_deepening",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:standard_ai_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:standard_ai_score_calculator",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_settings_test_utils",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_start_test_data",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:hex_map_test_data",
|
||||
@@ -227,3 +182,37 @@ cc_test(
|
||||
"@googletest//:gtest_main",
|
||||
],
|
||||
)
|
||||
|
||||
cc_test(
|
||||
name = "ai_integration_test",
|
||||
size = "large",
|
||||
srcs = ["AIIntegrationTest.cpp"],
|
||||
copts = TEST_COPTS,
|
||||
data = [
|
||||
"//src/main/resources/net/eagle0/shardok/maps",
|
||||
],
|
||||
linkstatic = True,
|
||||
tags = [
|
||||
"exclusive",
|
||||
"manual",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/common:filesystem_utils",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_iterative_deepening",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:shardok_ai_client",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:mcts_optimized_ai_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:normalized_ai_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:standard_ai_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:fixed_action_point_distances",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/fb_helpers:game_state_helpers",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/fb_helpers:hex_map_helpers",
|
||||
"//src/main/cpp/net/eagle0/shardok/util:map_loader",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:ai_performance_test_helpers",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_settings_test_utils",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_start_test_data",
|
||||
"@googletest//:gtest",
|
||||
"@googletest//:gtest_main",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackGroups.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/StandardAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/StandardAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/GameSettings_test_utils.hpp"
|
||||
|
||||
@@ -6,8 +6,8 @@ cc_test(
|
||||
copts = TEST_COPTS,
|
||||
linkstatic = True,
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:standard_ai_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:shardok_mcts_ai",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:standard_ai_score_calculator",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_settings_test_utils",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_start_test_data",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:hex_map_test_data",
|
||||
@@ -22,8 +22,8 @@ cc_test(
|
||||
copts = TEST_COPTS,
|
||||
linkstatic = True,
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:standard_ai_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:shardok_mcts_ai",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:standard_ai_score_calculator",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_settings_test_utils",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_start_test_data",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:hex_map_test_data",
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
#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/ai/StandardAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/mcts/ShardokMCTSAI.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/StandardAIScoreCalculator.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/test/cpp/net/eagle0/shardok/library/GameSettings_test_utils.hpp"
|
||||
@@ -49,7 +49,7 @@ protected:
|
||||
config.useMultithreading = false; // Single threaded for deterministic testing
|
||||
config.maxSimulationDepth = 10; // Limit depth
|
||||
mctsAI = std::make_unique<ShardokMCTSAI>(
|
||||
PlayerId{0},
|
||||
0, // playerId
|
||||
false, // isDefender
|
||||
strategy,
|
||||
*castleCoords,
|
||||
|
||||
@@ -8,7 +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/StandardAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/StandardAIScoreCalculator.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/test/cpp/net/eagle0/shardok/library/GameSettings_test_utils.hpp"
|
||||
@@ -42,8 +42,8 @@ protected:
|
||||
// Create MCTS AI instance with empty castle coords
|
||||
CoordsSet castleCoords(hexMap);
|
||||
mctsAI = std::make_unique<ShardokMCTSAI>(
|
||||
PlayerId{0}, // playerId
|
||||
false, // isDefender
|
||||
0, // playerId
|
||||
false, // isDefender
|
||||
strategy,
|
||||
castleCoords,
|
||||
*scorer,
|
||||
|
||||
+2
-2
@@ -2,12 +2,12 @@
|
||||
// Created by Dan Crosby on 12/08/21.
|
||||
//
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/StandardAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/StandardAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/GameSettings_test_utils.hpp"
|
||||
+1
-2
@@ -2,14 +2,13 @@
|
||||
// Created by Dan Crosby on 5/11/21.
|
||||
//
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIVictoryConditionScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIVictoryConditionScoreCalculator.hpp"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/StandardAIScoreCalculator.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/GameSettings_test_utils.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/GameStart_test_data.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/HexMap_test_data.hpp"
|
||||
@@ -0,0 +1,67 @@
|
||||
load("//tools:copts.bzl", "TEST_COPTS")
|
||||
|
||||
cc_test(
|
||||
name = "ai_score_calculator_test",
|
||||
srcs = ["AIScoreCalculator_test.cpp"],
|
||||
copts = TEST_COPTS,
|
||||
linkstatic = True,
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:standard_ai_score_calculator",
|
||||
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_settings_test_utils",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_start_test_data",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:hex_map_test_data",
|
||||
"@googletest//:gtest",
|
||||
"@googletest//:gtest_main",
|
||||
],
|
||||
)
|
||||
|
||||
cc_test(
|
||||
name = "ai_victory_condition_score_calculator_test",
|
||||
srcs = ["AIVictoryConditionScoreCalculator_test.cpp"],
|
||||
copts = TEST_COPTS,
|
||||
linkstatic = True,
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_victory_condition_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:standard_ai_score_calculator",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_settings_test_utils",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_start_test_data",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:hex_map_test_data",
|
||||
"@googletest//:gtest",
|
||||
"@googletest//:gtest_main",
|
||||
],
|
||||
)
|
||||
|
||||
cc_test(
|
||||
name = "normalized_ai_score_calculator_test",
|
||||
srcs = ["NormalizedAIScoreCalculator_test.cpp"],
|
||||
copts = TEST_COPTS,
|
||||
linkstatic = True,
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:normalized_ai_score_calculator",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_settings_test_utils",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_start_test_data",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:hex_map_test_data",
|
||||
"@googletest//:gtest",
|
||||
"@googletest//:gtest_main",
|
||||
],
|
||||
)
|
||||
|
||||
cc_test(
|
||||
name = "mcts_optimized_ai_score_calculator_test",
|
||||
srcs = ["MCTSOptimizedAIScoreCalculator_test.cpp"],
|
||||
copts = TEST_COPTS,
|
||||
linkstatic = True,
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:mcts_optimized_ai_score_calculator",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_settings_test_utils",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:game_start_test_data",
|
||||
"//src/test/cpp/net/eagle0/shardok/library:hex_map_test_data",
|
||||
"@googletest//:gtest",
|
||||
"@googletest//:gtest_main",
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,211 @@
|
||||
//
|
||||
// Tests for MCTSOptimizedAIScoreCalculator
|
||||
//
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/MCTSOptimizedAIScoreCalculator.hpp"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#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/score/AIScoreCalculator.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/GameSettings_test_utils.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/GameStart_test_data.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/HexMap_test_data.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
class MCTSOptimizedAIScoreCalculatorTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
InitializeGameSettings();
|
||||
settings = GetGameSettings();
|
||||
|
||||
// Set max_rounds so scoring calculations work correctly
|
||||
GetGameSettingsSetter().SetInt(kSettingsKeys.maxRounds, 31);
|
||||
|
||||
apdCache = std::make_shared<ActionPointDistancesCache>();
|
||||
alCache = std::make_unique<AttackLocationsCache>(BASIC_MAP_FB, GetGameSettingsGetter());
|
||||
|
||||
scorer = MakeMCTSOptimizedAIScoreCalculator(GetGameSettingsGetter(), apdCache, alCache);
|
||||
|
||||
// Create basic strategy
|
||||
strategy = AIStrategy{};
|
||||
strategy.strategyType = AIStrategy::STRATEGY_ATTACK_UNITS;
|
||||
}
|
||||
|
||||
// Helper to create a running game state
|
||||
auto CreateRunningGameState() -> GameStateW {
|
||||
flatbuffers::FlatBufferBuilder fbb;
|
||||
fbb.ForceDefaults(true);
|
||||
|
||||
const auto gameStatusOffset = net::eagle0::shardok::storage::fb::CreateGameStatusDirect(
|
||||
fbb,
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_GAME_RUNNING);
|
||||
|
||||
const auto hexMapOffset = AddHexMap(fbb, BASIC_MAP_FB);
|
||||
const auto playerInfoOffset = fbb.CreateVector(
|
||||
std::vector{AddPlayerInfo(fbb, 0, false, 1000), AddPlayerInfo(fbb, 1, true, 1000)});
|
||||
|
||||
std::vector<Unit> units{
|
||||
AddGenericUnit(0, 0, Coords(0, 0)), // Attacker unit
|
||||
AddGenericUnit(1, 1, Coords(5, 5)) // Defender unit
|
||||
};
|
||||
const auto unitsOffset = fbb.CreateVectorOfSortedStructs(&units);
|
||||
|
||||
net::eagle0::shardok::storage::fb::GameStateBuilder gsb(fbb);
|
||||
gsb.add_units(unitsOffset);
|
||||
gsb.add_hex_map(hexMapOffset);
|
||||
gsb.add_player_infos(playerInfoOffset);
|
||||
gsb.add_current_round(1);
|
||||
gsb.add_status(gameStatusOffset);
|
||||
|
||||
fbb.Finish(gsb.Finish());
|
||||
return GameStateW(fbb);
|
||||
}
|
||||
|
||||
GameSettingsSPtr settings;
|
||||
APDCache apdCache;
|
||||
ALCache alCache;
|
||||
std::unique_ptr<AIScoreCalculator> scorer;
|
||||
AIStrategy strategy;
|
||||
};
|
||||
|
||||
// Test that non-terminal scores are bounded (key property for MCTS)
|
||||
TEST_F(MCTSOptimizedAIScoreCalculatorTest, NonTerminalState_ScoreIsBounded) {
|
||||
auto gameState = CreateRunningGameState();
|
||||
CoordsSet castleCoords(BASIC_MAP_FB);
|
||||
|
||||
const auto score = scorer->GuessedStateScore(false, gameState, strategy, castleCoords);
|
||||
|
||||
// MCTS_OPTIMIZED should produce bounded scores, roughly in [-100, 100] range
|
||||
EXPECT_GE(score, -200.0) << "Score should be bounded (>= -200)";
|
||||
EXPECT_LE(score, 200.0) << "Score should be bounded (<= 200)";
|
||||
}
|
||||
|
||||
// Test with stronger attacker
|
||||
TEST_F(MCTSOptimizedAIScoreCalculatorTest, StrongerAttacker_PositiveScore) {
|
||||
// Create state with multiple attacker units vs one defender
|
||||
flatbuffers::FlatBufferBuilder fbb;
|
||||
fbb.ForceDefaults(true);
|
||||
|
||||
const auto gameStatusOffset = net::eagle0::shardok::storage::fb::CreateGameStatusDirect(
|
||||
fbb,
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_GAME_RUNNING);
|
||||
|
||||
const auto hexMapOffset = AddHexMap(fbb, BASIC_MAP_FB);
|
||||
const auto playerInfoOffset = fbb.CreateVector(
|
||||
std::vector{AddPlayerInfo(fbb, 0, false, 1000), AddPlayerInfo(fbb, 1, true, 1000)});
|
||||
|
||||
std::vector<Unit> units{
|
||||
AddGenericUnit(0, 0, Coords(0, 0)),
|
||||
AddGenericUnit(0, 1, Coords(1, 1)),
|
||||
AddGenericUnit(0, 2, Coords(2, 2)), // 3 attacker units
|
||||
AddGenericUnit(1, 3, Coords(5, 5)) // 1 defender unit
|
||||
};
|
||||
const auto unitsOffset = fbb.CreateVectorOfSortedStructs(&units);
|
||||
|
||||
net::eagle0::shardok::storage::fb::GameStateBuilder gsb(fbb);
|
||||
gsb.add_units(unitsOffset);
|
||||
gsb.add_hex_map(hexMapOffset);
|
||||
gsb.add_player_infos(playerInfoOffset);
|
||||
gsb.add_current_round(1);
|
||||
gsb.add_status(gameStatusOffset);
|
||||
|
||||
fbb.Finish(gsb.Finish());
|
||||
auto gameState = GameStateW(fbb);
|
||||
CoordsSet castleCoords(BASIC_MAP_FB);
|
||||
|
||||
const auto score = scorer->GuessedStateScore(false, gameState, strategy, castleCoords);
|
||||
|
||||
// With more attacker units, score should be positive
|
||||
EXPECT_GT(score, 0.0) << "Stronger attacker should have positive score";
|
||||
EXPECT_LE(score, 200.0) << "Score should still be bounded";
|
||||
}
|
||||
|
||||
// Test with stronger defender
|
||||
TEST_F(MCTSOptimizedAIScoreCalculatorTest, StrongerDefender_NegativeScore) {
|
||||
flatbuffers::FlatBufferBuilder fbb;
|
||||
fbb.ForceDefaults(true);
|
||||
|
||||
const auto gameStatusOffset = net::eagle0::shardok::storage::fb::CreateGameStatusDirect(
|
||||
fbb,
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_GAME_RUNNING);
|
||||
|
||||
const auto hexMapOffset = AddHexMap(fbb, BASIC_MAP_FB);
|
||||
const auto playerInfoOffset = fbb.CreateVector(
|
||||
std::vector{AddPlayerInfo(fbb, 0, false, 1000), AddPlayerInfo(fbb, 1, true, 1000)});
|
||||
|
||||
std::vector<Unit> units{
|
||||
AddGenericUnit(0, 0, Coords(0, 0)), // 1 attacker unit
|
||||
AddGenericUnit(1, 1, Coords(3, 3)),
|
||||
AddGenericUnit(1, 2, Coords(4, 4)),
|
||||
AddGenericUnit(1, 3, Coords(5, 5)) // 3 defender units
|
||||
};
|
||||
const auto unitsOffset = fbb.CreateVectorOfSortedStructs(&units);
|
||||
|
||||
net::eagle0::shardok::storage::fb::GameStateBuilder gsb(fbb);
|
||||
gsb.add_units(unitsOffset);
|
||||
gsb.add_hex_map(hexMapOffset);
|
||||
gsb.add_player_infos(playerInfoOffset);
|
||||
gsb.add_current_round(1);
|
||||
gsb.add_status(gameStatusOffset);
|
||||
|
||||
fbb.Finish(gsb.Finish());
|
||||
auto gameState = GameStateW(fbb);
|
||||
CoordsSet castleCoords(BASIC_MAP_FB);
|
||||
|
||||
const auto score = scorer->GuessedStateScore(false, gameState, strategy, castleCoords);
|
||||
|
||||
// With stronger defender, attacker score should be negative
|
||||
EXPECT_LT(score, 0.0) << "Stronger defender should result in negative score";
|
||||
EXPECT_GE(score, -200.0) << "Score should still be bounded";
|
||||
}
|
||||
|
||||
// Test that scores reflect relative strength consistently
|
||||
TEST_F(MCTSOptimizedAIScoreCalculatorTest, ConsistentWithRelativeStrength) {
|
||||
// Create two scenarios: balanced and imbalanced
|
||||
auto balancedState = CreateRunningGameState();
|
||||
CoordsSet castleCoords(BASIC_MAP_FB);
|
||||
|
||||
flatbuffers::FlatBufferBuilder fbb;
|
||||
fbb.ForceDefaults(true);
|
||||
|
||||
const auto gameStatusOffset = net::eagle0::shardok::storage::fb::CreateGameStatusDirect(
|
||||
fbb,
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_GAME_RUNNING);
|
||||
|
||||
const auto hexMapOffset = AddHexMap(fbb, BASIC_MAP_FB);
|
||||
const auto playerInfoOffset = fbb.CreateVector(
|
||||
std::vector{AddPlayerInfo(fbb, 0, false, 1000), AddPlayerInfo(fbb, 1, true, 1000)});
|
||||
|
||||
std::vector<Unit> units{
|
||||
AddGenericUnit(0, 0, Coords(0, 0)),
|
||||
AddGenericUnit(0, 1, Coords(1, 1)),
|
||||
AddGenericUnit(0, 2, Coords(2, 2)),
|
||||
AddGenericUnit(0, 3, Coords(3, 3)), // 4 attacker units
|
||||
AddGenericUnit(1, 4, Coords(5, 5)) // 1 defender unit
|
||||
};
|
||||
const auto unitsOffset = fbb.CreateVectorOfSortedStructs(&units);
|
||||
|
||||
net::eagle0::shardok::storage::fb::GameStateBuilder gsb(fbb);
|
||||
gsb.add_units(unitsOffset);
|
||||
gsb.add_hex_map(hexMapOffset);
|
||||
gsb.add_player_infos(playerInfoOffset);
|
||||
gsb.add_current_round(1);
|
||||
gsb.add_status(gameStatusOffset);
|
||||
|
||||
fbb.Finish(gsb.Finish());
|
||||
auto imbalancedState = GameStateW(fbb);
|
||||
|
||||
const auto balancedScore =
|
||||
scorer->GuessedStateScore(false, balancedState, strategy, castleCoords);
|
||||
const auto imbalancedScore =
|
||||
scorer->GuessedStateScore(false, imbalancedState, strategy, castleCoords);
|
||||
|
||||
// Imbalanced state (more attackers) should have higher score
|
||||
EXPECT_GT(imbalancedScore, balancedScore)
|
||||
<< "State with more attacker units should have higher score";
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
@@ -0,0 +1,417 @@
|
||||
//
|
||||
// Tests for NormalizedAIScoreCalculator
|
||||
//
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/NormalizedAIScoreCalculator.hpp"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#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/score/AIScoreCalculator.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/GameSettings_test_utils.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/GameStart_test_data.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/HexMap_test_data.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
class NormalizedAIScoreCalculatorTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
InitializeGameSettings();
|
||||
settings = GetGameSettings();
|
||||
|
||||
apdCache = std::make_shared<ActionPointDistancesCache>();
|
||||
alCache = std::make_unique<AttackLocationsCache>(BASIC_MAP_FB, GetGameSettingsGetter());
|
||||
|
||||
scorer = MakeNormalizedAIScoreCalculator(GetGameSettingsGetter(), apdCache, alCache);
|
||||
|
||||
// Create basic strategy
|
||||
strategy = AIStrategy{};
|
||||
strategy.strategyType = AIStrategy::STRATEGY_ATTACK_UNITS;
|
||||
}
|
||||
|
||||
// Helper to create a running game state
|
||||
auto CreateRunningGameState() -> GameStateW {
|
||||
flatbuffers::FlatBufferBuilder fbb;
|
||||
fbb.ForceDefaults(true);
|
||||
|
||||
const auto gameStatusOffset = net::eagle0::shardok::storage::fb::CreateGameStatusDirect(
|
||||
fbb,
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_GAME_RUNNING);
|
||||
|
||||
const auto hexMapOffset = AddHexMap(fbb, BASIC_MAP_FB);
|
||||
const auto playerInfoOffset = fbb.CreateVector(
|
||||
std::vector{AddPlayerInfo(fbb, 0, false, 1000), AddPlayerInfo(fbb, 1, true, 1000)});
|
||||
|
||||
std::vector<Unit> units{
|
||||
AddGenericUnit(0, 0, Coords(0, 0)), // Attacker unit
|
||||
AddGenericUnit(1, 1, Coords(5, 5)) // Defender unit
|
||||
};
|
||||
const auto unitsOffset = fbb.CreateVectorOfSortedStructs(&units);
|
||||
|
||||
net::eagle0::shardok::storage::fb::GameStateBuilder gsb(fbb);
|
||||
gsb.add_units(unitsOffset);
|
||||
gsb.add_hex_map(hexMapOffset);
|
||||
gsb.add_player_infos(playerInfoOffset);
|
||||
gsb.add_current_round(1);
|
||||
gsb.add_status(gameStatusOffset);
|
||||
|
||||
fbb.Finish(gsb.Finish());
|
||||
return GameStateW(fbb);
|
||||
}
|
||||
|
||||
// Helper to create attacker victory state
|
||||
auto CreateAttackerVictoryState() -> GameStateW {
|
||||
flatbuffers::FlatBufferBuilder fbb;
|
||||
fbb.ForceDefaults(true);
|
||||
|
||||
std::vector<PlayerId> winningIds{0};
|
||||
const auto gameStatusOffset = net::eagle0::shardok::storage::fb::CreateGameStatusDirect(
|
||||
fbb,
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY,
|
||||
nullptr, // description
|
||||
&winningIds);
|
||||
|
||||
const auto hexMapOffset = AddHexMap(fbb, BASIC_MAP_FB);
|
||||
const auto playerInfoOffset = fbb.CreateVector(
|
||||
std::vector{AddPlayerInfo(fbb, 0, false, 1000), AddPlayerInfo(fbb, 1, true, 1000)});
|
||||
|
||||
std::vector<Unit> units{
|
||||
AddGenericUnit(0, 0, Coords(0, 0)),
|
||||
AddGenericUnit(1, 1, Coords(5, 5))};
|
||||
const auto unitsOffset = fbb.CreateVectorOfSortedStructs(&units);
|
||||
|
||||
net::eagle0::shardok::storage::fb::GameStateBuilder gsb(fbb);
|
||||
gsb.add_units(unitsOffset);
|
||||
gsb.add_hex_map(hexMapOffset);
|
||||
gsb.add_player_infos(playerInfoOffset);
|
||||
gsb.add_current_round(1);
|
||||
gsb.add_status(gameStatusOffset);
|
||||
|
||||
fbb.Finish(gsb.Finish());
|
||||
return GameStateW(fbb);
|
||||
}
|
||||
|
||||
// Helper to create defender victory state
|
||||
auto CreateDefenderVictoryState() -> GameStateW {
|
||||
flatbuffers::FlatBufferBuilder fbb;
|
||||
fbb.ForceDefaults(true);
|
||||
|
||||
std::vector<PlayerId> winningIds{1};
|
||||
const auto gameStatusOffset = net::eagle0::shardok::storage::fb::CreateGameStatusDirect(
|
||||
fbb,
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY,
|
||||
nullptr, // description
|
||||
&winningIds);
|
||||
|
||||
const auto hexMapOffset = AddHexMap(fbb, BASIC_MAP_FB);
|
||||
const auto playerInfoOffset = fbb.CreateVector(
|
||||
std::vector{AddPlayerInfo(fbb, 0, false, 1000), AddPlayerInfo(fbb, 1, true, 1000)});
|
||||
|
||||
std::vector<Unit> units{
|
||||
AddGenericUnit(0, 0, Coords(0, 0)),
|
||||
AddGenericUnit(1, 1, Coords(5, 5))};
|
||||
const auto unitsOffset = fbb.CreateVectorOfSortedStructs(&units);
|
||||
|
||||
net::eagle0::shardok::storage::fb::GameStateBuilder gsb(fbb);
|
||||
gsb.add_units(unitsOffset);
|
||||
gsb.add_hex_map(hexMapOffset);
|
||||
gsb.add_player_infos(playerInfoOffset);
|
||||
gsb.add_current_round(1);
|
||||
gsb.add_status(gameStatusOffset);
|
||||
|
||||
fbb.Finish(gsb.Finish());
|
||||
return GameStateW(fbb);
|
||||
}
|
||||
|
||||
// Helper to create draw state
|
||||
auto CreateDrawState() -> GameStateW {
|
||||
flatbuffers::FlatBufferBuilder fbb;
|
||||
fbb.ForceDefaults(true);
|
||||
|
||||
const auto gameStatusOffset = net::eagle0::shardok::storage::fb::CreateGameStatusDirect(
|
||||
fbb,
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW);
|
||||
|
||||
const auto hexMapOffset = AddHexMap(fbb, BASIC_MAP_FB);
|
||||
const auto playerInfoOffset = fbb.CreateVector(
|
||||
std::vector{AddPlayerInfo(fbb, 0, false, 1000), AddPlayerInfo(fbb, 1, true, 1000)});
|
||||
|
||||
std::vector<Unit> units{
|
||||
AddGenericUnit(0, 0, Coords(0, 0)),
|
||||
AddGenericUnit(1, 1, Coords(5, 5))};
|
||||
const auto unitsOffset = fbb.CreateVectorOfSortedStructs(&units);
|
||||
|
||||
net::eagle0::shardok::storage::fb::GameStateBuilder gsb(fbb);
|
||||
gsb.add_units(unitsOffset);
|
||||
gsb.add_hex_map(hexMapOffset);
|
||||
gsb.add_player_infos(playerInfoOffset);
|
||||
gsb.add_current_round(1);
|
||||
gsb.add_status(gameStatusOffset);
|
||||
|
||||
fbb.Finish(gsb.Finish());
|
||||
return GameStateW(fbb);
|
||||
}
|
||||
|
||||
GameSettingsSPtr settings;
|
||||
APDCache apdCache;
|
||||
ALCache alCache;
|
||||
std::unique_ptr<AIScoreCalculator> scorer;
|
||||
AIStrategy strategy;
|
||||
};
|
||||
|
||||
// Test terminal state: Attacker victory should return 1.0
|
||||
TEST_F(NormalizedAIScoreCalculatorTest, AttackerVictory_Returns1Point0) {
|
||||
auto gameState = CreateAttackerVictoryState();
|
||||
CoordsSet castleCoords(BASIC_MAP_FB);
|
||||
|
||||
const auto score = scorer->GuessedStateScore(false, gameState, strategy, castleCoords);
|
||||
|
||||
EXPECT_EQ(score, 1.0);
|
||||
}
|
||||
|
||||
// Test terminal state: Defender victory should return 0.0 from attacker perspective
|
||||
TEST_F(NormalizedAIScoreCalculatorTest, DefenderVictory_Returns0Point0) {
|
||||
auto gameState = CreateDefenderVictoryState();
|
||||
CoordsSet castleCoords(BASIC_MAP_FB);
|
||||
|
||||
const auto score = scorer->GuessedStateScore(false, gameState, strategy, castleCoords);
|
||||
|
||||
EXPECT_EQ(score, 0.0);
|
||||
}
|
||||
|
||||
// Test terminal state: Draw should return 0.5
|
||||
TEST_F(NormalizedAIScoreCalculatorTest, Draw_Returns0Point5) {
|
||||
auto gameState = CreateDrawState();
|
||||
CoordsSet castleCoords(BASIC_MAP_FB);
|
||||
|
||||
const auto score = scorer->GuessedStateScore(false, gameState, strategy, castleCoords);
|
||||
|
||||
EXPECT_EQ(score, 0.5);
|
||||
}
|
||||
|
||||
// Test terminal state from defender perspective
|
||||
TEST_F(NormalizedAIScoreCalculatorTest, DefenderPerspective_DefenderVictory_Returns1Point0) {
|
||||
auto gameState = CreateDefenderVictoryState();
|
||||
CoordsSet castleCoords(BASIC_MAP_FB);
|
||||
|
||||
AIStrategy defenderStrategy;
|
||||
defenderStrategy.strategyType = AIStrategy::STRATEGY_HOLD_CASTLES;
|
||||
|
||||
const auto score = scorer->GuessedStateScore(true, gameState, defenderStrategy, castleCoords);
|
||||
|
||||
EXPECT_EQ(score, 1.0);
|
||||
}
|
||||
|
||||
// Test terminal state from defender perspective: attacker victory
|
||||
TEST_F(NormalizedAIScoreCalculatorTest, DefenderPerspective_AttackerVictory_Returns0Point0) {
|
||||
auto gameState = CreateAttackerVictoryState();
|
||||
CoordsSet castleCoords(BASIC_MAP_FB);
|
||||
|
||||
AIStrategy defenderStrategy;
|
||||
defenderStrategy.strategyType = AIStrategy::STRATEGY_HOLD_CASTLES;
|
||||
|
||||
const auto score = scorer->GuessedStateScore(true, gameState, defenderStrategy, castleCoords);
|
||||
|
||||
EXPECT_EQ(score, 0.0);
|
||||
}
|
||||
|
||||
// Test that non-terminal scores are in [0, 1] range
|
||||
TEST_F(NormalizedAIScoreCalculatorTest, NonTerminalState_ScoreInRange) {
|
||||
auto gameState = CreateRunningGameState();
|
||||
CoordsSet castleCoords(BASIC_MAP_FB);
|
||||
|
||||
const auto score = scorer->GuessedStateScore(false, gameState, strategy, castleCoords);
|
||||
|
||||
EXPECT_GE(score, 0.0) << "Score should be >= 0.0";
|
||||
EXPECT_LE(score, 1.0) << "Score should be <= 1.0";
|
||||
}
|
||||
|
||||
// Test with different unit configurations
|
||||
TEST_F(NormalizedAIScoreCalculatorTest, StrongerAttacker_HigherScore) {
|
||||
// Create state with multiple attacker units vs one defender
|
||||
flatbuffers::FlatBufferBuilder fbb;
|
||||
fbb.ForceDefaults(true);
|
||||
|
||||
const auto gameStatusOffset = net::eagle0::shardok::storage::fb::CreateGameStatusDirect(
|
||||
fbb,
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_GAME_RUNNING);
|
||||
|
||||
const auto hexMapOffset = AddHexMap(fbb, BASIC_MAP_FB);
|
||||
const auto playerInfoOffset = fbb.CreateVector(
|
||||
std::vector{AddPlayerInfo(fbb, 0, false, 1000), AddPlayerInfo(fbb, 1, true, 1000)});
|
||||
|
||||
std::vector<Unit> units{
|
||||
AddGenericUnit(0, 0, Coords(0, 0)),
|
||||
AddGenericUnit(0, 1, Coords(1, 1)),
|
||||
AddGenericUnit(0, 2, Coords(2, 2)), // 3 attacker units
|
||||
AddGenericUnit(1, 3, Coords(5, 5)) // 1 defender unit
|
||||
};
|
||||
const auto unitsOffset = fbb.CreateVectorOfSortedStructs(&units);
|
||||
|
||||
net::eagle0::shardok::storage::fb::GameStateBuilder gsb(fbb);
|
||||
gsb.add_units(unitsOffset);
|
||||
gsb.add_hex_map(hexMapOffset);
|
||||
gsb.add_player_infos(playerInfoOffset);
|
||||
gsb.add_current_round(1);
|
||||
gsb.add_status(gameStatusOffset);
|
||||
|
||||
fbb.Finish(gsb.Finish());
|
||||
auto gameState = GameStateW(fbb);
|
||||
CoordsSet castleCoords(BASIC_MAP_FB);
|
||||
|
||||
const auto score = scorer->GuessedStateScore(false, gameState, strategy, castleCoords);
|
||||
|
||||
// With more attacker units, score should be relatively high
|
||||
EXPECT_GT(score, 0.5) << "Stronger attacker should have score > 0.5";
|
||||
EXPECT_LE(score, 1.0);
|
||||
}
|
||||
|
||||
// Test with stronger defender
|
||||
TEST_F(NormalizedAIScoreCalculatorTest, StrongerDefender_LowerScore) {
|
||||
flatbuffers::FlatBufferBuilder fbb;
|
||||
fbb.ForceDefaults(true);
|
||||
|
||||
const auto gameStatusOffset = net::eagle0::shardok::storage::fb::CreateGameStatusDirect(
|
||||
fbb,
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_GAME_RUNNING);
|
||||
|
||||
const auto hexMapOffset = AddHexMap(fbb, BASIC_MAP_FB);
|
||||
const auto playerInfoOffset = fbb.CreateVector(
|
||||
std::vector{AddPlayerInfo(fbb, 0, false, 1000), AddPlayerInfo(fbb, 1, true, 1000)});
|
||||
|
||||
std::vector<Unit> units{
|
||||
AddGenericUnit(0, 0, Coords(0, 0)), // 1 attacker unit
|
||||
AddGenericUnit(1, 1, Coords(3, 3)),
|
||||
AddGenericUnit(1, 2, Coords(4, 4)),
|
||||
AddGenericUnit(1, 3, Coords(5, 5)) // 3 defender units
|
||||
};
|
||||
const auto unitsOffset = fbb.CreateVectorOfSortedStructs(&units);
|
||||
|
||||
net::eagle0::shardok::storage::fb::GameStateBuilder gsb(fbb);
|
||||
gsb.add_units(unitsOffset);
|
||||
gsb.add_hex_map(hexMapOffset);
|
||||
gsb.add_player_infos(playerInfoOffset);
|
||||
gsb.add_current_round(1);
|
||||
gsb.add_status(gameStatusOffset);
|
||||
|
||||
fbb.Finish(gsb.Finish());
|
||||
auto gameState = GameStateW(fbb);
|
||||
CoordsSet castleCoords(BASIC_MAP_FB);
|
||||
|
||||
const auto score = scorer->GuessedStateScore(false, gameState, strategy, castleCoords);
|
||||
|
||||
// With stronger defender, attacker score should be relatively low
|
||||
EXPECT_LT(score, 0.5) << "Stronger defender should result in attacker score < 0.5";
|
||||
EXPECT_GE(score, 0.0);
|
||||
}
|
||||
|
||||
// Test scatter strategy for defender
|
||||
TEST_F(NormalizedAIScoreCalculatorTest, DefenderScatterStrategy_ScoreInRange) {
|
||||
auto gameState = CreateRunningGameState();
|
||||
CoordsSet castleCoords(BASIC_MAP_FB);
|
||||
|
||||
AIStrategy scatterStrategy;
|
||||
scatterStrategy.strategyType = AIStrategy::STRATEGY_SCATTER;
|
||||
|
||||
const auto score = scorer->GuessedStateScore(true, gameState, scatterStrategy, castleCoords);
|
||||
|
||||
EXPECT_GE(score, 0.0);
|
||||
EXPECT_LE(score, 1.0);
|
||||
}
|
||||
|
||||
// Test that score is consistent with relative strength
|
||||
TEST_F(NormalizedAIScoreCalculatorTest, ConsistentWithRelativeStrength) {
|
||||
// Create two scenarios: balanced and imbalanced
|
||||
auto balancedState = CreateRunningGameState();
|
||||
CoordsSet castleCoords(BASIC_MAP_FB);
|
||||
|
||||
flatbuffers::FlatBufferBuilder fbb;
|
||||
fbb.ForceDefaults(true);
|
||||
|
||||
const auto gameStatusOffset = net::eagle0::shardok::storage::fb::CreateGameStatusDirect(
|
||||
fbb,
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_GAME_RUNNING);
|
||||
|
||||
const auto hexMapOffset = AddHexMap(fbb, BASIC_MAP_FB);
|
||||
const auto playerInfoOffset = fbb.CreateVector(
|
||||
std::vector{AddPlayerInfo(fbb, 0, false, 1000), AddPlayerInfo(fbb, 1, true, 1000)});
|
||||
|
||||
std::vector<Unit> units{
|
||||
AddGenericUnit(0, 0, Coords(0, 0)),
|
||||
AddGenericUnit(0, 1, Coords(1, 1)),
|
||||
AddGenericUnit(0, 2, Coords(2, 2)),
|
||||
AddGenericUnit(0, 3, Coords(3, 3)), // 4 attacker units
|
||||
AddGenericUnit(1, 4, Coords(5, 5)) // 1 defender unit
|
||||
};
|
||||
const auto unitsOffset = fbb.CreateVectorOfSortedStructs(&units);
|
||||
|
||||
net::eagle0::shardok::storage::fb::GameStateBuilder gsb(fbb);
|
||||
gsb.add_units(unitsOffset);
|
||||
gsb.add_hex_map(hexMapOffset);
|
||||
gsb.add_player_infos(playerInfoOffset);
|
||||
gsb.add_current_round(1);
|
||||
gsb.add_status(gameStatusOffset);
|
||||
|
||||
fbb.Finish(gsb.Finish());
|
||||
auto imbalancedState = GameStateW(fbb);
|
||||
|
||||
const auto balancedScore =
|
||||
scorer->GuessedStateScore(false, balancedState, strategy, castleCoords);
|
||||
const auto imbalancedScore =
|
||||
scorer->GuessedStateScore(false, imbalancedState, strategy, castleCoords);
|
||||
|
||||
// Imbalanced state (more attackers) should have higher score
|
||||
EXPECT_GT(imbalancedScore, balancedScore)
|
||||
<< "State with more attacker units should have higher score";
|
||||
}
|
||||
|
||||
// Test edge case: empty units (should return neutral 0.5)
|
||||
TEST_F(NormalizedAIScoreCalculatorTest, NoUnits_ReturnsNeutralScore) {
|
||||
flatbuffers::FlatBufferBuilder fbb;
|
||||
fbb.ForceDefaults(true);
|
||||
|
||||
const auto gameStatusOffset = net::eagle0::shardok::storage::fb::CreateGameStatusDirect(
|
||||
fbb,
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_GAME_RUNNING);
|
||||
|
||||
const auto hexMapOffset = AddHexMap(fbb, BASIC_MAP_FB);
|
||||
const auto playerInfoOffset = fbb.CreateVector(
|
||||
std::vector{AddPlayerInfo(fbb, 0, false, 1000), AddPlayerInfo(fbb, 1, true, 1000)});
|
||||
|
||||
std::vector<Unit> units{}; // No units
|
||||
const auto unitsOffset = fbb.CreateVectorOfSortedStructs(&units);
|
||||
|
||||
net::eagle0::shardok::storage::fb::GameStateBuilder gsb(fbb);
|
||||
gsb.add_units(unitsOffset);
|
||||
gsb.add_hex_map(hexMapOffset);
|
||||
gsb.add_player_infos(playerInfoOffset);
|
||||
gsb.add_current_round(1);
|
||||
gsb.add_status(gameStatusOffset);
|
||||
|
||||
fbb.Finish(gsb.Finish());
|
||||
auto gameState = GameStateW(fbb);
|
||||
CoordsSet castleCoords(BASIC_MAP_FB);
|
||||
|
||||
const auto score = scorer->GuessedStateScore(false, gameState, strategy, castleCoords);
|
||||
|
||||
// With no units, should return neutral score (0.5) since denominator is 0
|
||||
EXPECT_EQ(score, 0.5) << "Empty state should return neutral score 0.5";
|
||||
}
|
||||
|
||||
// Test flee strategy returns neutral score
|
||||
TEST_F(NormalizedAIScoreCalculatorTest, FleeStrategy_ReturnsNeutralScore) {
|
||||
auto gameState = CreateRunningGameState();
|
||||
CoordsSet castleCoords(BASIC_MAP_FB);
|
||||
|
||||
AIStrategy fleeStrategy;
|
||||
fleeStrategy.strategyType = AIStrategy::STRATEGY_FLEE;
|
||||
|
||||
const auto score = scorer->GuessedStateScore(false, gameState, fleeStrategy, castleCoords);
|
||||
|
||||
// Flee strategy doesn't fit [0,1] model, should return 0.5
|
||||
EXPECT_EQ(score, 0.5);
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
@@ -0,0 +1,199 @@
|
||||
//
|
||||
// AI Performance Test Helpers
|
||||
//
|
||||
|
||||
#include "AIPerformanceTestHelpers.hpp"
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/GameStateHelpers.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/util/MapLoader.hpp"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit.hpp"
|
||||
#include "src/main/protobuf/net/eagle0/shardok/common/player_info.pb.h"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
namespace {
|
||||
|
||||
// Profession enum values
|
||||
constexpr int NO_PROFESSION = 0;
|
||||
|
||||
// Player IDs
|
||||
constexpr PlayerId AI_PLAYER_ID = 0;
|
||||
constexpr PlayerId HUMAN_PLAYER_ID = 1;
|
||||
|
||||
// Battalion type for test units (longbowmen for attackers, light infantry for defenders)
|
||||
// These match the IDs in BattalionTypesTestData.cpp
|
||||
constexpr int LONGBOWMEN_BATTALION_TYPE = 4;
|
||||
constexpr int LIGHT_INFANTRY_BATTALION_TYPE = 0;
|
||||
|
||||
/**
|
||||
* Creates a properly initialized unit for performance testing.
|
||||
* This matches the unit creation in PerformanceTestGameStateBuilder.
|
||||
*/
|
||||
auto AddGenericUnit(
|
||||
PlayerId playerId,
|
||||
UnitId unitId,
|
||||
const net::eagle0::shardok::storage::fb::Coords& location,
|
||||
int profession,
|
||||
int battalionType,
|
||||
int startingPositionIndex,
|
||||
SettingsGetter settingsGetter) -> net::eagle0::shardok::storage::fb::Unit {
|
||||
net::eagle0::shardok::storage::fb::Unit unit{}; // Initialize to zero
|
||||
|
||||
// Basic unit properties
|
||||
unit.mutate_player_id(playerId);
|
||||
unit.mutate_unit_id(unitId);
|
||||
unit.mutate_eagle_player_id(playerId);
|
||||
unit.mutable_location() = location;
|
||||
unit.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT);
|
||||
unit.mutate_remaining_action_points(12);
|
||||
unit.mutate_hidden(false);
|
||||
unit.mutate_fortified(false);
|
||||
unit.mutate_can_flee(true);
|
||||
unit.mutate_can_start_fire(false);
|
||||
unit.mutate_can_archery(false);
|
||||
unit.mutate_stun_rounds_remaining(0);
|
||||
unit.mutate_commanding_unit_id(-1);
|
||||
unit.mutate_targeted_unit(-1);
|
||||
unit.mutate_starting_position_index(startingPositionIndex);
|
||||
unit.mutate_has_moved_in_zoc(false);
|
||||
unit.mutate_volleys_remaining(0);
|
||||
unit.mutate_food_remaining(1000.0f);
|
||||
|
||||
// Battalion - use actual max size from battalion type
|
||||
const auto battalionTypeInfo = settingsGetter.GetBattalionType(
|
||||
static_cast<net::eagle0::shardok::storage::fb::BattalionTypeId>(battalionType));
|
||||
net::eagle0::shardok::storage::fb::Battalion battalion;
|
||||
battalion.mutate_type(
|
||||
static_cast<net::eagle0::shardok::storage::fb::BattalionTypeId>(battalionType));
|
||||
battalion.mutate_size(static_cast<float>(battalionTypeInfo->capacity));
|
||||
battalion.mutate_armament(100.0f);
|
||||
battalion.mutate_training(100.0f);
|
||||
battalion.mutate_morale(50.0f);
|
||||
unit.mutable_battalion() = battalion;
|
||||
|
||||
// Hero (all units have heroes except undead)
|
||||
unit.mutate_has_attached_hero(true);
|
||||
|
||||
net::eagle0::shardok::storage::fb::Hero hero;
|
||||
hero.mutate_strength(102);
|
||||
hero.mutate_strength_xp(0);
|
||||
hero.mutate_agility(102);
|
||||
hero.mutate_agility_xp(0);
|
||||
hero.mutate_wisdom(102);
|
||||
hero.mutate_wisdom_xp(0);
|
||||
hero.mutate_charisma(102);
|
||||
hero.mutate_charisma_xp(0);
|
||||
hero.mutate_constitution(102);
|
||||
hero.mutate_constitution_xp(0);
|
||||
hero.mutate_vigor(102);
|
||||
hero.mutate_starting_vigor(102);
|
||||
hero.mutate_spent_vigor(0);
|
||||
hero.mutate_bravery(102);
|
||||
hero.mutate_integrity(50);
|
||||
hero.mutate_ambition(50);
|
||||
hero.mutate_eagle_hero_id(unitId + 1);
|
||||
hero.mutate_is_vip(false);
|
||||
|
||||
hero.mutable_profession_info().mutate_profession(
|
||||
static_cast<net::eagle0::shardok::storage::fb::Profession>(profession));
|
||||
hero.mutable_profession_info().mutate_meteor_cast_state(
|
||||
net::eagle0::shardok::storage::fb::MultiroundMagicState_NONE);
|
||||
|
||||
hero.mutable_control_info().mutate_controlled_unit_id(-1);
|
||||
hero.mutable_control_info().mutate_controlled_this_round(false);
|
||||
|
||||
unit.mutable_attached_hero() = hero;
|
||||
|
||||
// Initialize opponent knowledge for both players
|
||||
unit.mutable_opponent_knowledge()->Mutate(0, 0);
|
||||
unit.mutable_opponent_knowledge()->Mutate(1, 0);
|
||||
|
||||
return unit;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
auto CreatePerfTestGameState(const GameSettingsSPtr& settings, bool defenderToggle) -> GameStateW {
|
||||
// Load the Alah map
|
||||
auto hexMapProto = LoadMap("Alah");
|
||||
|
||||
// Create player info protos
|
||||
std::vector<net::eagle0::shardok::common::PlayerInfo> playerInfoProtos;
|
||||
|
||||
// AI player (player 0)
|
||||
net::eagle0::shardok::common::PlayerInfo aiPlayerInfo;
|
||||
aiPlayerInfo.set_player_id(AI_PLAYER_ID);
|
||||
aiPlayerInfo.set_is_defender(defenderToggle);
|
||||
aiPlayerInfo.set_starting_food(1000);
|
||||
aiPlayerInfo.add_victory_conditions(
|
||||
net::eagle0::shardok::common::VICTORY_CONDITION_LAST_PLAYER_STANDING);
|
||||
if (defenderToggle) {
|
||||
aiPlayerInfo.add_victory_conditions(
|
||||
net::eagle0::shardok::common::VICTORY_CONDITION_WIN_AFTER_MAX_ROUNDS);
|
||||
} else {
|
||||
aiPlayerInfo.add_victory_conditions(
|
||||
net::eagle0::shardok::common::VICTORY_CONDITION_HOLDS_CRITICAL_TILES);
|
||||
}
|
||||
playerInfoProtos.push_back(aiPlayerInfo);
|
||||
|
||||
// Human player (player 1)
|
||||
net::eagle0::shardok::common::PlayerInfo humanPlayerInfo;
|
||||
humanPlayerInfo.set_player_id(HUMAN_PLAYER_ID);
|
||||
humanPlayerInfo.set_is_defender(!defenderToggle);
|
||||
humanPlayerInfo.set_starting_food(1000);
|
||||
humanPlayerInfo.add_victory_conditions(
|
||||
net::eagle0::shardok::common::VICTORY_CONDITION_LAST_PLAYER_STANDING);
|
||||
if (!defenderToggle) {
|
||||
humanPlayerInfo.add_victory_conditions(
|
||||
net::eagle0::shardok::common::VICTORY_CONDITION_WIN_AFTER_MAX_ROUNDS);
|
||||
} else {
|
||||
humanPlayerInfo.add_victory_conditions(
|
||||
net::eagle0::shardok::common::VICTORY_CONDITION_HOLDS_CRITICAL_TILES);
|
||||
}
|
||||
playerInfoProtos.push_back(humanPlayerInfo);
|
||||
|
||||
// Create units - 6 per player
|
||||
std::vector<net::eagle0::shardok::storage::fb::Unit> units;
|
||||
|
||||
// Create AI units in reserve (location -1, -1)
|
||||
// Attackers are longbowmen, defenders are light infantry
|
||||
const int aiBattalionType =
|
||||
defenderToggle ? LIGHT_INFANTRY_BATTALION_TYPE : LONGBOWMEN_BATTALION_TYPE;
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
units.push_back(AddGenericUnit(
|
||||
AI_PLAYER_ID,
|
||||
i, // Unit ID
|
||||
net::eagle0::shardok::storage::fb::Coords(-1, -1), // Reserve location
|
||||
i + 1, // Profession: 1-6
|
||||
aiBattalionType,
|
||||
defenderToggle ? -1 : 0, // Starting position index
|
||||
settings->GetGetter()));
|
||||
}
|
||||
|
||||
// Create human units in reserve (location -1, -1)
|
||||
// Attackers are longbowmen, defenders are light infantry
|
||||
const int humanBattalionType =
|
||||
defenderToggle ? LONGBOWMEN_BATTALION_TYPE : LIGHT_INFANTRY_BATTALION_TYPE;
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
units.push_back(AddGenericUnit(
|
||||
HUMAN_PLAYER_ID,
|
||||
6 + i, // Unit ID starting at 6
|
||||
net::eagle0::shardok::storage::fb::Coords(-1, -1), // Reserve location
|
||||
NO_PROFESSION,
|
||||
humanBattalionType,
|
||||
defenderToggle ? 0 : -1, // Starting position index
|
||||
settings->GetGetter()));
|
||||
}
|
||||
|
||||
// Use the proper SetupInitialGameState helper
|
||||
return shardok::fb::SetupInitialGameState(
|
||||
"perf_test_game", // gameId
|
||||
hexMapProto,
|
||||
playerInfoProtos,
|
||||
units,
|
||||
4, // month (April)
|
||||
false, // isWinter
|
||||
settings->GetGetter());
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// AI Performance Test Helpers
|
||||
// Shared helper functions for creating performance test game states
|
||||
//
|
||||
|
||||
#ifndef AI_PERFORMANCE_TEST_HELPERS_HPP
|
||||
#define AI_PERFORMANCE_TEST_HELPERS_HPP
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
/**
|
||||
* Creates a 6v6 performance test game state on the Alah map.
|
||||
* This matches the "Perf button" configuration in the client.
|
||||
*
|
||||
* @param settings Game settings to use (must have max_rounds set in backing struct)
|
||||
* @param defenderToggle If true, AI is defender. If false, AI is attacker.
|
||||
* @return GameStateW in SETUP phase with 6 units per player in reserve
|
||||
*/
|
||||
auto CreatePerfTestGameState(const GameSettingsSPtr& settings, bool defenderToggle) -> GameStateW;
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // AI_PERFORMANCE_TEST_HELPERS_HPP
|
||||
@@ -62,6 +62,22 @@ cc_library(
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "ai_performance_test_helpers",
|
||||
srcs = ["AIPerformanceTestHelpers.cpp"],
|
||||
hdrs = ["AIPerformanceTestHelpers.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = ["//src/test/cpp/net/eagle0/shardok:__subpackages__"],
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/fb_helpers:game_state_helpers",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
|
||||
"//src/main/cpp/net/eagle0/shardok/util:map_loader",
|
||||
"//src/main/flatbuffer/net/eagle0/shardok/storage:unit_cc_fbs",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:player_info_cc_proto",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "hero_test_data",
|
||||
srcs = ["Hero_test_data.cpp"],
|
||||
|
||||
Reference in New Issue
Block a user