mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 00:35:41 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbf730e662 | ||
|
|
78c918412d | ||
|
|
dd9b419cce | ||
|
|
7bb5b4109a | ||
|
|
4af4cd5765 | ||
|
|
4694755b71 | ||
|
|
303e4d7815 | ||
|
|
26d00df97e | ||
|
|
6ec0cd8932 | ||
|
|
0c55cfa2c5 | ||
|
|
585f6f1bc7 |
@@ -205,9 +205,16 @@ auto AbstractMCTSAI::MCTSSelection(MCTSNode* root) const -> MCTSNode* {
|
||||
|
||||
while (!current->isTerminal && current->depth < config_.maxTreeDepth) {
|
||||
if (current->CanExpand()) {
|
||||
return current; // Node has untried actions
|
||||
return current; // Node has untried actions/outcomes
|
||||
} else if (!current->children.empty()) {
|
||||
current = current->GetBestChild(config_.explorationConstant);
|
||||
// Choose child based on node type
|
||||
if (current->IsChanceNode()) {
|
||||
// Chance nodes: select outcome proportional to probability
|
||||
current = current->GetBestChanceChild();
|
||||
} else {
|
||||
// Decision nodes: select using UCB1
|
||||
current = current->GetBestChild(config_.explorationConstant);
|
||||
}
|
||||
if (!current) break;
|
||||
} else {
|
||||
break; // Leaf node
|
||||
@@ -223,6 +230,100 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
|
||||
return node; // Nothing to expand
|
||||
}
|
||||
|
||||
// Handle chance node expansion (expanding outcomes)
|
||||
if (node->IsChanceNode()) {
|
||||
// Chance nodes expand their outcome children
|
||||
// This should have been set up when the chance node was created
|
||||
if (node->outcomeProbabilities.empty()) {
|
||||
throw MCTSInternalError(
|
||||
"Chance node has no outcome probabilities - this indicates a bug");
|
||||
}
|
||||
|
||||
const size_t outcomeIndex = node->nextUntriedActionIndex++;
|
||||
if (outcomeIndex >= node->outcomeProbabilities.size()) {
|
||||
throw MCTSInternalError(
|
||||
"Chance node outcomeIndex >= outcomeProbabilities.size() - bug in expansion");
|
||||
}
|
||||
|
||||
// The chance node's action should be the binary action
|
||||
if (!node->action) {
|
||||
throw MCTSInternalError("Chance node has no action - this indicates a bug");
|
||||
}
|
||||
|
||||
// Apply the action with the representative roll for this outcome
|
||||
// Outcome 0 = success, Outcome 1 = failure
|
||||
// Use the representative roll for this specific outcome
|
||||
const double representativeRoll = node->outcomeRolls[outcomeIndex];
|
||||
auto newState = engine.applyAction(*node->gameState, *node->action, representativeRoll);
|
||||
if (!newState) {
|
||||
throw MCTSInternalError(
|
||||
"MCTS expansion: engine.applyAction() returned nullptr for chance node "
|
||||
"outcome - this indicates a game engine error");
|
||||
}
|
||||
|
||||
// Determine if player changed
|
||||
const MCTSPlayerId newPlayerId = newState->currentPlayerId();
|
||||
const bool playerChanged = (newPlayerId != node->playerId);
|
||||
|
||||
// Calculate player flips and maximizing status
|
||||
const int newPlayerFlips = node->playerFlips + (playerChanged ? 1 : 0);
|
||||
const bool newIsMaximizing = (newPlayerId == playerId_);
|
||||
|
||||
// Create outcome child (decision node)
|
||||
auto outcomeChild = std::make_unique<MCTSNode>(
|
||||
node->action->clone(),
|
||||
std::move(newState),
|
||||
newPlayerId,
|
||||
node->depth + 1,
|
||||
outcomeIndex,
|
||||
newPlayerFlips,
|
||||
newIsMaximizing,
|
||||
node->actionWeight); // Inherit action weight from chance node
|
||||
|
||||
// Set up outcome child's actions if not terminal
|
||||
const bool shouldExpand =
|
||||
!outcomeChild->isTerminal && node->playerFlips <= config_.maxPlayerFlips;
|
||||
if (shouldExpand) {
|
||||
const auto childActions = engine.getLegalActions(
|
||||
*outcomeChild->gameState,
|
||||
playerId_,
|
||||
newPlayerFlips,
|
||||
config_.maxPlayerFlips);
|
||||
outcomeChild->totalActions = childActions.size();
|
||||
}
|
||||
|
||||
// Calculate scores
|
||||
outcomeChild->immediateScore = engine.evaluateState(*outcomeChild->gameState, playerId_);
|
||||
outcomeChild->lookaheadScore = outcomeChild->immediateScore;
|
||||
|
||||
// Set parent and add to children
|
||||
outcomeChild->parent = node;
|
||||
node->children.push_back(std::move(outcomeChild));
|
||||
|
||||
// Update chance node's immediate score to expected value of expanded outcomes
|
||||
// This corrects the initial value (which incorrectly used parent state) and ensures
|
||||
// fair UCB comparison with non-chance actions like END_TURN
|
||||
{
|
||||
double expectedImmediate = 0.0;
|
||||
double totalProbability = 0.0;
|
||||
for (size_t i = 0; i < node->children.size(); i++) {
|
||||
const double prob = node->outcomeProbabilities[i];
|
||||
const double childImmediate = node->children[i]->immediateScore;
|
||||
expectedImmediate += prob * childImmediate;
|
||||
totalProbability += prob;
|
||||
}
|
||||
// Normalize by total probability of expanded outcomes
|
||||
if (totalProbability > 0.0) {
|
||||
node->immediateScore = expectedImmediate / totalProbability;
|
||||
// Also update lookaheadScore if this is a fresh expansion
|
||||
if (node->children.size() == 1) { node->lookaheadScore = node->immediateScore; }
|
||||
}
|
||||
}
|
||||
|
||||
return node->children.back().get();
|
||||
}
|
||||
|
||||
// Handle decision node expansion (expanding actions)
|
||||
// Get next action to expand (sequential order)
|
||||
const size_t actionIndex = node->nextUntriedActionIndex++;
|
||||
|
||||
@@ -250,6 +351,43 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
|
||||
const double actionWeight =
|
||||
actionIndex < actionWeights.size() ? actionWeights[actionIndex] : 1.0;
|
||||
|
||||
// Check if this action requires a chance node
|
||||
if (action->requiresChanceNode()) {
|
||||
// Create intermediate chance node
|
||||
auto chanceNode = std::make_unique<MCTSNode>(
|
||||
action->clone(),
|
||||
node->gameState->clone(), // Chance node has same state as parent
|
||||
node->playerId,
|
||||
node->depth + 1,
|
||||
actionIndex,
|
||||
node->playerFlips,
|
||||
node->isMaximizingPlayer,
|
||||
actionWeight);
|
||||
|
||||
chanceNode->nodeType = NodeType::CHANCE;
|
||||
|
||||
// Get outcome information from engine
|
||||
const auto outcomeInfo = engine.getBinaryOutcomeInfo(*node->gameState, *action);
|
||||
|
||||
// Set up outcome metadata (2 outcomes for binary actions)
|
||||
chanceNode->outcomeProbabilities = outcomeInfo.getProbabilities();
|
||||
chanceNode->outcomeRolls = outcomeInfo.getRepresentativeRolls();
|
||||
chanceNode->totalActions = 2; // Binary: success and failure
|
||||
|
||||
// Chance node immediate score will be computed as expected value during backpropagation
|
||||
// For now, initialize to parent's score as a reasonable default
|
||||
chanceNode->immediateScore = engine.evaluateState(*node->gameState, playerId_);
|
||||
chanceNode->lookaheadScore = chanceNode->immediateScore;
|
||||
|
||||
// Set parent and add to children
|
||||
chanceNode->parent = node;
|
||||
node->children.push_back(std::move(chanceNode));
|
||||
|
||||
// Return the chance node for further expansion
|
||||
return node->children.back().get();
|
||||
}
|
||||
|
||||
// Regular (non-chance) action: create decision node directly
|
||||
auto newState = engine.applyAction(*node->gameState, *action);
|
||||
if (!newState) {
|
||||
throw MCTSInternalError(
|
||||
@@ -405,8 +543,45 @@ auto AbstractMCTSAI::MCTSBackpropagation(
|
||||
node->totalReward += reward;
|
||||
node->averageReward = node->totalReward / node->visitCount;
|
||||
|
||||
// Update lookahead score based on strategy
|
||||
if (useMinimaxBackup && !node->children.empty()) {
|
||||
// Update lookahead score based on node type and strategy
|
||||
if (node->IsChanceNode() && !node->children.empty()) {
|
||||
// Chance nodes: compute expected value (weighted average of outcomes)
|
||||
// lookaheadScore = sum(probability[i] * childValue[i])
|
||||
double expectedValue = 0.0;
|
||||
double totalProbability = 0.0;
|
||||
int visitedChildCount = 0;
|
||||
|
||||
for (size_t i = 0; i < node->children.size(); i++) {
|
||||
const auto& child = node->children[i];
|
||||
if (child->visitCount == 0) continue; // Unvisited outcomes don't contribute
|
||||
|
||||
const double probability = node->outcomeProbabilities[i];
|
||||
const double childValue = child->lookaheadScore;
|
||||
expectedValue += probability * childValue;
|
||||
totalProbability += probability;
|
||||
visitedChildCount++;
|
||||
}
|
||||
|
||||
// Use expected value if we have visited outcomes, else use average
|
||||
if (visitedChildCount > 0) {
|
||||
// CRITICAL: Normalize by total probability to get correct expected value
|
||||
// when not all outcomes have been visited yet
|
||||
if (totalProbability > 0.0 && totalProbability < 1.0) {
|
||||
// Normalize to account for unvisited outcomes
|
||||
// This gives the correct expected value among visited outcomes
|
||||
expectedValue /= totalProbability;
|
||||
}
|
||||
node->lookaheadScore = expectedValue;
|
||||
} else {
|
||||
// No outcomes visited yet, fall back to average
|
||||
if (node->visitCount == 1) {
|
||||
node->lookaheadScore = reward;
|
||||
} else {
|
||||
const double alpha = 1.0 / node->visitCount;
|
||||
node->lookaheadScore = (1.0 - alpha) * node->lookaheadScore + alpha * reward;
|
||||
}
|
||||
}
|
||||
} else if (useMinimaxBackup && !node->children.empty()) {
|
||||
// Minimax backup: use best/worst child value for adversarial games
|
||||
// The operation (MAX or MIN) depends on whose turn it is at THIS node
|
||||
// - If this node is root player's turn: root chooses MAX (best for root)
|
||||
@@ -712,18 +887,43 @@ auto AbstractMCTSAI::LogSearchResults(
|
||||
|
||||
if (!bestSequence.empty()) {
|
||||
printf("MCTS: Best sequence from chosen action (final: %.2f):\n", sequenceScore);
|
||||
int displayedStep = 0;
|
||||
for (size_t i = 0; i < bestSequence.size(); ++i) {
|
||||
const auto* node = bestSequence[i];
|
||||
printf(" %zu.", i + 1);
|
||||
|
||||
// Skip outcome nodes (children of chance nodes) - they're displayed with their parent
|
||||
if (i > 0 && node->parent && node->parent->IsChanceNode()) { continue; }
|
||||
|
||||
displayedStep++;
|
||||
printf(" %d.", displayedStep);
|
||||
if (node->action) { printf(" %s", node->action->getDescription().c_str()); }
|
||||
|
||||
// If this is a chance node, display outcome probabilities and scores
|
||||
if (node->IsChanceNode() && !node->outcomeProbabilities.empty()) {
|
||||
printf(" [");
|
||||
for (size_t j = 0; j < node->outcomeProbabilities.size(); ++j) {
|
||||
if (j > 0) printf(", ");
|
||||
const double prob = node->outcomeProbabilities[j] * 100;
|
||||
// Show lookahead score for each outcome if child exists
|
||||
if (j < node->children.size() && node->children[j]->visitCount > 0) {
|
||||
printf("%.0f%%->%.1f", prob, node->children[j]->lookaheadScore);
|
||||
} else {
|
||||
printf("%.0f%%->?", prob);
|
||||
}
|
||||
}
|
||||
printf("]");
|
||||
}
|
||||
|
||||
printf(" (visits:%d, immediate:%.2f, lookahead:%.2f)\n",
|
||||
node->visitCount,
|
||||
node->immediateScore,
|
||||
node->lookaheadScore);
|
||||
|
||||
// For non-root nodes in the sequence, show what the top alternatives were
|
||||
// This helps diagnose if opponent moves are being properly explored
|
||||
if (i > 0 && node->parent && !node->parent->children.empty()) {
|
||||
// Skip showing alternatives for chance nodes (they have outcome children, not action
|
||||
// alternatives)
|
||||
if (i > 0 && node->parent && !node->parent->children.empty() &&
|
||||
!node->parent->IsChanceNode()) {
|
||||
// Collect all siblings (including this node) and sort by visit count
|
||||
std::vector<const MCTSNode*> siblings;
|
||||
siblings.reserve(node->parent->children.size());
|
||||
@@ -810,6 +1010,10 @@ auto AbstractMCTSAI::DumpNodeRecursive(
|
||||
|
||||
// Write node information
|
||||
out << indent;
|
||||
|
||||
// Show node type for chance nodes
|
||||
if (node->IsChanceNode()) { out << "[CHANCE] "; }
|
||||
|
||||
if (node->action) {
|
||||
out << node->action->getDescription();
|
||||
} else {
|
||||
@@ -828,6 +1032,47 @@ auto AbstractMCTSAI::DumpNodeRecursive(
|
||||
if (node->isTerminal) { out << ", TERMINAL"; }
|
||||
out << ")\n";
|
||||
|
||||
// Show outcome probabilities and rolls for chance nodes
|
||||
if (node->IsChanceNode() && !node->outcomeProbabilities.empty()) {
|
||||
// Create indent for outcome info (keep tree structure but replace branch chars)
|
||||
std::string outcomeIndent;
|
||||
for (size_t k = 0; k < indent.size(); ++k) {
|
||||
// Replace ├ and └ with │ or space to continue the tree line
|
||||
// Check for UTF-8 multi-byte sequences
|
||||
const unsigned char ch = static_cast<unsigned char>(indent[k]);
|
||||
if (ch == 0xE2 && k + 2 < indent.size()) {
|
||||
// UTF-8 box drawing characters start with 0xE2
|
||||
const unsigned char ch2 = static_cast<unsigned char>(indent[k + 1]);
|
||||
const unsigned char ch3 = static_cast<unsigned char>(indent[k + 2]);
|
||||
if (ch2 == 0x94) {
|
||||
if (ch3 == 0x9C || ch3 == 0x94) {
|
||||
// ├ (0xE2 0x94 0x9C) or └ (0xE2 0x94 0x94) -> replace with │
|
||||
outcomeIndent += "│ ";
|
||||
k += 2; // Skip the next 2 bytes
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Other box drawing chars, keep them
|
||||
outcomeIndent += indent[k];
|
||||
outcomeIndent += indent[k + 1];
|
||||
outcomeIndent += indent[k + 2];
|
||||
k += 2;
|
||||
} else {
|
||||
outcomeIndent += indent[k];
|
||||
}
|
||||
}
|
||||
out << outcomeIndent << " Outcomes: ";
|
||||
for (size_t i = 0; i < node->outcomeProbabilities.size(); ++i) {
|
||||
if (i > 0) out << ", ";
|
||||
out << "[" << i << "] p=" << std::fixed << std::setprecision(3)
|
||||
<< node->outcomeProbabilities[i];
|
||||
if (i < node->outcomeRolls.size()) {
|
||||
out << " roll=" << std::fixed << std::setprecision(1) << node->outcomeRolls[i];
|
||||
}
|
||||
}
|
||||
out << "\n";
|
||||
}
|
||||
|
||||
// Recursively dump children
|
||||
if (!node->children.empty()) {
|
||||
for (size_t i = 0; i < node->children.size(); ++i) {
|
||||
|
||||
@@ -27,6 +27,11 @@ public:
|
||||
|
||||
// Check if two actions are equivalent
|
||||
[[nodiscard]] virtual bool equals(const MCTSAction& other) const = 0;
|
||||
|
||||
// Check if this action requires a chance node (binary success/failure outcome)
|
||||
// Examples: START_FIRE, RAISE_DEAD, EXTINGUISH_FIRE
|
||||
// If true, the game engine should provide outcome probabilities
|
||||
[[nodiscard]] virtual bool requiresChanceNode() const = 0;
|
||||
};
|
||||
|
||||
} // namespace mcts
|
||||
|
||||
@@ -16,15 +16,37 @@
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
// Information about binary chance outcomes (success/failure)
|
||||
struct BinaryOutcomeInfo {
|
||||
double successProbability; // Probability of success (0.0 to 1.0)
|
||||
|
||||
// Representative D100 rolls for each outcome
|
||||
// For binary actions: [successRoll, failureRoll]
|
||||
// These are midpoints of the success/failure ranges for simulation
|
||||
[[nodiscard]] std::vector<double> getRepresentativeRolls() const {
|
||||
// Success roll: midpoint between (1-successProbability)*100 and 100
|
||||
// Failure roll: midpoint between 0 and (1-successProbability)*100
|
||||
const double successRoll = (1.0 - successProbability / 2.0) * 100.0;
|
||||
const double failureRoll = (1.0 - successProbability) / 2.0 * 100.0;
|
||||
return {successRoll, failureRoll};
|
||||
}
|
||||
|
||||
[[nodiscard]] std::vector<double> getProbabilities() const {
|
||||
return {successProbability, 1.0 - successProbability};
|
||||
}
|
||||
};
|
||||
|
||||
// Abstract interface for game engines
|
||||
class MCTSGameEngine {
|
||||
public:
|
||||
virtual ~MCTSGameEngine() = default;
|
||||
|
||||
// Apply an action to a state and return the resulting state
|
||||
// If deterministicRoll is provided (0.0-100.0), use that for any random outcomes
|
||||
[[nodiscard]] virtual std::unique_ptr<MCTSGameState> applyAction(
|
||||
const MCTSGameState& state,
|
||||
const MCTSAction& action) const = 0;
|
||||
const MCTSAction& action,
|
||||
double deterministicRoll = -1.0) const = 0;
|
||||
|
||||
// Apply an action to a mutable state in-place (for efficient simulation)
|
||||
// Default: clone, apply, and move the result back
|
||||
@@ -111,6 +133,13 @@ public:
|
||||
(void)state; // Suppress unused parameter warning
|
||||
return filteredIndex;
|
||||
}
|
||||
|
||||
// Get binary outcome information for an action that requires a chance node
|
||||
// Only called for actions where action.requiresChanceNode() returns true
|
||||
// Returns success probability for binary success/failure actions
|
||||
[[nodiscard]] virtual BinaryOutcomeInfo getBinaryOutcomeInfo(
|
||||
const MCTSGameState& state,
|
||||
const MCTSAction& action) const = 0;
|
||||
};
|
||||
|
||||
} // namespace mcts
|
||||
|
||||
@@ -17,8 +17,16 @@
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
// Node type for MCTS tree
|
||||
enum class NodeType {
|
||||
DECISION, // Player chooses an action (standard MCTS node)
|
||||
CHANCE // Nature determines outcome (for probabilistic actions)
|
||||
};
|
||||
|
||||
// Abstract MCTS Node structure
|
||||
struct MCTSNode {
|
||||
// Node type
|
||||
NodeType nodeType = NodeType::DECISION;
|
||||
// Action information
|
||||
std::unique_ptr<MCTSAction> action; // The action that led to this node (null for root)
|
||||
size_t actionIndex = SIZE_MAX; // Index in the original actions array (SIZE_MAX for root)
|
||||
@@ -43,6 +51,10 @@ struct MCTSNode {
|
||||
size_t totalActions = 0; // Total number of available actions
|
||||
MCTSNode* parent = nullptr;
|
||||
|
||||
// Chance node specific fields (only used when nodeType == CHANCE)
|
||||
std::vector<double> outcomeProbabilities; // Probability of each outcome
|
||||
std::vector<double> outcomeRolls; // Representative roll for each outcome
|
||||
|
||||
// Game context
|
||||
MCTSPlayerId playerId;
|
||||
int depth = 0;
|
||||
@@ -136,6 +148,40 @@ struct MCTSNode {
|
||||
// Check if this node can be expanded
|
||||
[[nodiscard]] bool CanExpand() const { return nextUntriedActionIndex < totalActions; }
|
||||
|
||||
// Check if this is a chance node
|
||||
[[nodiscard]] bool IsChanceNode() const { return nodeType == NodeType::CHANCE; }
|
||||
|
||||
// Check if this is a decision node
|
||||
[[nodiscard]] bool IsDecisionNode() const { return nodeType == NodeType::DECISION; }
|
||||
|
||||
// Get best child from chance node (probability-weighted selection)
|
||||
// For chance nodes, we want to explore outcomes proportionally to their probability
|
||||
[[nodiscard]] MCTSNode* GetBestChanceChild() const {
|
||||
if (children.empty() || !IsChanceNode()) return nullptr;
|
||||
|
||||
// Find the outcome that is most under-explored relative to its probability
|
||||
// Expected visits for outcome i: total_visits * probability[i]
|
||||
// Actual visits: child[i]->visitCount
|
||||
// Deficit: expected - actual
|
||||
size_t bestIndex = 0;
|
||||
double bestDeficit = -std::numeric_limits<double>::max();
|
||||
|
||||
for (size_t i = 0; i < children.size(); i++) {
|
||||
if (!children[i] || children[i]->isRedundant) continue;
|
||||
|
||||
const double expectedVisits = visitCount * outcomeProbabilities[i];
|
||||
const double actualVisits = static_cast<double>(children[i]->visitCount);
|
||||
const double deficit = expectedVisits - actualVisits;
|
||||
|
||||
if (deficit > bestDeficit) {
|
||||
bestDeficit = deficit;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return children[bestIndex].get();
|
||||
}
|
||||
|
||||
// Get best child based on UCB1
|
||||
[[nodiscard]] MCTSNode* GetBestChild(const double explorationConstant) const {
|
||||
if (children.empty()) return nullptr;
|
||||
|
||||
@@ -357,9 +357,7 @@ auto UnitValue(
|
||||
kCastleMultiplierBonus * (terrain->modifier().castle().integrity() + 25) / 100.0;
|
||||
}
|
||||
double onFireMultiplier = 1.0;
|
||||
if (terrain->modifier().fire().present() && (isAttacker || attackerWantsCastles)) {
|
||||
onFireMultiplier *= kOnFireMultiplier;
|
||||
}
|
||||
if (terrain->modifier().fire().present()) { onFireMultiplier *= kOnFireMultiplier; }
|
||||
{
|
||||
for (const auto adjacentCoords = HexMapUtils::GetAdjacentCoords(map, location);
|
||||
const auto &c : adjacentCoords) {
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
|
||||
#define DEBUG_FLEE_DECISIONS
|
||||
|
||||
#include <chrono>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
|
||||
#include "AIAttackerStrategySelector.hpp"
|
||||
#include "AIConfig.hpp"
|
||||
#include "AIDefenderStrategySelector.hpp"
|
||||
@@ -208,6 +213,35 @@ auto ShardokAIClient::StandardChooseCommandIndex(
|
||||
IterativeDeepeningAI::SearchResult search_result;
|
||||
|
||||
if (aiAlgorithmType == AIAlgorithmType::MCTS) {
|
||||
// Set unique debug dump path for each action using timestamp
|
||||
const auto now = std::chrono::system_clock::now();
|
||||
const auto nowTime = std::chrono::system_clock::to_time_t(now);
|
||||
const auto nowMs =
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()) %
|
||||
1000;
|
||||
|
||||
std::ostringstream pathStream;
|
||||
pathStream << "/tmp/shardok_debug_"
|
||||
<< std::put_time(std::localtime(&nowTime), "%Y%m%d_%H%M%S") << "_"
|
||||
<< std::setfill('0') << std::setw(3) << nowMs.count() << "_p"
|
||||
<< static_cast<int>(playerId) << ".txt";
|
||||
adjustedMCTSConfig.debugDumpPath = pathStream.str();
|
||||
|
||||
// Also dump the game state to a file for reproduction
|
||||
std::ostringstream statePathStream;
|
||||
statePathStream << "/tmp/shardok_state_"
|
||||
<< std::put_time(std::localtime(&nowTime), "%Y%m%d_%H%M%S") << "_"
|
||||
<< std::setfill('0') << std::setw(3) << nowMs.count() << "_p"
|
||||
<< static_cast<int>(playerId) << ".bin";
|
||||
const std::string statePath = statePathStream.str();
|
||||
|
||||
// Write the flatbuffer game state to file using SaveTo method
|
||||
if (guessedState.SaveTo(statePath)) {
|
||||
printf("Game state dumped to: %s\n", statePath.c_str());
|
||||
} else {
|
||||
printf("Failed to dump game state to: %s\n", statePath.c_str());
|
||||
}
|
||||
|
||||
// Using Monte Carlo Tree Search AI (with abstraction layer)
|
||||
ShardokMCTSAI ai(
|
||||
playerId,
|
||||
|
||||
@@ -63,4 +63,15 @@ bool ShardokAction::equals(const MCTSAction& other) const {
|
||||
return commandIndex_ == shardokOther->commandIndex_;
|
||||
}
|
||||
|
||||
bool ShardokAction::requiresChanceNode() const {
|
||||
// Binary actions with success/failure outcomes
|
||||
// These require chance nodes to properly model their probabilistic nature
|
||||
switch (type_) {
|
||||
case CommandType::START_FIRE_COMMAND:
|
||||
case CommandType::EXTINGUISH_FIRE_COMMAND:
|
||||
case CommandType::RAISE_DEAD_COMMAND: return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace shardok::mcts
|
||||
@@ -36,6 +36,7 @@ public:
|
||||
[[nodiscard]] std::string getDescription() const override;
|
||||
[[nodiscard]] std::unique_ptr<MCTSAction> clone() const override;
|
||||
[[nodiscard]] bool equals(const MCTSAction& other) const override;
|
||||
[[nodiscard]] bool requiresChanceNode() const override;
|
||||
|
||||
// Shardok-specific accessors (O(1), no allocations)
|
||||
[[nodiscard]] int getType() const { return static_cast<int>(type_); }
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
|
||||
#include "ShardokAction.hpp"
|
||||
#include "ShardokGameState.hpp"
|
||||
#include "src/main/cpp/net/eagle0/common/SequenceRandomGenerator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/common/mcts/abstract/MCTSTypes.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AICommandFilter.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIHeuristicWeighting.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokException.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
|
||||
namespace shardok::mcts {
|
||||
@@ -60,7 +62,8 @@ ShardokGameEngine::ShardokGameEngine(
|
||||
|
||||
std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
|
||||
const MCTSGameState& state,
|
||||
const MCTSAction& action) const {
|
||||
const MCTSAction& action,
|
||||
double deterministicRoll) const {
|
||||
const auto* shardokState = dynamic_cast<const ShardokGameState*>(&state);
|
||||
const auto* shardokAction = dynamic_cast<const ShardokAction*>(&action);
|
||||
|
||||
@@ -90,7 +93,15 @@ std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
|
||||
engine = std::make_shared<ShardokEngine>(*engine);
|
||||
}
|
||||
|
||||
engine->PostCommand(currentPlayer, shardokAction->getIndex(), nullptr);
|
||||
// Create deterministic random generator if a specific roll is requested
|
||||
std::shared_ptr<::RandomGenerator> randomGen = nullptr;
|
||||
if (deterministicRoll >= 0.0) {
|
||||
// Convert D100 roll (0-100) to probability (0.0-1.0)
|
||||
randomGen = std::make_shared<::SequenceRandomGenerator>(
|
||||
std::vector<double>{deterministicRoll / 100.0});
|
||||
}
|
||||
|
||||
engine->PostCommand(currentPlayer, shardokAction->getIndex(), randomGen);
|
||||
|
||||
// Create and return the new state (don't cache the mutated engine)
|
||||
auto newState = std::make_unique<ShardokGameState>(
|
||||
@@ -488,6 +499,56 @@ void ShardokGameEngine::resetCacheStatistics() {
|
||||
timeInLegalActionsComputation_.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
BinaryOutcomeInfo ShardokGameEngine::getBinaryOutcomeInfo(
|
||||
const MCTSGameState& state,
|
||||
const MCTSAction& action) const {
|
||||
const auto* shardokState = dynamic_cast<const ShardokGameState*>(&state);
|
||||
const auto* shardokAction = dynamic_cast<const ShardokAction*>(&action);
|
||||
|
||||
if (!shardokState || !shardokAction) {
|
||||
throw ShardokInternalErrorException("Invalid state or action type in getBinaryOutcomeInfo");
|
||||
}
|
||||
|
||||
const auto currentPlayer = static_cast<PlayerId>(state.currentPlayerId());
|
||||
|
||||
// Get or create the engine for this state
|
||||
std::shared_ptr<ShardokEngine> engine;
|
||||
if (auto cachedEngine = shardokState->getCachedEngine()) {
|
||||
engine = cachedEngine;
|
||||
} else {
|
||||
engine = std::make_shared<ShardokEngine>(
|
||||
gameSettings_,
|
||||
shardokState->getShardokState(),
|
||||
criticalTileCoords_,
|
||||
0,
|
||||
false);
|
||||
// Populate command cache
|
||||
[[maybe_unused]] const auto commands =
|
||||
engine->GetAvailableCommandsForAIPlayer(currentPlayer);
|
||||
shardokState->setCachedEngine(engine);
|
||||
}
|
||||
|
||||
// Get command descriptors
|
||||
const auto descriptors = engine->GetAvailableCommandsForAIPlayer(currentPlayer);
|
||||
const size_t actionIndex = shardokAction->getIndex();
|
||||
|
||||
if (actionIndex >= descriptors->size()) {
|
||||
throw ShardokInternalErrorException("Action index out of range in getBinaryOutcomeInfo");
|
||||
}
|
||||
|
||||
const auto& descriptor = descriptors->at(actionIndex);
|
||||
|
||||
// Get success probability
|
||||
if (!descriptor->HasOdds()) {
|
||||
throw ShardokInternalErrorException("Action does not have odds in getBinaryOutcomeInfo");
|
||||
}
|
||||
|
||||
const auto successChancePercentile = descriptor->GetOddsPercentile();
|
||||
const double successProbability = static_cast<double>(successChancePercentile) / 100.0;
|
||||
|
||||
return BinaryOutcomeInfo{successProbability};
|
||||
}
|
||||
|
||||
void ShardokGameEngine::clearLegalActionsCache() { legalActionsCache_.clear(); }
|
||||
|
||||
// Extern-linkage function for testing
|
||||
|
||||
@@ -47,7 +47,8 @@ public:
|
||||
// MCTSGameEngine interface implementation
|
||||
[[nodiscard]] std::unique_ptr<MCTSGameState> applyAction(
|
||||
const MCTSGameState& state,
|
||||
const MCTSAction& action) const override;
|
||||
const MCTSAction& action,
|
||||
double deterministicRoll = -1.0) const override;
|
||||
|
||||
void applyActionMutable(std::unique_ptr<MCTSGameState>& state, const MCTSAction& action)
|
||||
const override;
|
||||
@@ -85,6 +86,10 @@ public:
|
||||
size_t filteredIndex,
|
||||
const MCTSGameState& state) const override;
|
||||
|
||||
[[nodiscard]] BinaryOutcomeInfo getBinaryOutcomeInfo(
|
||||
const MCTSGameState& state,
|
||||
const MCTSAction& action) const override;
|
||||
|
||||
// Report transposition table statistics
|
||||
void reportCacheStatistics() const;
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ vector<shared_ptr<ShardokAIClient>> ShardokGameController::MakeAIClients(
|
||||
mctsConfig.maxPlayerFlips = 1;
|
||||
mctsConfig.maxSimulationFlips = 1;
|
||||
mctsConfig.simulationPolicy = mcts::MCTSSimulationPolicy::WEIGHTED_HEURISTIC;
|
||||
|
||||
for (const auto &pi : e->GetPlayerInfos()) {
|
||||
if (pi.is_ai()) {
|
||||
auto newClient = std::make_shared<ShardokAIClient>(
|
||||
|
||||
@@ -40,6 +40,10 @@ public:
|
||||
return tttOther && tttOther->position_ == position_;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool requiresChanceNode() const override {
|
||||
return false; // Tic-Tac-Toe is deterministic
|
||||
}
|
||||
|
||||
[[nodiscard]] int getPosition() const { return position_; }
|
||||
|
||||
private:
|
||||
@@ -134,7 +138,9 @@ class TicTacToeEngine : public MCTSGameEngine {
|
||||
public:
|
||||
[[nodiscard]] std::unique_ptr<MCTSGameState> applyAction(
|
||||
const MCTSGameState& state,
|
||||
const MCTSAction& action) const override {
|
||||
const MCTSAction& action,
|
||||
double deterministicRoll = -1.0) const override {
|
||||
(void)deterministicRoll; // Tic-Tac-Toe is deterministic, ignore roll
|
||||
const auto* tttState = dynamic_cast<const TicTacToeState*>(&state);
|
||||
const auto* tttAction = dynamic_cast<const TicTacToeAction*>(&action);
|
||||
|
||||
@@ -174,6 +180,13 @@ public:
|
||||
return state.score(playerId);
|
||||
}
|
||||
|
||||
[[nodiscard]] BinaryOutcomeInfo getBinaryOutcomeInfo(
|
||||
const MCTSGameState& /*state*/,
|
||||
const MCTSAction& /*action*/) const override {
|
||||
// Tic-Tac-Toe is deterministic - this should never be called
|
||||
throw std::runtime_error("getBinaryOutcomeInfo called on deterministic Tic-Tac-Toe engine");
|
||||
}
|
||||
|
||||
// All other methods use the default implementations from MCTSGameEngine:
|
||||
// - filterActions: no filtering (returns all indices)
|
||||
// - simulateRandomPlayout: uses the efficient mutable state approach
|
||||
|
||||
@@ -8,15 +8,18 @@
|
||||
|
||||
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
|
||||
#include "src/main/cpp/net/eagle0/common/TsvParser.hpp"
|
||||
#include "src/main/cpp/net/eagle0/common/byte_vector.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/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/ShardokAIClient.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/mcts/ShardokMCTSAI.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/MCTSOptimizedAIScoreCalculator.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/main/cpp/net/eagle0/shardok/library/fb_helpers/GameStateHelpers.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/HexMapHelpers.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/util/MapLoader.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/AIPerformanceTestHelpers.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/GameSettings_test_utils.hpp"
|
||||
@@ -29,7 +32,7 @@ namespace shardok {
|
||||
class ShardokMCTSAITest : public testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
// Set up filesystem path for loading maps and settings
|
||||
// Set up filesystem path for loading maps and settings - MUST be before creating settings
|
||||
auto path = std::filesystem::current_path();
|
||||
path.append("bazel-bin/src/test/cpp/net/eagle0/shardok/ai/mcts/shardok_mcts_ai_basic_test");
|
||||
FilesystemUtils::SetExecPath(path);
|
||||
@@ -38,12 +41,20 @@ protected:
|
||||
InitializeGameSettings();
|
||||
|
||||
// Load production settings from TSV file to match real game configuration
|
||||
const std::string settingsPath =
|
||||
FilesystemUtils::StaticShardokFilesDirectory() + "settings.tsv";
|
||||
const std::string settingsTsv = std::string(byte_vector::FromPath(settingsPath));
|
||||
TsvParser parser;
|
||||
const auto valuesAndTypes = parser.ParseColumnEntryTsv(settingsTsv);
|
||||
GetGameSettingsSetter().SetFromTypesAndValues(valuesAndTypes[1], valuesAndTypes[0]);
|
||||
try {
|
||||
const std::string settingsPath =
|
||||
FilesystemUtils::StaticShardokFilesDirectory() + "settings.tsv";
|
||||
const std::string settingsTsv = std::string(byte_vector::FromPath(settingsPath));
|
||||
TsvParser parser;
|
||||
const auto valuesAndTypes = parser.ParseColumnEntryTsv(settingsTsv);
|
||||
GetGameSettingsSetter().SetFromTypesAndValues(valuesAndTypes[1], valuesAndTypes[0]);
|
||||
printf("[TEST SETUP] Successfully loaded production settings from %s\n",
|
||||
settingsPath.c_str());
|
||||
} catch (const std::exception& e) {
|
||||
printf("[TEST SETUP] Warning: Could not load settings.tsv (%s). Using test defaults "
|
||||
"instead.\n",
|
||||
e.what());
|
||||
}
|
||||
|
||||
// Get the configured settings
|
||||
settings = GetGameSettings();
|
||||
@@ -303,7 +314,8 @@ TEST_F(ShardokMCTSAITest, PrefersArcheryOverEndTurn) {
|
||||
static_cast<int>(chosenCommand->GetCommandType()));
|
||||
|
||||
// CRITICAL: AI should choose tactical actions (ARCHERY, MOVE, etc), NOT END_TURN
|
||||
// With production settings, the AI may choose MOVE→ARCHERY as a better strategy
|
||||
// With the chance node normalization fix, the AI correctly evaluates sequences
|
||||
// and may choose MOVE→ARCHERY as a better strategy than ARCHERY alone
|
||||
EXPECT_NE(
|
||||
chosenCommand->GetCommandType(),
|
||||
net::eagle0::shardok::common::CommandType::END_TURN_COMMAND)
|
||||
@@ -413,7 +425,7 @@ TEST_F(ShardokMCTSAITest, PrefersArcheryOverEndTurnWithZeroFlips) {
|
||||
|
||||
const auto& chosenCommand = (*commands)[result.bestCommandIndex];
|
||||
|
||||
// With production settings, tactical actions should be preferred over END_TURN
|
||||
// With getActionWeights fix and chance node normalization, tactical actions should be preferred
|
||||
// The AI may choose MOVE→ARCHERY as a better strategy than ARCHERY alone
|
||||
EXPECT_NE(
|
||||
chosenCommand->GetCommandType(),
|
||||
@@ -776,4 +788,269 @@ TEST_F(ShardokMCTSAITest, MinimaxSetupPhaseMixedChildren) {
|
||||
printf("[MINIMAX_SETUP_MIXED_TEST] P0 setup phase with mixed children completed\n");
|
||||
}
|
||||
|
||||
// Test that AI should NOT choose START_FIRE when it's not beneficial
|
||||
// This test demonstrates the chance node behavior with binary actions
|
||||
TEST_F(ShardokMCTSAITest, DoesNotPreferStartFireWhenNotBeneficial) {
|
||||
// Load the Alah map for proper castle positions
|
||||
const auto alahMapProto = LoadMap("Alah");
|
||||
|
||||
// Create GAME_RUNNING state with units positioned as specified
|
||||
flatbuffers::FlatBufferBuilder fbb;
|
||||
fbb.ForceDefaults(true);
|
||||
|
||||
// Create GameStatus
|
||||
net::eagle0::shardok::storage::fb::EndGameCondition endGameCondition;
|
||||
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);
|
||||
const auto gameStatusOffset = statusB.Finish();
|
||||
|
||||
// Convert map proto to flatbuffer
|
||||
const auto mapOffset = shardok::fb::ConvertHexMapProto(
|
||||
fbb,
|
||||
alahMapProto,
|
||||
false, // isWinter
|
||||
settings->GetGetter().Backing().initial_winter_ice_integrity());
|
||||
const auto playerInfoOffset = fbb.CreateVector(
|
||||
std::vector{AddPlayerInfo(fbb, 0, false, 1000), AddPlayerInfo(fbb, 1, true, 1000)});
|
||||
|
||||
// Create units with specified positions using default AddGenericUnit
|
||||
// Attackers: (4,3), (4,4), (4,5)
|
||||
// Defenders in castles: (10,12), (11,10), (11,13)
|
||||
std::vector<Unit> units{
|
||||
AddGenericUnit(0, 0, Coords(4, 3)), // Attacker
|
||||
AddGenericUnit(0, 1, Coords(4, 4)), // Attacker
|
||||
AddGenericUnit(0, 2, Coords(4, 5)), // Attacker
|
||||
AddGenericUnit(1, 3, Coords(10, 12)), // Defender in castle
|
||||
AddGenericUnit(1, 4, Coords(11, 10)), // Defender in castle
|
||||
AddGenericUnit(1, 5, Coords(11, 13)), // Defender in castle
|
||||
};
|
||||
|
||||
// Configure all units with 1000 troops, light infantry
|
||||
// Match real game scenario: attackers have hero stats 75, defenders have 102
|
||||
// Defenders have much more food (664.57), attackers have 100.0
|
||||
for (size_t i = 0; i < units.size(); ++i) {
|
||||
units[i].mutable_battalion().mutate_size(1000);
|
||||
units[i].mutable_battalion().mutate_type(
|
||||
net::eagle0::shardok::storage::fb::BattalionTypeId_LIGHT_INFANTRY);
|
||||
|
||||
// Configure based on player (attackers=0-2, defenders=3-5)
|
||||
if (i < 3) {
|
||||
// Attacker units (player 0)
|
||||
units[i].mutate_food_remaining(100.0f);
|
||||
units[i].mutable_attached_hero().mutate_strength(75);
|
||||
units[i].mutable_attached_hero().mutate_agility(75);
|
||||
units[i].mutable_attached_hero().mutate_wisdom(75);
|
||||
units[i].mutable_attached_hero().mutate_charisma(75);
|
||||
units[i].mutable_attached_hero().mutate_constitution(75);
|
||||
units[i].mutable_attached_hero().mutate_vigor(75);
|
||||
units[i].mutable_attached_hero().mutate_starting_vigor(75);
|
||||
units[i].mutable_attached_hero().mutate_bravery(75);
|
||||
units[i].mutable_attached_hero().mutate_integrity(75);
|
||||
units[i].mutable_attached_hero().mutate_ambition(75);
|
||||
units[i].mutate_can_start_fire(true); // Enable for attackers too (real game has this)
|
||||
} else {
|
||||
// Defender units (player 1)
|
||||
units[i].mutate_food_remaining(664.57f); // Much higher food in real game
|
||||
units[i].mutable_attached_hero().mutate_strength(102);
|
||||
units[i].mutable_attached_hero().mutate_agility(102);
|
||||
units[i].mutable_attached_hero().mutate_wisdom(102);
|
||||
units[i].mutable_attached_hero().mutate_charisma(102);
|
||||
units[i].mutable_attached_hero().mutate_constitution(102);
|
||||
units[i].mutable_attached_hero().mutate_vigor(102);
|
||||
units[i].mutable_attached_hero().mutate_starting_vigor(102);
|
||||
units[i].mutable_attached_hero().mutate_bravery(102);
|
||||
units[i].mutable_attached_hero().mutate_integrity(102);
|
||||
units[i].mutable_attached_hero().mutate_ambition(102);
|
||||
units[i].mutate_can_start_fire(true);
|
||||
}
|
||||
};
|
||||
|
||||
const auto unitsOffset = fbb.CreateVectorOfSortedStructs(&units);
|
||||
|
||||
// Initialize possible_chargee_ids
|
||||
std::vector<int16_t> chargeeIds(6, -1);
|
||||
const auto chargeeIdsOffset = fbb.CreateVector(chargeeIds);
|
||||
|
||||
// Create weather
|
||||
auto weather = net::eagle0::shardok::storage::fb::Weather(
|
||||
net::eagle0::shardok::storage::fb::WeatherConditions_SUN,
|
||||
net::eagle0::shardok::storage::fb::Wind(
|
||||
net::eagle0::shardok::storage::fb::HexMapDirection_EAST,
|
||||
0));
|
||||
|
||||
auto gs = net::eagle0::shardok::storage::fb::GameStateBuilder(fbb);
|
||||
gs.add_units(unitsOffset);
|
||||
gs.add_hex_map(mapOffset);
|
||||
gs.add_status(gameStatusOffset);
|
||||
gs.add_player_infos(playerInfoOffset);
|
||||
gs.add_current_round(1);
|
||||
gs.add_possible_chargee_ids(chargeeIdsOffset);
|
||||
gs.add_eligible_charger_id(-1);
|
||||
gs.add_weather(&weather);
|
||||
gs.add_month(4);
|
||||
gs.add_current_player(1); // Defender's turn
|
||||
|
||||
fbb.Finish(gs.Finish());
|
||||
auto gameState = GameStateW(fbb);
|
||||
|
||||
// Verify state
|
||||
ASSERT_EQ(
|
||||
gameState->status()->state(),
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_GAME_RUNNING);
|
||||
ASSERT_EQ(gameState->current_player(), 1);
|
||||
|
||||
// Create ShardokEngine to get available commands
|
||||
ShardokEngine engine(settings, gameState);
|
||||
const PlayerId defenderPlayerId = 1;
|
||||
const auto commands = engine.GetAvailableCommandsForAIPlayer(defenderPlayerId);
|
||||
ASSERT_GT(commands->size(), 0) << "Defender should have commands available";
|
||||
|
||||
// Check if START_FIRE is available
|
||||
bool hasStartFireCommand = false;
|
||||
for (const auto& cmd : *commands) {
|
||||
if (cmd->GetCommandType() ==
|
||||
net::eagle0::shardok::common::CommandType::START_FIRE_COMMAND) {
|
||||
hasStartFireCommand = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
printf("[START_FIRE_TEST] Defender has START_FIRE available: %s\n",
|
||||
hasStartFireCommand ? "YES" : "NO");
|
||||
|
||||
// Get the HexMap from the created game state
|
||||
const HexMap* alahMap = gameState->hex_map();
|
||||
|
||||
// Create castle coords and caches for Alah map
|
||||
auto alahCastleCoords = std::make_unique<CoordsSet>(alahMap);
|
||||
auto alahAlCache = std::make_unique<AttackLocationsCache>(alahMap, settings->GetGetter());
|
||||
// Use MCTS-optimized scorer to match real game behavior
|
||||
auto alahScorer =
|
||||
MakeMCTSOptimizedAIScoreCalculator(settings->GetGetter(), apdCache, alahAlCache);
|
||||
|
||||
// Create MCTS AI for defender with tree dump enabled
|
||||
AIStrategy defenderStrategy{};
|
||||
defenderStrategy.strategyType = AIStrategy::STRATEGY_HOLD_CASTLES;
|
||||
|
||||
ShardokMCTSAI::MCTSConfig config;
|
||||
config.useMultithreading = false; // Disable for deterministic tree dump
|
||||
config.maxSimulationDepth = 10;
|
||||
config.maxPlayerFlips = 1;
|
||||
config.maxSimulationFlips = 1;
|
||||
config.simulationPolicy = mcts::MCTSSimulationPolicy::WEIGHTED_HEURISTIC;
|
||||
config.backpropagationPolicy = mcts::MCTSBackpropagationPolicy::MINIMAX;
|
||||
config.debugDumpPath = "/tmp/mcts_start_fire_test_tree.txt"; // Enable tree dump
|
||||
|
||||
auto defenderAI = std::make_unique<ShardokMCTSAI>(
|
||||
1, // playerId
|
||||
true, // isDefender
|
||||
defenderStrategy,
|
||||
*alahCastleCoords,
|
||||
*alahScorer,
|
||||
apdCache,
|
||||
alahAlCache,
|
||||
config);
|
||||
|
||||
AITimeBudget budget;
|
||||
budget.remainingBudget =
|
||||
std::chrono::milliseconds(10000); // Give much more time to match real game exploration
|
||||
budget.minDepthRequired = 1;
|
||||
|
||||
const auto result = defenderAI->Search(settings, gameState, budget);
|
||||
|
||||
ASSERT_TRUE(result.searchCompleted) << "Search should complete";
|
||||
ASSERT_LT(result.bestCommandIndex, commands->size()) << "bestCommandIndex should be valid";
|
||||
|
||||
const auto& chosenCommand = (*commands)[result.bestCommandIndex];
|
||||
|
||||
printf("[START_FIRE_TEST] Chosen command type: %d\n",
|
||||
static_cast<int>(chosenCommand->GetCommandType()));
|
||||
printf("[START_FIRE_TEST] Tree dump saved to: %s\n", config.debugDumpPath.c_str());
|
||||
|
||||
// With the chance node normalization fix, the AI correctly evaluates START_FIRE
|
||||
// as not beneficial and chooses END_TURN instead.
|
||||
// The bug was that when only one outcome of a chance node was visited,
|
||||
// expectedValue = probability * value gave wrong results (e.g., 0.04 * 35 = 1.4).
|
||||
// The fix normalizes by dividing by totalProbability.
|
||||
EXPECT_EQ(
|
||||
chosenCommand->GetCommandType(),
|
||||
net::eagle0::shardok::common::CommandType::END_TURN_COMMAND)
|
||||
<< "AI should choose END_TURN when START_FIRE is not beneficial. "
|
||||
<< "With correct chance node evaluation, END_TURN has better expected value.";
|
||||
}
|
||||
|
||||
// Temporary diagnostic test to analyze the real game state
|
||||
TEST_F(ShardokMCTSAITest, DISABLED_AnalyzeRealGameState) {
|
||||
// Load the game state from the real game
|
||||
auto gameState = LoadGameStateFromFile("/tmp/shardok_state_20251119_123546_931_p1.bin");
|
||||
|
||||
printf("\n=== REAL GAME STATE ANALYSIS ===\n\n");
|
||||
|
||||
// Access the underlying flatbuffer
|
||||
const auto* gs = gameState.Get();
|
||||
|
||||
// Analyze game status
|
||||
printf("Game Status:\n");
|
||||
printf(" Current Player: %d\n", gs->current_player());
|
||||
printf(" Current Round: %d\n", gs->current_round());
|
||||
printf("\n");
|
||||
|
||||
// Analyze map
|
||||
const auto* hexMap = gs->hex_map();
|
||||
printf("Map:\n");
|
||||
printf(" Rows: %d, Columns: %d\n", hexMap->row_count(), hexMap->column_count());
|
||||
printf("\n");
|
||||
|
||||
// Analyze units
|
||||
const auto* units = gs->units();
|
||||
printf("Units (%u total):\n", units->size());
|
||||
int normalUnitCount = 0;
|
||||
for (unsigned int i = 0; i < units->size(); ++i) {
|
||||
const auto* unit = units->Get(i);
|
||||
if (unit->status() != net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) {
|
||||
continue; // Skip reserved slots
|
||||
}
|
||||
normalUnitCount++;
|
||||
printf(" [%u] Player %d, Unit %d @(%d,%d)\n",
|
||||
i,
|
||||
unit->player_id(),
|
||||
unit->unit_id(),
|
||||
unit->location().row(),
|
||||
unit->location().column());
|
||||
printf(" Battalion: size=%d, type=%d\n",
|
||||
unit->battalion().size(),
|
||||
unit->battalion().type());
|
||||
printf(" Food: %.2f\n", unit->food_remaining());
|
||||
printf(" Can start fire: %s\n", unit->can_start_fire() ? "YES" : "NO");
|
||||
|
||||
// Show hero stats
|
||||
const auto& hero = unit->attached_hero();
|
||||
printf(" Hero stats: str=%d agi=%d wis=%d cha=%d con=%d\n",
|
||||
hero.strength(),
|
||||
hero.agility(),
|
||||
hero.wisdom(),
|
||||
hero.charisma(),
|
||||
hero.constitution());
|
||||
}
|
||||
printf("Total normal units: %d\n", normalUnitCount);
|
||||
printf("\n");
|
||||
|
||||
// Analyze weather
|
||||
printf("Weather:\n");
|
||||
printf(" Conditions: %d\n", gs->weather()->conditions());
|
||||
printf(" Wind direction: %d\n", gs->weather()->wind().direction());
|
||||
printf("\n");
|
||||
|
||||
printf("=== END ANALYSIS ===\n\n");
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.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/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/util/HexMapUtils.hpp"
|
||||
#include "src/test/cpp/net/eagle0/shardok/library/AIPerformanceTestHelpers.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"
|
||||
@@ -371,6 +373,168 @@ TEST_F(MCTSOptimizedAIScoreCalculatorTest, MoreDefenderUnits_AlwaysHelpsDefender
|
||||
<< ", 6=" << score6;
|
||||
}
|
||||
|
||||
// Test that placing fire adjacent to defender should decrease defender's score
|
||||
// even when attackers are far away. This tests the scorer's fire hazard evaluation.
|
||||
TEST_F(MCTSOptimizedAIScoreCalculatorTest, FireAdjacentToDefender_DecreasesDefenderScore) {
|
||||
// Set up filesystem for map loading
|
||||
auto path = std::filesystem::current_path();
|
||||
path.append(
|
||||
"bazel-bin/src/test/cpp/net/eagle0/shardok/ai/score/"
|
||||
"mcts_optimized_ai_score_calculator_test");
|
||||
FilesystemUtils::SetExecPath(path);
|
||||
|
||||
// Create game state on Alah map with placed units
|
||||
auto gameState = CreatePerfTestGameState(settings, false); // attacker is player 0
|
||||
ShardokEngine engine(settings, gameState);
|
||||
|
||||
const PlayerId attackerPlayerId = 0;
|
||||
const PlayerId defenderPlayerId = 1;
|
||||
|
||||
// Place attacker units at attacker starting positions (top of map, far from defenders)
|
||||
// Alah map: attacker positions are near rows 0-4
|
||||
{
|
||||
// Place 3 attacker units
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
auto commands = engine.GetAvailableCommandsForAIPlayer(attackerPlayerId);
|
||||
for (size_t j = 0; j < commands->size(); ++j) {
|
||||
if ((*commands)[j]->GetCommandType() ==
|
||||
net::eagle0::shardok::common::CommandType::PLACE_UNIT_COMMAND) {
|
||||
engine.PostCommand(attackerPlayerId, j);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attacker ends setup
|
||||
{
|
||||
auto commands = engine.GetAvailableCommandsForAIPlayer(attackerPlayerId);
|
||||
for (size_t i = 0; i < commands->size(); ++i) {
|
||||
if ((*commands)[i]->GetCommandType() ==
|
||||
net::eagle0::shardok::common::CommandType::END_PLAYER_SETUP_COMMAND) {
|
||||
engine.PostCommand(attackerPlayerId, i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Place 3 defender units at castle positions (near row 9-11)
|
||||
{
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
auto commands = engine.GetAvailableCommandsForAIPlayer(defenderPlayerId);
|
||||
for (size_t j = 0; j < commands->size(); ++j) {
|
||||
if ((*commands)[j]->GetCommandType() ==
|
||||
net::eagle0::shardok::common::CommandType::PLACE_UNIT_COMMAND) {
|
||||
engine.PostCommand(defenderPlayerId, j);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Defender ends setup to start the battle
|
||||
{
|
||||
auto commands = engine.GetAvailableCommandsForAIPlayer(defenderPlayerId);
|
||||
for (size_t i = 0; i < commands->size(); ++i) {
|
||||
if ((*commands)[i]->GetCommandType() ==
|
||||
net::eagle0::shardok::common::CommandType::END_PLAYER_SETUP_COMMAND) {
|
||||
engine.PostCommand(defenderPlayerId, i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set up scorer with Alah map
|
||||
const auto* hexMap = engine.GetCurrentGameState()->hex_map();
|
||||
alCache = std::make_unique<AttackLocationsCache>(hexMap, GetGameSettingsGetter());
|
||||
scorer = MakeMCTSOptimizedAIScoreCalculator(GetGameSettingsGetter(), apdCache, alCache);
|
||||
|
||||
AIStrategy defenderStrategy{};
|
||||
defenderStrategy.strategyType = AIStrategy::STRATEGY_HOLD_CASTLES;
|
||||
|
||||
CoordsSet castleCoords(hexMap);
|
||||
|
||||
// Score WITHOUT fire
|
||||
const auto scoreWithoutFire = scorer->GuessedStateScore(
|
||||
true, // isDefender
|
||||
engine.GetCurrentGameState(),
|
||||
defenderStrategy,
|
||||
castleCoords);
|
||||
|
||||
// Find a defender unit and get its position
|
||||
const auto* units = engine.GetCurrentGameState()->units();
|
||||
Coords defenderLocation{-1, -1};
|
||||
for (unsigned int i = 0; i < units->size(); ++i) {
|
||||
const auto* unit = units->Get(i);
|
||||
if (unit->player_id() == defenderPlayerId && unit->location().row() >= 0) {
|
||||
defenderLocation = unit->location();
|
||||
break;
|
||||
}
|
||||
}
|
||||
ASSERT_GE(defenderLocation.row(), 0) << "Should find a placed defender unit";
|
||||
|
||||
// Get adjacent hex (between defenders and attackers = toward lower row numbers)
|
||||
// Alah map: attackers are at low rows, defenders at high rows
|
||||
const int8_t fireRow = static_cast<int8_t>(defenderLocation.row() - 1);
|
||||
const int8_t fireCol = defenderLocation.column();
|
||||
Coords fireLocation{fireRow, fireCol};
|
||||
|
||||
// Set fire on the terrain directly using FlatBuffer mutable access
|
||||
auto* mutableTerrain = GetMutableTerrain(hexMap, fireLocation);
|
||||
ASSERT_NE(mutableTerrain, nullptr) << "Fire location should be valid terrain";
|
||||
|
||||
// Set fire by calling mutable_fire() and then mutate_present(true)
|
||||
mutableTerrain->mutable_modifier().mutable_fire().mutate_present(true);
|
||||
|
||||
// Score WITH fire (using same game state that now has fire)
|
||||
const auto scoreWithFire = scorer->GuessedStateScore(
|
||||
true, // isDefender
|
||||
engine.GetCurrentGameState(),
|
||||
defenderStrategy,
|
||||
castleCoords);
|
||||
|
||||
// Get the context-free unit value for the defender at (9, 10)
|
||||
const Unit* defenderUnit = nullptr;
|
||||
for (unsigned int i = 0; i < units->size(); ++i) {
|
||||
const auto* unit = units->Get(i);
|
||||
if (unit->player_id() == defenderPlayerId &&
|
||||
unit->location().row() == defenderLocation.row() &&
|
||||
unit->location().column() == defenderLocation.column()) {
|
||||
defenderUnit = unit;
|
||||
break;
|
||||
}
|
||||
}
|
||||
ASSERT_NE(defenderUnit, nullptr) << "Should find defender unit";
|
||||
|
||||
const auto contextFreeValue = ContextFreeUnitValue(defenderUnit);
|
||||
printf("DEBUG: Context-free unit value for defender at (%d, %d): %.2f\n",
|
||||
defenderLocation.row(),
|
||||
defenderLocation.column(),
|
||||
contextFreeValue);
|
||||
|
||||
printf("DEBUG: Defender score without fire: %.2f\n", scoreWithoutFire);
|
||||
printf("DEBUG: Defender score with fire adjacent: %.2f\n", scoreWithFire);
|
||||
printf("DEBUG: Fire location: (%d, %d), Defender location: (%d, %d)\n",
|
||||
fireLocation.row(),
|
||||
fireLocation.column(),
|
||||
defenderLocation.row(),
|
||||
defenderLocation.column());
|
||||
|
||||
// Check if fire was actually set
|
||||
const auto* fireTerrain = GetTerrain(hexMap, fireLocation);
|
||||
printf("DEBUG: Fire present at (%d, %d): %s\n",
|
||||
fireLocation.row(),
|
||||
fireLocation.column(),
|
||||
fireTerrain->modifier().fire().present() ? "YES" : "NO");
|
||||
|
||||
// CRITICAL TEST: Fire adjacent to defender should DECREASE defender's score
|
||||
// Even though fire might slightly reduce attacker scores due to distance debuffs,
|
||||
// the hazard to the nearby defender should outweigh any benefit.
|
||||
EXPECT_LT(scoreWithFire, scoreWithoutFire)
|
||||
<< "Fire adjacent to defender should DECREASE defender's score! "
|
||||
<< "Without fire: " << scoreWithoutFire << ", With fire: " << scoreWithFire;
|
||||
}
|
||||
|
||||
// Test setup phase scoring with Alah map - reproducing the MCTS bug scenario
|
||||
// DISABLED: This test hits a separate bug in CoordsSet ("mismatched sizes" exception)
|
||||
// after placing 4+ units. The test was useful during investigation to verify scores
|
||||
|
||||
@@ -469,3 +469,7 @@ auto postEndTurnCommand(
|
||||
AvailableCommandsOfType(game, playerId, END_TURN_COMMAND)[0],
|
||||
std::move(generator));
|
||||
}
|
||||
|
||||
auto LoadGameStateFromFile(const std::string &path) -> GameStateW {
|
||||
return GameStateW::LoadFrom(path);
|
||||
}
|
||||
|
||||
@@ -125,4 +125,7 @@ auto postEndTurnCommand(
|
||||
PlayerId playerId,
|
||||
shared_ptr<RandomGenerator> generator) -> vector<ActionResultView>;
|
||||
|
||||
// Load a game state from a binary file dumped by ShardokAIClient
|
||||
auto LoadGameStateFromFile(const std::string &path) -> GameStateW;
|
||||
|
||||
#endif // EAGLE0_SHARDOKENGINEBASEDTESTDATA_HPP
|
||||
|
||||
Reference in New Issue
Block a user