mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 06:55:41 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e49af0868f | ||
|
|
08bbbdd651 | ||
|
|
16eb808a73 | ||
|
|
d2a1cc77e9 | ||
|
|
4a974b265a | ||
|
|
f181c9241d | ||
|
|
6cad64d348 | ||
|
|
83780ca7bb | ||
|
|
1a9b307922 | ||
|
|
c887b868fd | ||
|
|
05bacede4f | ||
|
|
d3a1088b4c | ||
|
|
0a33ad4433 | ||
|
|
3f6a8340d3 | ||
|
|
811fcec712 | ||
|
|
3c2f5eeab1 |
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user