Compare commits

...
Author SHA1 Message Date
adminandClaude fbf730e662 Fix fire penalty to apply to all units, not just attackers
The fire multiplier in AIUnitScoreCalculator was only being applied when
`isAttacker || attackerWantsCastles`, which meant defenders weren't
penalized for standing on or adjacent to fire. This caused incorrect AI
scoring where starting a fire adjacent to an enemy defender wouldn't
properly decrease their score.

Fixed by removing the `isAttacker` condition from the on-fire penalty
check, so fire hazards are correctly evaluated for all units.

Also added a test that verifies fire adjacent to a defender decreases
the defender's score, and fixed the test to properly set fire on the
FlatBuffer using mutate_present(true).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 09:58:06 -08:00
adminandClaude 78c918412d Add failing test for fire adjacent to defender scoring bug
Test that placing a fire adjacent to a defender should DECREASE the
defender's score, even when attackers are far away.

The test currently fails, demonstrating that the MCTS optimized scorer
doesn't account for fire hazards near units. Both with and without fire
produce the exact same score (1.23), when the fire should reduce the
defender's score due to the danger of fire damage.

This test uses the Alah map with:
- 3 attacker units placed at attacker starting positions (far from defenders)
- 3 defender units placed at castle positions
- Fire placed at (8, 10), adjacent to defender at (9, 10)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 09:33:27 -08:00
adminandClaude dd9b419cce Fix chance node immediate score to use expected value of outcomes
The chance node's immediateScore was incorrectly set to the parent state
evaluation instead of the expected value of outcomes. This caused exploration
imbalance because chance nodes started with inflated scores compared to
non-chance actions like END_TURN.

After expanding each outcome child, the chance node's immediateScore is now
updated to the expected value of all expanded outcomes. This ensures fair
UCB comparison between chance and non-chance actions.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 08:56:12 -08:00
adminandClaude 7bb5b4109a Consolidate chance node output in MCTS sequence display
When displaying the best sequence, chance nodes now show actual outcome
probabilities and scores using the node's outcomeProbabilities data.
Format: "action [prob%->score, prob%->score]"

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 08:16:42 -08:00
admin 4af4cd5765 passing START_FIRE test 2025-11-20 08:16:42 -08:00
admin 4694755b71 failing START_FIRE test 2025-11-20 08:16:24 -08:00
adminandClaude 303e4d7815 Restore Unicode box-drawing characters in tree dump
Previously removed them due to compilation errors when comparing with
char literals. Now properly handle UTF-8 multi-byte sequences to
replace ├ and └ with │ for the outcome info line while preserving
all other box-drawing characters.

Result: Tree structure is preserved and readable with nice formatting.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 08:16:05 -08:00
adminandClaude 26d00df97e Improve MCTS tree dump to display chance nodes
- Add [CHANCE] prefix to chance node descriptions
- Display outcome probabilities and representative rolls
- Initialize chance node immediate scores to parent state score
- Fix Unicode character handling in tree dump formatting

Example output:
  [CHANCE] START_FIRE_COMMAND Unit:5 @(11,12) (visits:14203...)
    Outcomes: [0] p=0.510 roll=74.5, [1] p=0.490 roll=24.5

This makes it easy to inspect the chance node structure and verify
that outcomes are being explored with correct probabilities/rolls.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 08:16:05 -08:00
adminandClaude 6ec0cd8932 Phase 1: Apply chance node outcomes with representative rolls
This completes the final critical piece of Phase 1 - actually applying
binary action outcomes with their specific deterministic rolls.

Previously, both success and failure outcomes were applied with the
default roll, causing them to see the same result and defeating the
entire purpose of chance nodes.

Changes:
- Add deterministicRoll parameter to MCTSGameEngine::applyAction()
- Update ShardokGameEngine to create SequenceRandomGenerator with
  specified roll and pass it to PostCommand
- Update AbstractMCTSAI expansion to pass outcomeRolls when expanding
  chance node outcomes
- Update TicTacToeEngine test mock to match new interface

For a 51% success action like START_FIRE:
- Success outcome (index 0): applied with roll ~74.5 → succeeds
- Failure outcome (index 1): applied with roll ~24.5 → fails

This allows MCTS to correctly explore both outcomes and make better
decisions about probabilistic actions.

Tests: All MCTS tests pass (abstract_mcts_ai_test, ai_mcts_test,
shardok_mcts_ai_basic_test, mcts_setup_phase_reserve_test)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 08:16:05 -08:00
adminandClaude 0c55cfa2c5 Phase 1: Complete selection and backpropagation for chance nodes
This commit completes the core MCTS chance node implementation for binary
actions (START_FIRE, RAISE_DEAD, EXTINGUISH_FIRE). With these changes, MCTS
now properly models probabilistic outcomes instead of using a fixed 50% roll.

Changes:
- MCTSSelection: Updated to use GetBestChanceChild() for chance nodes instead
  of UCB1, implementing probability-weighted outcome selection
- MCTSBackpropagation: Added expected value calculation for chance nodes
  (weighted average: sum(probability[i] * childValue[i]))
- All existing tests pass (abstract_mcts_ai_test, ai_mcts_test,
  mcts_setup_phase_reserve_test, shardok_mcts_ai_basic_test)

How it works:
1. When expanding START_FIRE action, MCTS creates intermediate chance node
2. Chance node expands into 2 outcome children (success/failure)
3. Selection: chance nodes use probability-weighted selection
4. Backpropagation: chance nodes compute expected value from outcomes
5. Final result: proper modeling of binary success/failure probabilities

Remaining work:
- Apply actions with representative rolls for each outcome (currently uses
  default roll which defeats the purpose of chance nodes)
- Add specific unit tests for chance node behavior
- Test on START_FIRE scenario to verify fix

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 08:16:05 -08:00
adminandClaude 585f6f1bc7 Phase 1: Add MCTS chance node infrastructure for binary actions
This commit implements the foundational infrastructure for chance nodes in MCTS
to properly model probabilistic actions like START_FIRE, RAISE_DEAD, and
EXTINGUISH_FIRE. These actions have binary success/failure outcomes that were
previously modeled with a fixed 50% roll, causing the AI to overvalue them.

Changes:
- MCTSNode: Add NodeType enum (DECISION/CHANCE), outcome metadata (probabilities,
  representative rolls), and helper methods (IsChanceNode, GetBestChanceChild)
- MCTSAction: Add requiresChanceNode() virtual method to identify binary actions
- ShardokAction: Implement requiresChanceNode() for START_FIRE, EXTINGUISH_FIRE,
  RAISE_DEAD commands
- MCTSGameEngine: Add BinaryOutcomeInfo struct and getBinaryOutcomeInfo() method
- ShardokGameEngine: Implement getBinaryOutcomeInfo() using command descriptors
- AbstractMCTSAI::MCTSExpansion(): Modified to create chance nodes when expanding
  binary actions, then expand chance nodes into outcome children
- MockTicTacToe: Updated test mocks to implement new virtual methods

Known limitation:
- Chance node outcomes currently apply actions with default roll (TODO: use
  representative rolls for each outcome)

Next steps:
- Update selection logic to handle chance nodes
- Update backpropagation to handle chance nodes
- Apply actions with specific rolls for each outcome
- Add unit tests

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 08:16:04 -08:00
16 changed files with 921 additions and 24 deletions
@@ -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