Evaluate move follow-ups as leaf compounds

This commit is contained in:
2026-07-07 08:08:43 -07:00
parent 560ceb8097
commit d874ca42a9
3 changed files with 155 additions and 44 deletions
@@ -11,6 +11,7 @@
#include <iomanip>
#include <iostream>
#include <limits>
#include <optional>
#include <sstream>
#include <stdexcept>
@@ -117,41 +118,14 @@ static auto CommandSorter(
return false;
}
static auto FollowUpCommandScoreBonus(const CommandType commandType) -> ScoreValue {
static auto IsCompoundLeafFollowUpCommand(const CommandType commandType) -> bool {
switch (commandType) {
case net::eagle0::shardok::common::CHARGE_COMMAND: return 30.0;
case net::eagle0::shardok::common::ARCHERY_COMMAND: return 20.0;
case net::eagle0::shardok::common::METEOR_START_COMMAND: return 35.0;
case net::eagle0::shardok::common::LIGHTNING_BOLT_COMMAND: return 25.0;
case net::eagle0::shardok::common::REDUCE_COMMAND: return 25.0;
case net::eagle0::shardok::common::FEAR_COMMAND: return 15.0;
case net::eagle0::shardok::common::START_FIRE_COMMAND: return 15.0;
case net::eagle0::shardok::common::REPAIR_COMMAND: return 10.0;
case net::eagle0::shardok::common::REINFORCE_COMMAND: return 10.0;
case net::eagle0::shardok::common::SCOUT_COMMAND: return 5.0;
default: return 0.0;
case net::eagle0::shardok::common::ARCHERY_COMMAND:
case net::eagle0::shardok::common::CHARGE_COMMAND: return true;
default: return false;
}
}
static auto LeafMoveFollowUpScoreBonus(
const ShardokEngine& guessedEngine,
const PlayerId pid,
const size_t commandIndex) -> ScoreValue {
const CommandListSPtr commandsWithFollowUps =
guessedEngine.GetAvailableCommandsForAIPlayer(pid, true);
if (!commandsWithFollowUps || commandIndex >= commandsWithFollowUps->size()) { return 0.0; }
const CommandSPtr& command = commandsWithFollowUps->at(commandIndex);
if (command->GetCommandType() != net::eagle0::shardok::common::MOVE_COMMAND) { return 0.0; }
ScoreValue bonus = 0.0;
const auto proto = command->GetCommandProto();
for (const int followUpType : proto.follow_up_command_types()) {
bonus += FollowUpCommandScoreBonus(static_cast<CommandType>(followUpType));
}
return bonus;
}
AICommandEvaluator::AICommandEvaluator(
const AIScoreCalculator& scorer,
const APDCache& apdCache,
@@ -162,6 +136,100 @@ AICommandEvaluator::AICommandEvaluator(
battalionTypeGetter_(std::move(battalionTypeGetter)),
scoreMoveFollowUps_(scoreMoveFollowUps) {}
auto AICommandEvaluator::BestLeafMoveFollowUpScore(
const PlayerId pid,
const bool isDefender,
const int maxRepeatCount,
const ShardokEngine& movedEngine,
const AIStrategy& attackerStrategy,
const CoordsSet& allCastleCoords,
const std::chrono::steady_clock::time_point deadline) const -> std::optional<ScoreValue> {
if (std::chrono::steady_clock::now() > deadline) { return std::nullopt; }
const CommandListSPtr followUpCommands = movedEngine.GetAvailableCommandsForAIPlayer(pid);
if (!followUpCommands || followUpCommands->empty()) { return std::nullopt; }
std::optional<ScoreValue> bestScore;
auto scoreFollowUpOutcome = [&](const size_t followUpIndex,
const std::shared_ptr<RandomGenerator>& randomGenerator)
-> std::optional<ScoreValue> {
if (std::chrono::steady_clock::now() > deadline) { return std::nullopt; }
auto followUpEngine = std::make_shared<ShardokEngine>(movedEngine, false);
try {
followUpEngine->PostCommand(pid, followUpIndex, randomGenerator);
} catch (const std::exception& e) {
std::ostringstream context;
context << "AI leaf compound follow-up failed while posting command"
<< " player=" << static_cast<int>(pid) << " follow_up_index=" << followUpIndex
<< ' ' << DescribeCommandAt(followUpCommands, followUpIndex);
throw WithExceptionContext(context.str(), e);
}
try {
return scorer_.GuessedStateScore(
isDefender,
followUpEngine->GetCurrentGameState(),
attackerStrategy,
allCastleCoords);
} catch (const std::exception& e) {
std::ostringstream context;
context << "AI leaf compound follow-up failed while scoring state"
<< " player=" << static_cast<int>(pid) << " follow_up_index=" << followUpIndex
<< ' ' << DescribeCommandAt(followUpCommands, followUpIndex);
throw WithExceptionContext(context.str(), e);
}
};
for (size_t followUpIndex = 0; followUpIndex < followUpCommands->size(); ++followUpIndex) {
const CommandSPtr& followUpCommand = followUpCommands->at(followUpIndex);
const CommandType followUpType = followUpCommand->GetCommandType();
if (!IsCompoundLeafFollowUpCommand(followUpType)) { continue; }
std::optional<ScoreValue> followUpScore;
if (IsDeterministic(followUpType)) {
followUpScore = scoreFollowUpOutcome(followUpIndex, AverageGenerator());
} else if (followUpCommand->HasOdds()) {
const auto successChancePercentile = followUpCommand->GetOddsPercentile();
const double successChance = static_cast<double>(successChancePercentile) / 100.0;
const auto successScore = scoreFollowUpOutcome(
followUpIndex,
std::make_shared<SequenceRandomGenerator>(
std::vector{SuccessSampleForOdds(successChance)}));
const auto failureScore = scoreFollowUpOutcome(
followUpIndex,
std::make_shared<SequenceRandomGenerator>(
std::vector{FailureSampleForOdds(successChance)}));
if (successScore.has_value() && failureScore.has_value()) {
followUpScore = std::lerp(*failureScore, *successScore, successChance);
}
} else {
ScoreValue sum = 0.0;
int completedSamples = 0;
const int sampleCount = std::max(1, maxRepeatCount);
for (int repeatIteration = 0; repeatIteration < sampleCount; ++repeatIteration) {
auto sequence =
std::vector{RandomnessSampleForRepeat(repeatIteration, sampleCount)};
const auto sampleScore = scoreFollowUpOutcome(
followUpIndex,
std::make_shared<SequenceRandomGenerator>(std::move(sequence)));
if (!sampleScore.has_value()) { continue; }
sum += *sampleScore;
completedSamples++;
}
if (completedSamples > 0) {
followUpScore = sum / static_cast<ScoreValue>(completedSamples);
}
}
if (followUpScore.has_value() && (!bestScore.has_value() || *followUpScore > *bestScore)) {
bestScore = followUpScore;
}
}
return bestScore;
}
auto AICommandEvaluator::PerformLookahead(
const PlayerId pid,
const bool isDefender,
@@ -294,10 +362,11 @@ auto AICommandEvaluator::EvaluateWithRandomness(
}
const auto guessedCommands = guessedEngine.GetAvailableCommandsForAIPlayer(pid);
const ScoreValue leafMoveFollowUpBonus =
scoreMoveFollowUps_ && remainingLookahead <= 0
? LeafMoveFollowUpScoreBonus(guessedEngine, pid, commandIndex)
: 0.0;
const bool shouldExpandLeafMoveFollowUps =
scoreMoveFollowUps_ && remainingLookahead <= 0 &&
commandIndex < guessedCommands->size() &&
guessedCommands->at(commandIndex)->GetCommandType() ==
net::eagle0::shardok::common::MOVE_COMMAND;
auto innerEngine = std::make_shared<ShardokEngine>(guessedEngine, false);
try {
innerEngine->PostCommand(pid, commandIndex, randomGenerator);
@@ -314,11 +383,23 @@ auto AICommandEvaluator::EvaluateWithRandomness(
ScoreValue innerUtility;
try {
innerUtility = scorer_.GuessedStateScore(
isDefender,
innerEngine->GetCurrentGameState(),
attackerStrategy,
allCastleCoords) +
leafMoveFollowUpBonus;
isDefender,
innerEngine->GetCurrentGameState(),
attackerStrategy,
allCastleCoords);
if (shouldExpandLeafMoveFollowUps) {
const auto followUpScore = BestLeafMoveFollowUpScore(
pid,
isDefender,
maxRepeatCount,
*innerEngine,
attackerStrategy,
allCastleCoords,
deadline);
if (followUpScore.has_value()) {
innerUtility = std::max(innerUtility, *followUpScore);
}
}
} catch (const std::exception& e) {
std::ostringstream context;
context << "AI EvaluateWithRandomness failed while scoring simulated state"
@@ -8,6 +8,7 @@
#include <chrono>
#include <future>
#include <optional>
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
@@ -112,6 +113,15 @@ private:
const AIStrategy& attackerStrategy,
const CoordsSet& allCastleCoords,
std::chrono::steady_clock::time_point deadline) const -> ImmediateAndLookaheadScore;
[[nodiscard]] auto BestLeafMoveFollowUpScore(
PlayerId pid,
bool isDefender,
int maxRepeatCount,
const ShardokEngine& movedEngine,
const AIStrategy& attackerStrategy,
const CoordsSet& allCastleCoords,
std::chrono::steady_clock::time_point deadline) const -> std::optional<ScoreValue>;
};
} // namespace shardok
@@ -53,6 +53,18 @@ private:
ScoreValue score_;
};
class AttackerActionPointSpentScorer : public AIScoreCalculator {
public:
[[nodiscard]] auto GuessedStateScore(
bool /*isDefender*/,
const GameStateW& state,
const AIStrategy& /*aiStrategy*/,
const CoordsSet& /*allCastleCoords*/) const -> ScoreValue override {
const auto* attacker = state->units()->Get(0);
return 10.0 - attacker->remaining_action_points();
}
};
class GatedCastleCoordsScorer : public AIScoreCalculator {
public:
explicit GatedCastleCoordsScorer(std::shared_future<void> gate) : gate_(std::move(gate)) {}
@@ -214,7 +226,7 @@ TEST_F(AICommandEvaluatorTest, LookaheadFutureOwnsCastleCoords) {
EXPECT_TRUE(evaluation.completed);
}
TEST_F(AICommandEvaluatorTest, LeafMoveWithArcheryFollowUpReceivesTacticalBonus) {
TEST_F(AICommandEvaluatorTest, LeafMoveWithArcheryFollowUpCanUseCompoundScore) {
auto attacker = GetUnitT1();
attacker.mutable_location() = Coords(2, 3);
attacker.mutate_remaining_action_points(10);
@@ -247,7 +259,7 @@ TEST_F(AICommandEvaluatorTest, LeafMoveWithArcheryFollowUpReceivesTacticalBonus)
net::eagle0::shardok::common::MOVE_COMMAND,
commandsWithoutFollowUps->at(moveIndex)->GetCommandType());
const ConstantScorer scorer(100.0);
const AttackerActionPointSpentScorer scorer;
const auto apdCache = std::make_shared<ActionPointDistancesCache>();
const auto battalionTypeGetter = [this](BattalionTypeId typeId) {
return settings->GetGetter().GetBattalionType(typeId);
@@ -255,6 +267,14 @@ TEST_F(AICommandEvaluatorTest, LeafMoveWithArcheryFollowUpReceivesTacticalBonus)
const AICommandEvaluator evaluator(scorer, apdCache, battalionTypeGetter);
const CoordsSet emptyCastleCoords(state->hex_map());
ShardokEngine moveOnlyEngine(engine, false);
moveOnlyEngine.PostCommand(0, static_cast<int64_t>(moveIndex));
const ScoreValue moveOnlyScore = scorer.GuessedStateScore(
/*isDefender=*/false,
moveOnlyEngine.GetCurrentGameState(),
FleeStrategy,
emptyCastleCoords);
auto result = evaluator.EvaluateCommand(
0,
/*isDefender=*/false,
@@ -262,14 +282,14 @@ TEST_F(AICommandEvaluatorTest, LeafMoveWithArcheryFollowUpReceivesTacticalBonus)
/*maxRepeatCount=*/1,
engine,
FleeStrategy,
/*currentUtility=*/100.0,
/*currentUtility=*/0.0,
emptyCastleCoords,
moveIndex,
std::chrono::steady_clock::now() + std::chrono::seconds(5));
const auto evaluation = result.get();
EXPECT_TRUE(evaluation.completed);
EXPECT_GT(evaluation.score, 100.0);
EXPECT_GT(evaluation.score, moveOnlyScore);
}
} // namespace shardok