Score AI move follow-up opportunities

This commit is contained in:
2026-07-06 19:16:13 -07:00
parent 266ddf5570
commit 91ec899247
6 changed files with 200 additions and 38 deletions
@@ -117,6 +117,41 @@ static auto CommandSorter(
return false;
}
static auto FollowUpCommandScoreBonus(const CommandType commandType) -> ScoreValue {
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;
}
}
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,
@@ -257,6 +292,9 @@ auto AICommandEvaluator::EvaluateWithRandomness(
}
const auto guessedCommands = guessedEngine.GetAvailableCommandsForAIPlayer(pid);
const ScoreValue leafMoveFollowUpBonus =
remainingLookahead <= 0 ? LeafMoveFollowUpScoreBonus(guessedEngine, pid, commandIndex)
: 0.0;
auto innerEngine = std::make_shared<ShardokEngine>(guessedEngine, false);
try {
innerEngine->PostCommand(pid, commandIndex, randomGenerator);
@@ -273,10 +311,11 @@ auto AICommandEvaluator::EvaluateWithRandomness(
ScoreValue innerUtility;
try {
innerUtility = scorer_.GuessedStateScore(
isDefender,
innerEngine->GetCurrentGameState(),
attackerStrategy,
allCastleCoords);
isDefender,
innerEngine->GetCurrentGameState(),
attackerStrategy,
allCastleCoords) +
leafMoveFollowUpBonus;
} catch (const std::exception& e) {
std::ostringstream context;
context << "AI EvaluateWithRandomness failed while scoring simulated state"
@@ -112,6 +112,7 @@ void ShardokEngine::ApplyAndAddActionResults(const std::vector<ActionResultProto
void ShardokEngine::ApplyAndAddActionResult(const ActionResultProto &result) {
gameState = ApplyResult(std::move(gameState), result, settingsGetter);
ClearCachedAvailableCommands();
if (trackHistory) {
actionHistory.emplace_back();
@@ -146,7 +147,13 @@ ShardokEngine::ShardokEngine(const ShardokEngine &toCopy, const bool trackHistor
criticalTileCoords(toCopy.criticalTileCoords),
tutorialController_(toCopy.tutorialController_),
pendingReinforcementPlacement_(toCopy.pendingReinforcementPlacement_),
cachedAvailableCommands(toCopy.cachedAvailableCommands) {}
cachedAvailableCommandsWithoutFollowUps(toCopy.cachedAvailableCommandsWithoutFollowUps),
cachedAvailableCommandsWithFollowUps(toCopy.cachedAvailableCommandsWithFollowUps) {}
void ShardokEngine::ClearCachedAvailableCommands() const {
cachedAvailableCommandsWithoutFollowUps = nullptr;
cachedAvailableCommandsWithFollowUps = nullptr;
}
auto ShardokEngine::GetGameStateAtStartOfAction(const ActionId startingActionId) const
-> GameStateW {
@@ -316,13 +323,13 @@ auto ShardokEngine::PostWhilePlayerHasOnlyOneOption(
net::eagle0::shardok::storage::fb::GameStatus_::State_GAME_RUNNING &&
playerId == GetCurrentPlayerId() && playerId != UNCONTROLLED_PLAYER_ID) {
// if there's only one command available, go ahead and take it.
cachedAvailableCommands = availableCommandsFactory->GetAvailableCommands(
cachedAvailableCommandsWithFollowUps = availableCommandsFactory->GetAvailableCommands(
gameState,
playerId,
/* includeFollowUps=*/true);
if (cachedAvailableCommands->size() == 1) {
const auto loneCommand = cachedAvailableCommands->front();
cachedAvailableCommands = nullptr;
if (cachedAvailableCommandsWithFollowUps->size() == 1) {
const auto loneCommand = cachedAvailableCommandsWithFollowUps->front();
ClearCachedAvailableCommands();
PostActionUnchecked(loneCommand, randomGenerator, std::nullopt);
commandCount++;
} else {
@@ -471,7 +478,7 @@ void ShardokEngine::PostPlacementCommands(
}
}
cachedAvailableCommands = nullptr;
ClearCachedAvailableCommands();
// now execute
for (const auto &pi : placementInfos) {
@@ -533,7 +540,7 @@ void ShardokEngine::PostFinishedPlacementCommand(
if (it == placementCommands->end()) {
throw ShardokClientErrorException("No finish placement command found");
}
cachedAvailableCommands = nullptr;
ClearCachedAvailableCommands();
PostActionUnchecked(*it, randomGenerator, std::nullopt);
}
@@ -550,21 +557,22 @@ void ShardokEngine::PostCommand(
}
// Make sure this command is available & legal
if (!cachedAvailableCommands) {
cachedAvailableCommands = availableCommandsFactory->GetAvailableCommands(
if (!cachedAvailableCommandsWithFollowUps) {
cachedAvailableCommandsWithFollowUps = availableCommandsFactory->GetAvailableCommands(
gameState,
player,
/* includeFollowUps=*/true);
}
if (commandIndex >= static_cast<int64_t>(cachedAvailableCommands->size()) || commandIndex < 0) {
if (commandIndex >= static_cast<int64_t>(cachedAvailableCommandsWithFollowUps->size()) ||
commandIndex < 0) {
throw ShardokClientErrorException(
string("Illegal command index ") + std::to_string(commandIndex) +
string(", max is ") + std::to_string(cachedAvailableCommands->size()));
string(", max is ") + std::to_string(cachedAvailableCommandsWithFollowUps->size()));
}
const CommandSPtr command = (*cachedAvailableCommands)[commandIndex];
cachedAvailableCommands = nullptr;
const CommandSPtr command = (*cachedAvailableCommandsWithFollowUps)[commandIndex];
ClearCachedAvailableCommands();
PostActionUnchecked(command, randomGenerator, roll);
}
@@ -665,6 +673,12 @@ auto ShardokEngine::GetPreviewCommands(const PlayerId playerId) const -> std::ve
auto ShardokEngine::GetAvailableCommandsForAIPlayer(const PlayerId playerId) const
-> CommandListSPtr {
return GetAvailableCommandsForAIPlayer(playerId, false);
}
auto ShardokEngine::GetAvailableCommandsForAIPlayer(
const PlayerId playerId,
const bool includeFollowUps) const -> CommandListSPtr {
if (GameIsOver(GetGameStatus())) {
// The game is over
return {};
@@ -672,8 +686,12 @@ auto ShardokEngine::GetAvailableCommandsForAIPlayer(const PlayerId playerId) con
if (playerId != GetCurrentPlayerId()) return {};
CommandListSPtr &cachedAvailableCommands = includeFollowUps
? cachedAvailableCommandsWithFollowUps
: cachedAvailableCommandsWithoutFollowUps;
if (!cachedAvailableCommands) {
cachedAvailableCommands = UncachedGetAvailableCommands(playerId, false);
cachedAvailableCommands = UncachedGetAvailableCommands(playerId, includeFollowUps);
}
return cachedAvailableCommands;
@@ -688,11 +706,8 @@ auto ShardokEngine::GetAvailableCommandProtos(const PlayerId playerId, const boo
if (playerId != GetCurrentPlayerId()) return {};
if (!cachedAvailableCommands) {
cachedAvailableCommands = UncachedGetAvailableCommands(playerId, includeFollowUps);
}
return CommandProtosFromActionList(*cachedAvailableCommands);
return CommandProtosFromActionList(
*GetAvailableCommandsForAIPlayer(playerId, includeFollowUps));
}
auto ShardokEngine::UncachedGetAvailableCommands(
@@ -53,7 +53,10 @@ private:
TutorialBattleController tutorialController_;
std::optional<ReinforcementPlacementInfo> pendingReinforcementPlacement_;
mutable CommandListSPtr cachedAvailableCommands{};
mutable CommandListSPtr cachedAvailableCommandsWithoutFollowUps{};
mutable CommandListSPtr cachedAvailableCommandsWithFollowUps{};
void ClearCachedAvailableCommands() const;
void ApplyAndAddActionResult(const ActionResult &result);
@@ -185,6 +188,8 @@ public:
[[nodiscard]] auto GetPreviewCommands(PlayerId playerId) const -> std::vector<CommandProto>;
[[nodiscard]] auto GetAvailableCommandsForAIPlayer(PlayerId playerId) const -> CommandListSPtr;
[[nodiscard]] auto GetAvailableCommandsForAIPlayer(PlayerId playerId, bool includeFollowUps)
const -> CommandListSPtr;
[[nodiscard]] auto GetAvailableCommandProtos(PlayerId playerId, bool includeFollowUps) const
-> std::vector<CommandProto>;
@@ -37,6 +37,22 @@ public:
}
};
class ConstantScorer : public AIScoreCalculator {
public:
explicit ConstantScorer(const ScoreValue score) : score_(score) {}
[[nodiscard]] auto GuessedStateScore(
bool /*isDefender*/,
const GameStateW& /*state*/,
const AIStrategy& /*aiStrategy*/,
const CoordsSet& /*allCastleCoords*/) const -> ScoreValue override {
return score_;
}
private:
ScoreValue score_;
};
class GatedCastleCoordsScorer : public AIScoreCalculator {
public:
explicit GatedCastleCoordsScorer(std::shared_future<void> gate) : gate_(std::move(gate)) {}
@@ -84,6 +100,7 @@ protected:
setter.SetInt(kSettingsKeys.fleeSuccessPerAdjacentEnemy, 0);
setter.SetInt(kSettingsKeys.fleeSuccessPerAdjacentFriendly, 0);
setter.SetInt(kSettingsKeys.fleeSuccessPerAdjacentImpassableTile, 0);
setter.SetInt(kSettingsKeys.archeryActionPointCost, 5);
settings = GetGameSettings();
}
@@ -197,4 +214,62 @@ TEST_F(AICommandEvaluatorTest, LookaheadFutureOwnsCastleCoords) {
EXPECT_TRUE(evaluation.completed);
}
TEST_F(AICommandEvaluatorTest, LeafMoveWithArcheryFollowUpReceivesTacticalBonus) {
auto attacker = GetUnitT1();
attacker.mutable_location() = Coords(2, 3);
attacker.mutate_remaining_action_points(10);
auto defender = GetUnitT2();
defender.mutable_location() = Coords(2, 0);
std::vector attackers{attacker};
std::vector defenders{defender};
const auto enginePtr = GameWithUnits(BASIC_MAP, attackers, defenders);
const ShardokEngine& engine = *enginePtr;
const GameStateW& state = engine.GetCurrentGameState();
const auto commandsWithFollowUps = engine.GetAvailableCommandsForAIPlayer(0, true);
const auto moveWithArcheryFollowUp =
std::ranges::find_if(*commandsWithFollowUps, [](const CommandSPtr& command) {
const auto proto = command->GetCommandProto();
return proto.type() == net::eagle0::shardok::common::MOVE_COMMAND &&
std::ranges::contains(
proto.follow_up_command_types(),
net::eagle0::shardok::common::ARCHERY_COMMAND);
});
ASSERT_NE(moveWithArcheryFollowUp, commandsWithFollowUps->end());
const size_t moveIndex = static_cast<size_t>(
std::distance(commandsWithFollowUps->begin(), moveWithArcheryFollowUp));
const auto commandsWithoutFollowUps = engine.GetAvailableCommandsForAIPlayer(0);
ASSERT_LT(moveIndex, commandsWithoutFollowUps->size());
ASSERT_EQ(
net::eagle0::shardok::common::MOVE_COMMAND,
commandsWithoutFollowUps->at(moveIndex)->GetCommandType());
const ConstantScorer scorer(100.0);
const auto apdCache = std::make_shared<ActionPointDistancesCache>();
const auto battalionTypeGetter = [this](BattalionTypeId typeId) {
return settings->GetGetter().GetBattalionType(typeId);
};
const AICommandEvaluator evaluator(scorer, apdCache, battalionTypeGetter);
const CoordsSet emptyCastleCoords(state->hex_map());
auto result = evaluator.EvaluateCommand(
0,
/*isDefender=*/false,
/*remainingLookahead=*/0,
/*maxRepeatCount=*/1,
engine,
FleeStrategy,
/*currentUtility=*/100.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);
}
} // namespace shardok
@@ -757,6 +757,30 @@ TEST_F(AvailableCommandsTests,
EXPECT_TRUE(HasMoveWithFollowUpAction(commands, ARCHERY_COMMAND));
}
TEST_F(AvailableCommandsTests, GetAvailableCommands_cachesFollowUpAndNoFollowUpListsSeparately) {
vector<Unit> attackerUnits;
attackerUnits.push_back(GetUnitT1());
attackerUnits.back().mutable_location() = Coords(2, 3);
vector<Unit> defenderUnits;
defenderUnits.push_back(GetUnitT2());
defenderUnits.back().mutable_location() = Coords(2, 0);
const auto game = GameWithUnits(BASIC_MAP, attackerUnits, defenderUnits);
const auto commandsWithoutFollowUps = game->GetAvailableCommandsForAIPlayer(0);
ASSERT_TRUE(std::ranges::any_of(*commandsWithoutFollowUps, [](const CommandSPtr &command) {
return command->GetCommandType() == MOVE_COMMAND;
}));
const vector<CommandProto> commandsWithFollowUps = game->GetAvailableCommandProtos(0, true);
EXPECT_TRUE(HasMoveWithFollowUpAction(commandsWithFollowUps, ARCHERY_COMMAND));
const vector<CommandProto> commandsWithoutFollowUpsAgain =
game->GetAvailableCommandProtos(0, false);
EXPECT_FALSE(HasMoveWithFollowUpAction(commandsWithoutFollowUpsAgain, ARCHERY_COMMAND));
}
TEST_F(AvailableCommandsTests,
GetAvailableCommands_commandToMoveWithMeteorCostRemaining_includesStartMeteorAndLightning) {
vector<Unit> attackerUnits;
@@ -24,6 +24,12 @@ constexpr int MONTH = 4;
auto MakePlayerInfo(int playerId, bool isDefender, int food) -> PlayerInfoProto;
auto CommandDescriptorsEqualIgnoringFollowUps(CommandProto lhs, CommandProto rhs) -> bool {
lhs.clear_follow_up_command_types();
rhs.clear_follow_up_command_types();
return google::protobuf::util::MessageDifferencer::Equals(lhs, rhs);
}
PlayerInfoProto ATTACKER_INFO = MakePlayerInfo(0, false, 1000);
PlayerInfoProto DEFENDER_INFO = MakePlayerInfo(1, true, 1000);
std::vector PLAYER_INFOS{ATTACKER_INFO, DEFENDER_INFO};
@@ -387,13 +393,12 @@ auto postWithResults(
const PlayerId playerId,
const CommandProto &command) -> std::vector<ActionResultView> {
std::vector<CommandProto> commands = game->GetAvailableCommandProtos(playerId, false);
const auto index =
std::ranges::find_if(
commands,
[&](const CommandProto &c) {
return google::protobuf::util::MessageDifferencer::Equals(c, command);
}) -
commands.begin();
const auto index = std::ranges::find_if(
commands,
[&](const CommandProto &c) {
return CommandDescriptorsEqualIgnoringFollowUps(c, command);
}) -
commands.begin();
EXPECT_GE(index, 0);
EXPECT_LT(static_cast<size_t>(index), commands.size());
@@ -407,13 +412,12 @@ auto postWithResults(
const CommandProto &command,
std::shared_ptr<RandomGenerator> generator) -> std::vector<ActionResultView> {
std::vector<CommandProto> commands = game->GetAvailableCommandProtos(playerId, false);
const auto index =
std::ranges::find_if(
commands,
[&](const CommandProto &c) {
return google::protobuf::util::MessageDifferencer::Equals(c, command);
}) -
commands.begin();
const auto index = std::ranges::find_if(
commands,
[&](const CommandProto &c) {
return CommandDescriptorsEqualIgnoringFollowUps(c, command);
}) -
commands.begin();
EXPECT_GE(index, 0);
EXPECT_LT(static_cast<size_t>(index), commands.size());