Compare commits

...
Author SHA1 Message Date
admin e49af0868f bad 2025-09-28 08:29:45 -07:00
admin 08bbbdd651 let's actually look at the code 2025-09-28 08:28:49 -07:00
admin 16eb808a73 what a mess 2025-09-28 08:28:49 -07:00
admin d2a1cc77e9 slow progress 2025-09-28 08:28:49 -07:00
admin 4a974b265a don't stop simulation on a player flip 2025-09-28 08:28:49 -07:00
admin f181c9241d set the current playerId correctly 2025-09-28 08:28:49 -07:00
admin 6cad64d348 investigate playerflips issue 2025-09-28 08:28:48 -07:00
admin 83780ca7bb hide info 2025-09-28 08:28:48 -07:00
admin 1a9b307922 logging fixes and use correct settings 2025-09-28 08:28:48 -07:00
admin c887b868fd player flips 2025-09-28 08:28:48 -07:00
admin 05bacede4f maybe kinda working 2025-09-28 08:28:48 -07:00
admin d3a1088b4c END_TURN not marked as terminal 2025-09-28 08:28:47 -07:00
admin 0a33ad4433 still a little drunk but END_TURN is scoring correctly 2025-09-28 08:28:47 -07:00
admin 3f6a8340d3 MCTSAI as a separate target 2025-09-28 08:27:49 -07:00
admin 811fcec712 MCTS integration complete 2025-09-28 08:25:46 -07:00
admin 3c2f5eeab1 store the decision tree 2025-09-28 08:25:45 -07:00
12 changed files with 3340 additions and 7 deletions
+177
View File
@@ -0,0 +1,177 @@
# MCTS with Player Flips - Clean Design
## Core Principles
1. **Single Perspective**: All scoring is from our AI's perspective (positive = good for us, negative = bad for us)
2. **Player Flips**: Continue expansion/simulation until N player turn changes occur
3. **Minimax Integration**: Our turns maximize our score, opponent turns minimize our score
4. **Simplicity**: No special casing - just track when currentPlayer changes
## Node Structure
```cpp
struct MCTSNode {
// Command that led to this state
size_t commandIndex;
CommandType commandType;
// Game state after executing the command
GameStateW resultingGameState;
PlayerId currentPlayer; // Whose turn it is in this state
// Tree position
int playerFlipsFromRoot; // Number of player changes from root
bool isOurTurn; // currentPlayer == our AI's playerId
// MCTS statistics (always from our perspective)
int visitCount = 0;
double totalScore = 0.0;
double averageScore = 0.0;
// Tree structure
std::vector<std::unique_ptr<MCTSNode>> children;
std::vector<size_t> untriedCommands;
};
```
## Expansion Algorithm
```cpp
MCTSNode* Expand(MCTSNode* node) {
// Check if we've reached max player flips
if (node->playerFlipsFromRoot >= maxPlayerFlips) {
return node; // Don't expand further
}
// Pick untried command
size_t cmdIndex = PickUntriedCommand(node);
// Execute command to create child state
auto childEngine = std::make_shared<ShardokEngine>(parentEngine);
PlayerId playerBefore = childEngine->GetCurrentPlayerId();
childEngine->PostCommand(playerBefore, cmdIndex, averageGenerator);
PlayerId playerAfter = childEngine->GetCurrentPlayerId();
// Create child node
auto child = std::make_unique<MCTSNode>();
child->commandIndex = cmdIndex;
child->resultingGameState = childEngine->GetCurrentGameState();
child->currentPlayer = playerAfter;
child->isOurTurn = (playerAfter == ourPlayerId);
// Track player flips
child->playerFlipsFromRoot = node->playerFlipsFromRoot;
if (playerBefore != playerAfter) {
child->playerFlipsFromRoot++;
}
// Score from our perspective
child->immediateScore = ScoreFromOurPerspective(child->resultingGameState);
return child;
}
```
## Simulation Algorithm
```cpp
double Simulate(const GameStateW& startState, PlayerId startPlayer, int startFlips) {
auto simEngine = CreateEngine(startState);
int currentFlips = startFlips;
while (currentFlips < maxPlayerFlips) {
PlayerId currentPlayer = simEngine->GetCurrentPlayerId();
bool isOurTurn = (currentPlayer == ourPlayerId);
// Get available commands
auto commands = simEngine->GetAvailableCommandsForAIPlayer(currentPlayer);
if (commands->empty()) break;
// Pick best command based on whose turn it is
size_t bestCmd = 0;
double bestScore = isOurTurn ? -infinity : +infinity;
for (size_t i = 0; i < commands->size(); ++i) {
auto testEngine = CreateEngine(simEngine);
testEngine->PostCommand(currentPlayer, i, averageGenerator);
// Always score from our perspective
double score = ScoreFromOurPerspective(testEngine->GetCurrentGameState());
// Our turn: maximize our score, Opponent turn: minimize our score
bool shouldSelect = isOurTurn ? (score > bestScore) : (score < bestScore);
if (shouldSelect) {
bestScore = score;
bestCmd = i;
}
}
// Execute chosen command
PlayerId playerBefore = simEngine->GetCurrentPlayerId();
simEngine->PostCommand(currentPlayer, bestCmd, averageGenerator);
PlayerId playerAfter = simEngine->GetCurrentPlayerId();
// Track player flips
if (playerBefore != playerAfter) {
currentFlips++;
}
}
return ScoreFromOurPerspective(simEngine->GetCurrentGameState());
}
```
## Selection Algorithm
```cpp
MCTSNode* SelectChild(MCTSNode* node) {
MCTSNode* bestChild = nullptr;
double bestUCB1 = node->isOurTurn ? -infinity : +infinity;
for (auto& child : node->children) {
double ucb1 = child->averageScore + explorationTerm;
// Our turn: pick highest UCB1, Opponent turn: pick lowest UCB1
bool shouldSelect = node->isOurTurn ? (ucb1 > bestUCB1) : (ucb1 < bestUCB1);
if (shouldSelect) {
bestUCB1 = ucb1;
bestChild = child.get();
}
}
return bestChild;
}
```
## Backpropagation Algorithm
```cpp
void Backpropagate(MCTSNode* node, double score) {
while (node != nullptr) {
node->visitCount++;
node->totalScore += score; // Always from our perspective
node->averageScore = node->totalScore / node->visitCount;
node = node->parent;
}
}
```
## Key Simplifications
1. **No END_TURN special cases** - just check if currentPlayer changed after any command
2. **Consistent scoring** - always from our AI's perspective throughout
3. **Clear minimax** - our nodes maximize, opponent nodes minimize
4. **Simple state tracking** - just count player flips, no complex inheritance
5. **Unified command handling** - all commands handled the same way
## Implementation Plan
1. **Refactor MCTSNode structure** - simplify to core fields needed
2. **Rewrite MCTSExpansion** - remove END_TURN special cases, just track player changes
3. **Fix MCTSSimulation** - ensure consistent perspective and proper minimax
4. **Simplify MCTSSelection** - clean minimax logic
5. **Clean up MCTSBackpropagation** - single perspective throughout
6. **Add comprehensive testing** - verify player flips are tracked correctly
This design eliminates the confusion around perspectives and special cases, making the algorithm much easier to understand and debug.
@@ -10,7 +10,8 @@ namespace shardok {
// Enum for AI algorithm selection
enum class AIAlgorithmType {
ITERATIVE_DEEPENING, // Default: Minimax with sophisticated randomness
MCTS // Monte Carlo Tree Search with multithreading
MCTS, // Monte Carlo Tree Search with multithreading (original)
MCTS_CLEAN // Clean MCTS implementation with fixed simulation perspective
};
} // namespace shardok
File diff suppressed because it is too large Load Diff
@@ -619,7 +619,6 @@ auto result = mctsAI.Search(settings, state, commands, budget);
```
#### Algorithm Comparison
| Feature | Iterative Deepening | MCTS |
|---------|-------------------|------|
| **Randomness Handling** | Sophisticated (chance nodes, multi-sample) | Simplified (average rolls) |
@@ -636,6 +635,7 @@ The MCTS implementation provides a solid foundation. Known limitations:
Note: The APD cache is fully thread-safe using thread-local storage and mutex-protected shared cache.
<<<<<<< HEAD
Adding chance node handling and ensuring thread safety would make it a superior replacement for the iterative deepening approach while maintaining the sophisticated randomness evaluation that makes the current system effective.
## MCTS Configuration Options
@@ -762,4 +762,4 @@ MCTSAI ai(playerId, isDefender, strategy, castleCoords, apdCache, alCache, confi
- Higher `immediateScoreTieBreakThreshold` to emphasize direct paths
- `BEST_IMMEDIATE` simulation for most predictable behavior
The configuration system allows fine-tuning MCTS behavior for different scenarios while maintaining compatibility with the existing AI infrastructure.
The configuration system allows fine-tuning MCTS behavior for different scenarios while maintaining compatibility with the existing AI infrastructure.
@@ -345,6 +345,43 @@ cc_library(
],
)
cc_library(
name = "ai_mcts_clean",
srcs = ["MCTSCleanAI.cpp"],
hdrs = ["MCTSCleanAI.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
deps = [
":ai_attack_locations",
":ai_config",
":ai_iterative_deepening", # For SearchResult compatibility
":ai_score_calculator",
":ai_strategy",
":ai_time_budget",
"//src/main/cpp/net/eagle0/common:random_generator",
"//src/main/cpp/net/eagle0/common:sequence_random_generator",
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
],
)
cc_library(
name = "ai_config",
hdrs = ["AIConfig.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__pkg__",
],
)
cc_library(
name = "shardok_ai_client",
srcs = ["ShardokAIClient.cpp"],
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
//
// MCTS-based AI system for Shardok
// Alternative to IterativeDeepeningAI using Monte Carlo Tree Search
//
#ifndef EAGLE0_MCTSAI_HPP
#define EAGLE0_MCTSAI_HPP
#include <chrono>
#include <future>
#include <memory>
#include <vector>
#include "AIStrategy.hpp"
#include "AITimeBudget.hpp"
#include "IterativeDeepeningAI.hpp" // For SearchResult compatibility
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
namespace shardok {
// Forward declarations
class ShardokEngine;
struct MCTSNode;
// Configuration for MCTS algorithm
struct MCTSConfig {
double explorationConstant = 1.414; // UCB1 constant (sqrt(2) by default)
int maxPlayerFlips = 1; // Number of player turn changes to evaluate
bool useMultithreading = true; // Enable parallel MCTS
int numThreads = 16; // Number of threads for parallel MCTS (when enabled)
};
class MCTSAI {
public:
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
using SearchResult = IterativeDeepeningAI::SearchResult;
MCTSAI(PlayerId playerId,
bool isDefender,
AIStrategy strategy,
const CoordsSet& castleCoords,
const APDCache& apdCache,
const ALCache& alCache,
MCTSConfig config = MCTSConfig{});
// Main search interface - compatible with IterativeDeepeningAI
[[nodiscard]] auto Search(
const GameSettingsSPtr& settings,
const GameStateW& state,
const std::vector<CommandProto>& commands,
const AITimeBudget& budget) const -> SearchResult;
// Get/set configuration
[[nodiscard]] auto GetConfig() const -> const MCTSConfig& { return config; }
void SetConfig(const MCTSConfig& newConfig) { config = newConfig; }
private:
PlayerId playerId;
bool isDefender;
AIStrategy strategy;
const CoordsSet& castleCoords;
const APDCache& apdCache;
const ALCache& alCache;
MCTSConfig config;
// Internal MCTS tree building
[[nodiscard]] auto BuildMCTSTree(
const ShardokEngine& engine,
const SettingsGetter& settingsGetter,
std::chrono::steady_clock::time_point deadline) const -> std::unique_ptr<MCTSNode>;
// MCTS algorithm phases
auto MCTSSelection(MCTSNode* root) const -> MCTSNode*;
auto MCTSExpansion(
MCTSNode* node,
const ShardokEngine& engine,
const SettingsGetter& settingsGetter) const -> MCTSNode*;
auto MCTSSimulation(
const ShardokEngine& engineState,
PlayerId currentPlayer,
const SettingsGetter& settingsGetter) const -> double;
auto MCTSBackpropagation(MCTSNode* node, double reward) const -> void;
// Helper functions
[[nodiscard]] auto IsTerminalForPlayer(
const GameStateW& gameState,
PlayerId currentPlayer,
const SettingsGetter& settingsGetter) const -> bool;
// Get coordinate information for logging
[[nodiscard]] std::string GetCommandCoordinateInfo(
net::eagle0::shardok::common::CommandType commandType,
size_t commandIndex,
const GameStateW& gameState,
const GameSettingsSPtr& settings) const;
};
} // namespace shardok
#endif // EAGLE0_MCTSAI_HPP
@@ -0,0 +1,292 @@
#include "MCTSCleanAI.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <limits>
#include "AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/common/SequenceRandomGenerator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
namespace shardok {
MCTSCleanAI::MCTSCleanAI(
PlayerId playerId,
bool isDefender,
const AIStrategy& strategy,
const CoordsSet& castleCoords,
const APDCache& apdCache,
const ALCache& alCache)
: ourPlayerId(playerId),
isDefender(isDefender),
strategy(strategy),
castleCoords(castleCoords),
apdCache(apdCache),
alCache(alCache),
rng(std::chrono::steady_clock::now().time_since_epoch().count()) {}
IterativeDeepeningAI::SearchResult MCTSCleanAI::Search(
const GameSettingsSPtr& settings,
const GameStateW& gameState,
const std::vector<CommandProto>& availableCommands,
const AITimeBudget& timeBudget) {
const auto startTime = std::chrono::steady_clock::now();
const auto maxTime = std::chrono::duration_cast<std::chrono::milliseconds>(
timeBudget.remainingBudget * 0.9); // 90% of budget
// Create root node
auto root = std::make_unique<MCTSNode>();
root->resultingGameState = gameState;
root->currentPlayer = ourPlayerId;
root->isOurTurn = true;
root->playerFlipsFromRoot = 0;
// Initialize untried commands for root
for (size_t i = 0; i < availableCommands.size(); ++i) { root->untriedCommands.push_back(i); }
int iterationCount = 0;
// Main MCTS loop
while (std::chrono::steady_clock::now() - startTime < maxTime) {
// 1. Selection - find leaf node to expand
MCTSNode* selected = Selection(root.get());
// 2. Expansion - add new child if possible
MCTSNode* expanded = Expansion(selected, settings, availableCommands);
// 3. Simulation - run random playout
double reward = Simulation(
expanded->resultingGameState,
expanded->currentPlayer,
expanded->playerFlipsFromRoot,
settings);
// 4. Backpropagation - update statistics
Backpropagation(expanded, reward);
iterationCount++;
// Early exit if all commands tried
if (root->untriedCommands.empty() && root->children.size() == availableCommands.size()) {
bool allChildrenFullyExplored = true;
for (const auto& child : root->children) {
if (child->visitCount < 10) { // Minimum visits per child
allChildrenFullyExplored = false;
break;
}
}
if (allChildrenFullyExplored) { break; }
}
}
// Select best command based on visit counts
size_t bestCommandIndex = 0;
int maxVisits = 0;
for (size_t i = 0; i < root->children.size(); ++i) {
if (root->children[i]->visitCount > maxVisits) {
maxVisits = root->children[i]->visitCount;
bestCommandIndex = root->children[i]->commandIndex;
}
}
// Create search result
IterativeDeepeningAI::SearchResult result;
result.bestCommandIndex = bestCommandIndex;
result.availableCommandCount = availableCommands.size();
result.depthAchieved = maxPlayerFlips; // Our max search depth
result.commandCountEvaluated = iterationCount;
result.completionReason = EvaluationCompletionReason::RAN_OUT_OF_TIME;
printf("MCTS Clean: %d iterations, best command %zu with %d visits\n",
iterationCount,
bestCommandIndex,
maxVisits);
return result;
}
// Selection - navigate to leaf using UCB1
MCTSCleanAI::MCTSNode* MCTSCleanAI::Selection(MCTSNode* root) {
MCTSNode* current = root;
while (!current->children.empty() && current->untriedCommands.empty()) {
current = SelectChild(current);
}
return current;
}
// Expansion - add new child node
MCTSCleanAI::MCTSNode* MCTSCleanAI::Expansion(
MCTSNode* node,
const GameSettingsSPtr& settings,
const std::vector<CommandProto>& availableCommands) {
// If we've reached max player flips, don't expand
if (node->playerFlipsFromRoot >= maxPlayerFlips) { return node; }
// If no untried commands, return current node
if (node->untriedCommands.empty()) { return node; }
// Pick an untried command
size_t cmdIndex = PickUntriedCommand(node);
// Create child engine and execute command
auto childEngine = CreateEngine(node->resultingGameState, settings);
PlayerId playerBefore = childEngine->GetCurrentPlayerId();
// Use sequence random generator for deterministic results
auto averageGenerator = std::make_shared<SequenceRandomGenerator>(std::vector<double>{0.5});
childEngine->PostCommand(playerBefore, cmdIndex, averageGenerator);
PlayerId playerAfter = childEngine->GetCurrentPlayerId();
// Create child node
bool isOurTurnAfter = (playerAfter == ourPlayerId);
auto child = std::make_unique<MCTSNode>(
cmdIndex,
availableCommands[cmdIndex].type(),
playerAfter,
isOurTurnAfter);
child->resultingGameState = childEngine->GetCurrentGameState();
child->parent = node;
// Track player flips
child->playerFlipsFromRoot = node->playerFlipsFromRoot;
if (playerBefore != playerAfter) { child->playerFlipsFromRoot++; }
// Initialize child's untried commands if we haven't hit max flips
if (child->playerFlipsFromRoot < maxPlayerFlips) {
auto childCommands = childEngine->GetAvailableCommandsForAIPlayer(playerAfter);
for (size_t i = 0; i < childCommands->size(); ++i) { child->untriedCommands.push_back(i); }
}
MCTSNode* childPtr = child.get();
node->children.push_back(std::move(child));
return childPtr;
}
// Simulation - run random playout from current state
double MCTSCleanAI::Simulation(
const GameStateW& startState,
PlayerId /* startPlayer */,
int startFlips,
const GameSettingsSPtr& settings) {
auto simEngine = CreateEngine(startState, settings);
int currentFlips = startFlips;
auto averageGenerator = std::make_shared<SequenceRandomGenerator>(std::vector<double>{0.5});
while (currentFlips < maxPlayerFlips) {
PlayerId currentPlayer = simEngine->GetCurrentPlayerId();
bool isOurTurn = (currentPlayer == ourPlayerId);
// Get available commands
auto commands = simEngine->GetAvailableCommandsForAIPlayer(currentPlayer);
if (commands->empty()) break;
// Pick best command based on whose turn it is
size_t bestCmd = 0;
double bestScore = isOurTurn ? -std::numeric_limits<double>::infinity()
: std::numeric_limits<double>::infinity();
for (size_t i = 0; i < commands->size(); ++i) {
auto testEngine = CreateEngine(simEngine->GetCurrentGameState(), settings);
testEngine->PostCommand(currentPlayer, i, averageGenerator);
// Always score from our perspective
double score = ScoreFromOurPerspective(
testEngine->GetCurrentGameState(),
settings->GetGetter());
// Our turn: maximize our score, Opponent turn: minimize our score
bool shouldSelect = isOurTurn ? (score > bestScore) : (score < bestScore);
if (shouldSelect) {
bestScore = score;
bestCmd = i;
}
}
// Execute chosen command
PlayerId playerBefore = simEngine->GetCurrentPlayerId();
simEngine->PostCommand(currentPlayer, bestCmd, averageGenerator);
PlayerId playerAfter = simEngine->GetCurrentPlayerId();
// Track player flips
if (playerBefore != playerAfter) { currentFlips++; }
}
return ScoreFromOurPerspective(simEngine->GetCurrentGameState(), settings->GetGetter());
}
// Backpropagation - update node statistics
void MCTSCleanAI::Backpropagation(MCTSNode* node, double score) {
while (node != nullptr) {
node->visitCount++;
node->totalScore += score; // Always from our perspective
node->averageScore = node->totalScore / node->visitCount;
node = node->parent;
}
}
// Helper functions
double MCTSCleanAI::ScoreFromOurPerspective(
const GameStateW& gameState,
const SettingsGetter& settingsGetter) {
return AIScoreCalculator::GuessedStateScore(
isDefender,
gameState,
strategy,
castleCoords,
settingsGetter,
apdCache,
alCache);
}
MCTSCleanAI::MCTSNode* MCTSCleanAI::SelectChild(MCTSNode* node) {
MCTSNode* bestChild = nullptr;
double bestUCB1 = node->isOurTurn ? -std::numeric_limits<double>::infinity()
: std::numeric_limits<double>::infinity();
for (const auto& child : node->children) {
double exploitation = child->averageScore;
double exploration =
explorationConstant * std::sqrt(std::log(node->visitCount) / child->visitCount);
double ucb1 = exploitation + exploration;
// Our turn: pick highest UCB1, Opponent turn: pick lowest UCB1
bool shouldSelect = node->isOurTurn ? (ucb1 > bestUCB1) : (ucb1 < bestUCB1);
if (shouldSelect) {
bestUCB1 = ucb1;
bestChild = child.get();
}
}
return bestChild;
}
size_t MCTSCleanAI::PickUntriedCommand(MCTSNode* node) {
if (node->untriedCommands.empty()) {
return 0; // Should not happen
}
// Pick random untried command
std::uniform_int_distribution<size_t> indexDist(0, node->untriedCommands.size() - 1);
size_t randomIndex = indexDist(rng);
size_t cmdIndex = node->untriedCommands[randomIndex];
node->untriedCommands.erase(node->untriedCommands.begin() + randomIndex);
return cmdIndex;
}
std::shared_ptr<ShardokEngine> MCTSCleanAI::CreateEngine(
const GameStateW& gameState,
const GameSettingsSPtr& settings) {
return std::make_shared<ShardokEngine>(settings, gameState);
}
} // namespace shardok
@@ -0,0 +1,133 @@
#pragma once
#include <memory>
#include <random>
#include <vector>
#include "AIAttackLocations.hpp"
#include "AIConfig.hpp"
#include "AIStrategy.hpp"
#include "AITimeBudget.hpp"
#include "IterativeDeepeningAI.hpp" // For SearchResult
#include "src/main/cpp/net/eagle0/common/RandomGenerator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
namespace shardok {
// Forward declare cache type aliases
using APDCache = std::shared_ptr<ActionPointDistancesCache>;
using ALCache = std::unique_ptr<AttackLocationsCache>;
} // namespace shardok
namespace shardok {
// Clean MCTS implementation following the design document
class MCTSCleanAI {
public:
// Clean node structure with single perspective scoring
struct MCTSNode {
// Command that led to this state
size_t commandIndex;
net::eagle0::shardok::common::CommandType commandType;
// Game state after executing the command
GameStateW resultingGameState;
PlayerId currentPlayer; // Whose turn it is in this state
// Tree position
int playerFlipsFromRoot; // Number of player changes from root
bool isOurTurn; // currentPlayer == our AI's playerId
// MCTS statistics (always from our perspective)
int visitCount = 0;
double totalScore = 0.0;
double averageScore = 0.0;
// Tree structure
std::vector<std::unique_ptr<MCTSNode>> children;
std::vector<size_t> untriedCommands;
MCTSNode* parent = nullptr;
// Constructor
MCTSNode(
size_t cmdIndex,
net::eagle0::shardok::common::CommandType cmdType,
PlayerId currentPlayerId,
bool ourTurn)
: commandIndex(cmdIndex),
commandType(cmdType),
currentPlayer(currentPlayerId),
playerFlipsFromRoot(0),
isOurTurn(ourTurn) {}
// Root constructor
MCTSNode()
: commandIndex(0),
commandType(net::eagle0::shardok::common::UNKNOWN_COMMAND),
currentPlayer(0),
playerFlipsFromRoot(0),
isOurTurn(true) {}
};
MCTSCleanAI(
PlayerId playerId,
bool isDefender,
const AIStrategy& strategy,
const CoordsSet& castleCoords,
const APDCache& apdCache,
const ALCache& alCache);
// Main search function compatible with existing interface
IterativeDeepeningAI::SearchResult Search(
const GameSettingsSPtr& settings,
const GameStateW& gameState,
const std::vector<CommandProto>& availableCommands,
const AITimeBudget& timeBudget);
private:
PlayerId ourPlayerId;
bool isDefender;
AIStrategy strategy;
CoordsSet castleCoords;
const APDCache& apdCache;
const ALCache& alCache;
// MCTS parameters
static constexpr int maxPlayerFlips = 5; // Stop after N player changes
static constexpr double explorationConstant = 1.414; // sqrt(2)
// Random number generation
std::mt19937 rng;
std::uniform_real_distribution<double> uniformDist{0.0, 1.0};
// Core MCTS algorithms
MCTSNode* Selection(MCTSNode* root);
MCTSNode* Expansion(
MCTSNode* node,
const GameSettingsSPtr& settings,
const std::vector<CommandProto>& availableCommands);
double Simulation(
const GameStateW& startState,
PlayerId startPlayer,
int startFlips,
const GameSettingsSPtr& settings);
void Backpropagation(MCTSNode* node, double score);
// Helper functions
double ScoreFromOurPerspective(
const GameStateW& gameState,
const SettingsGetter& settingsGetter);
MCTSNode* SelectChild(MCTSNode* node);
size_t PickUntriedCommand(MCTSNode* node);
std::shared_ptr<ShardokEngine> CreateEngine(
const GameStateW& gameState,
const GameSettingsSPtr& settings);
};
} // namespace shardok
@@ -0,0 +1,147 @@
# MCTS Player-Flip Depth System Design
## Overview
Replace command-count-based depth limits with player-turn-aware depth that naturally aligns with game structure. All nodes evaluate to the same player-flip depth to ensure comparable scores.
## Core Concepts
### Player-Flip Depth
- **Depth 1**: Evaluate until first player flip (complete our turn)
- **Depth 2**: Continue through opponent's full turn
- **Depth 3**: Continue through our next full turn
- **Depth N**: N complete player turn changes
### Minimax Selection
- **Always evaluate from original AI's perspective**
- **Our turn**: Select moves that maximize our score
- **Opponent's turn**: Select moves that minimize our score
- Same backpropagation value regardless of whose turn
## Implementation Plan
### 1. Configuration Changes
```cpp
struct MCTSConfig {
double explorationConstant = 1.414;
int maxPlayerFlips = 2; // How many player changes to evaluate
bool useMultithreading = true;
int numThreads = 16;
// REMOVED: maxTreeDepth - all nodes go to same player-flip depth
// REMOVED: maxSimulationDepth - replaced by maxPlayerFlips
};
```
### 2. Node Structure Updates
```cpp
struct MCTSNode {
// Existing fields...
// New fields for player-flip tracking
int playerFlipsFromRoot = 0;
bool isMaximizingPlayer = true; // true = our turn, false = opponent's
// Modified selection for minimax
MCTSNode* GetBestChild(double explorationConstant) {
if (isMaximizingPlayer) {
return GetChildWithHighestUCB1(explorationConstant);
} else {
return GetChildWithLowestUCB1(explorationConstant);
}
}
};
```
### 3. Expansion Rules
- **Continue expanding** until reaching `maxPlayerFlips` player changes
- **No arbitrary depth limit** - accept theoretical stack overflow risk
- **Mark as terminal** only when:
- Game is over
- Reached `maxPlayerFlips` player changes
- No available commands
### 4. Key Implementation Details
#### Terminal Detection
```cpp
bool IsTerminalForExpansion() {
return playerFlipsFromRoot >= maxPlayerFlips ||
gameIsOver ||
noCommandsAvailable;
}
```
#### Player Tracking
```cpp
// When expanding END_TURN or END_PLAYER_SETUP
child->playerFlipsFromRoot = parent->playerFlipsFromRoot + 1;
child->isMaximizingPlayer = !parent->isMaximizingPlayer;
```
#### UCB1 for Minimax
- Maximizing player: Choose highest UCB1
- Minimizing player: Choose lowest UCB1
- Unvisited nodes: Extreme values to force exploration
### 5. Simulation Strategy
Simulations run until `maxPlayerFlips` is reached:
- Random moves for both players
- Stop at player-flip boundaries
- Always evaluate from original AI perspective
## Benefits
### Consistent Evaluation
- All leaf nodes at same player-flip depth
- Scores are directly comparable
- No apples-to-oranges comparison issues
### Natural Game Structure
- Respects turn boundaries
- Complete tactical sequences evaluated
- Opponent responses properly modeled
### Strategic Depth Control
- **Setup/Early**: `maxPlayerFlips = 1` (fast, local tactics)
- **Mid-game**: `maxPlayerFlips = 2` (balanced)
- **Critical**: `maxPlayerFlips = 3+` (deep strategy)
## Implementation Order
1. **Phase 1**: Update MCTSConfig
- Remove `maxTreeDepth` and `maxSimulationDepth`
- Add `maxPlayerFlips`
2. **Phase 2**: Modify MCTSNode
- Add `playerFlipsFromRoot` and `isMaximizingPlayer`
- Update child selection for minimax
3. **Phase 3**: Update Expansion
- Track player flips
- Remove depth-based termination
- Only terminate at player-flip boundaries
4. **Phase 4**: Fix Selection
- Implement minimax selection based on `isMaximizingPlayer`
- Modify UCB1 interpretation
5. **Phase 5**: Update Simulation
- Run until `maxPlayerFlips` reached
- Handle both player perspectives
## Testing Strategy
1. Verify all evaluations reach same player-flip depth
2. Confirm opponent chooses minimizing moves
3. Test with different `maxPlayerFlips` settings
4. Validate score consistency across tree
## Notes
- Stack overflow risk accepted for evaluation consistency
- Each unit can make multiple moves per turn (move + scout + attack)
- Maximum ~10 units per player limits practical depth
- END_TURN and END_PLAYER_SETUP both count as player flips
@@ -125,7 +125,7 @@ int main(int argc, char* argv[]) {
isDefender,
hexMap,
settingsGetter,
AIAlgorithmType::MCTS);
AIAlgorithmType::MCTS_CLEAN);
// Create a second AI client for the human player during setup
// This ensures consistent state handling during setup phase
@@ -135,16 +135,22 @@ int main(int argc, char* argv[]) {
!isDefender,
hexMap,
settingsGetter,
AIAlgorithmType::MCTS);
AIAlgorithmType::MCTS_CLEAN);
// Complete setup phase - AI makes intelligent placement decisions
if (currentState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP) {
std::cout << "Setup phase detected. Completing setup...\n";
int setupMoves = 0;
while (currentState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP) {
PlayerId currentPlayer = currentState->current_player();
auto availableCommands = engine.GetAvailableCommandProtos(currentPlayer, false);
std::cout << "Setup move " << setupMoves++ << ": player "
<< static_cast<int>(currentPlayer) << " has " << availableCommands.size()
<< " commands\n";
if (availableCommands.empty()) {
std::cout << "No commands available for player "
<< static_cast<int>(currentPlayer) << "\n";
@@ -154,15 +160,19 @@ int main(int argc, char* argv[]) {
if (currentPlayer == aiPlayerId) {
// Let AI make intelligent placement decisions
auto choiceResults = aiClient.ChooseCommandIndex(engine);
std::cout << "AI player chose command " << choiceResults.chosenIndex << "\n";
engine.PostCommand(currentPlayer, choiceResults.chosenIndex);
} else {
// Human player: use AI for setup to ensure consistent state handling
auto choiceResults = humanSetupAI.ChooseCommandIndex(engine);
std::cout << "Human player (AI) chose command " << choiceResults.chosenIndex
<< "\n";
engine.PostCommand(currentPlayer, choiceResults.chosenIndex);
}
currentState = engine.GetCurrentGameState();
}
std::cout << "Setup complete! Game state: " << currentState->status()->state() << "\n";
}
// Test AI performance for configured number of turns
@@ -171,6 +181,25 @@ int main(int argc, char* argv[]) {
std::vector<AIPerformanceMetrics> metrics;
for (int turn = 0; turn < config.numTurns; ++turn) {
// Update current state and check whose turn it is
currentState = engine.GetCurrentGameState();
PlayerId currentPlayer = currentState->current_player();
// If it's not the AI's turn, skip to next iteration
if (currentPlayer != aiPlayerId) {
std::cout << " Turn " << turn << ": It's player "
<< static_cast<int>(currentPlayer) << "'s turn (not AI). Skipping...\n";
// Have the opponent make a simple move to advance the game
const auto opponentCommands =
engine.GetAvailableCommandProtos(currentPlayer, false);
if (!opponentCommands.empty()) {
// For now, opponent chooses first available command
engine.PostCommand(currentPlayer, 0);
}
continue;
}
// Check if AI can make a move
const auto availableCommands = engine.GetAvailableCommandProtos(aiPlayerId, false);
if (availableCommands.empty()) {
@@ -75,7 +75,7 @@ vector<shared_ptr<ShardokAIClient>> ShardokGameController::MakeAIClients(
pi.is_defender(),
e->GetCurrentGameState()->hex_map(),
e->GetGameSettings()->GetGetter(),
AIAlgorithmType::MCTS);
AIAlgorithmType::MCTS_CLEAN);
aic.push_back(newClient);
}