Short-circuit defender water-crossing checks (#8779)

This commit is contained in:
2026-07-24 14:01:12 -07:00
committed by GitHub
parent 82fc200d51
commit f913116132
4 changed files with 241 additions and 41 deletions
@@ -4,9 +4,6 @@
#include "AIDefenderStrategySelector.hpp"
#include <algorithm>
#include <ranges>
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackGroups.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCalculator.hpp"
@@ -67,45 +64,15 @@ auto AIDefenderStrategySelector::BestDefenderStrategy(
canFlee && roundsRemaining > 2 &&
defenderTroops < MAXIMUM_RATIO_FOR_DEFENDER_TO_FLEE * attackerTroops &&
attackerNonUndeadUnitCount >= criticalTileCoords.size();
if (waterCrossingCouldAffectStrategy) {
vector<UnitId> attackerUnitIdsRequiringWaterCrossing{};
attackerUnitIdsRequiringWaterCrossing.reserve(gameState->units()->size());
for (const auto& player : *gameState->player_infos()) {
if (!player->is_defender()) {
const auto& unitIdsRequiringWaterCrossing = UnitIdsRequiringWaterCrossing(
if (waterCrossingCouldAffectStrategy && HasEnoughNonUndeadAttackersWithoutWaterCrossing(
gameState,
player->player_id(),
criticalTileCoords,
landRegions,
apdCache,
battalionTypeGetter);
attackerUnitIdsRequiringWaterCrossing.insert(
attackerUnitIdsRequiringWaterCrossing.end(),
unitIdsRequiringWaterCrossing.begin(),
unitIdsRequiringWaterCrossing.end());
}
}
uint32_t attackerNonUndeadUnitNotRequiringWaterCrossingCount = 0;
for (const Unit* unit : *gameState->units()) {
if (unit->status() != net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT &&
unit->status() != net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT) {
continue;
}
const auto* pi = PlayerInfoForPid(gameState, unit->player_id());
if (pi != nullptr && !pi->is_defender() &&
unit->battalion().type() !=
net::eagle0::shardok::storage::fb::BattalionTypeId_UNDEAD &&
!std::ranges::contains(attackerUnitIdsRequiringWaterCrossing, unit->unit_id())) {
++attackerNonUndeadUnitNotRequiringWaterCrossingCount;
}
}
if (attackerNonUndeadUnitNotRequiringWaterCrossingCount >= criticalTileCoords.size()) {
battalionTypeGetter,
criticalTileCoords.size())) {
return FleeStrategy;
}
}
if (attackerNonUndeadUnitCount >= criticalTileCoords.size()) {
chosenStrategy = HoldCastlesStrategy;
@@ -37,6 +37,15 @@ auto CanReachWithDistances(
return distances->Distance(origin, destination) != ActionPointDistances::IMPOSSIBLE;
}
auto CanReachAllWithDistances(
const Coords &origin,
const CoordsSet &destinations,
const ActionPointDistances *distances) -> bool {
return std::ranges::all_of(destinations, [&](const Coords &destination) {
return CanReachWithDistances(origin, destination, distances);
});
}
auto DefinitelyReachesAllWithoutWater(
const LandRegions &landRegions,
const Coords &origin,
@@ -46,6 +55,58 @@ auto DefinitelyReachesAllWithoutWater(
});
}
auto DefinitelyReachesAllWithoutWater(
const GameStateW &gameState,
const net::eagle0::shardok::storage::fb::Unit *unit,
const CoordsSet &destinations,
const LandRegions &landRegions) -> bool {
if (unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) {
return DefinitelyReachesAllWithoutWater(landRegions, unit->location(), destinations);
}
const auto *startingPositions = gameState->hex_map()
->attacker_starting_positions()
->Get(unit->starting_position_index())
->positions();
return std::ranges::all_of(*startingPositions, [&](const Coords *position) {
return DefinitelyReachesAllWithoutWater(landRegions, *position, destinations);
});
}
auto CanReachAllWithoutWater(
const GameStateW &gameState,
const net::eagle0::shardok::storage::fb::Unit *unit,
const CoordsSet &destinations,
const ActionPointDistances *distances) -> bool {
if (unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) {
return CanReachAllWithDistances(unit->location(), destinations, distances);
}
const auto *startingPositions = gameState->hex_map()
->attacker_starting_positions()
->Get(unit->starting_position_index())
->positions();
return std::ranges::all_of(*startingPositions, [&](const Coords *position) {
return CanReachAllWithDistances(*position, destinations, distances);
});
}
auto IsActiveNonUndeadAttacker(
const GameStateW &gameState,
const net::eagle0::shardok::storage::fb::Unit *unit) -> bool {
if (unit->status() != net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT &&
unit->status() != net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT) {
return false;
}
if (unit->battalion().type() == net::eagle0::shardok::storage::fb::BattalionTypeId_UNDEAD) {
return false;
}
return std::ranges::any_of(*gameState->player_infos(), [&](const auto *playerInfo) {
return playerInfo->player_id() == unit->player_id() && !playerInfo->is_defender();
});
}
auto CanCreateWaterCrossing(
const net::eagle0::shardok::storage::fb::Unit *unit,
const BattalionTypeGetter &battalionTypeGetter) -> bool {
@@ -176,6 +237,73 @@ auto UnitIdsRequiringWaterCrossing(
return unitIdsToReturn;
}
auto HasEnoughNonUndeadAttackersWithoutWaterCrossing(
const GameStateW &gameState,
const CoordsSet &destinations,
const LandRegions &landRegions,
const APDCache &apdCache,
const BattalionTypeGetter &battalionTypeGetter,
const std::size_t requiredUnitCount) -> bool {
if (requiredUnitCount == 0) return true;
std::size_t eligibleUnitCount = 0;
std::size_t reachableUnitCount = 0;
for (const auto *unit : *gameState->units()) {
if (!IsActiveNonUndeadAttacker(gameState, unit)) continue;
++eligibleUnitCount;
if (DefinitelyReachesAllWithoutWater(gameState, unit, destinations, landRegions) &&
++reachableUnitCount >= requiredUnitCount) {
return true;
}
}
if (eligibleUnitCount < requiredUnitCount) return false;
const HexMap *map = gameState->hex_map();
MapId mapId = ActionPointDistancesCache::GetMapId(map);
fb::HexMapW fireClearedMap;
if (HasNonWaterFire(map)) {
fireClearedMap = fb::CopyHexMap(map);
for (uint32_t index = 0; index < fireClearedMap->terrain()->size(); ++index) {
if (IsWater(fireClearedMap->terrain()->Get(index)->type())) continue;
// const_cast is safe because we own the mutable buffer (fireClearedMap)
const_cast<net::eagle0::shardok::storage::fb::Terrain *>(
fireClearedMap->mutable_terrain()->GetMutableObject(index))
->mutable_modifier()
.mutable_fire()
.mutate_present(false);
}
map = fireClearedMap.Get();
mapId = RecomputeModifierHash(fireClearedMap.Get());
}
std::array<const ActionPointDistances *, BattalionTypeId::BattalionTypeId_MAX + 1>
distancesByBattalionType{};
const auto distancesFor = [&](const BattalionTypeSPtr &battalionType) {
auto &distances = distancesByBattalionType[battalionType->typeId];
if (distances == nullptr) {
distances = apdCache->GetRaw(map, mapId, battalionType, false);
}
return distances;
};
for (const auto *unit : *gameState->units()) {
if (!IsActiveNonUndeadAttacker(gameState, unit)) continue;
if (DefinitelyReachesAllWithoutWater(gameState, unit, destinations, landRegions)) continue;
const auto &battalionType = battalionTypeGetter(unit->battalion().type());
if (CanReachAllWithoutWater(gameState, unit, destinations, distancesFor(battalionType)) &&
++reachableUnitCount >= requiredUnitCount) {
return true;
}
}
return false;
}
auto UnitIdsToCreateWaterCrossing(
const GameStateW &gameState,
const PlayerId pid,
@@ -5,6 +5,8 @@
#ifndef EAGLE0_SHARDOK_AI_AI_WATER_CROSSING_CALCULATOR_HPP
#define EAGLE0_SHARDOK_AI_AI_WATER_CROSSING_CALCULATOR_HPP
#include <cstddef>
#include "src/main/cpp/net/eagle0/shardok/ai/AICommonTypes.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
@@ -40,6 +42,16 @@ static inline void AssertValid(const Coords& c, const HexMap* hexMap) {
const APDCache& apdCache,
const BattalionTypeGetter& battalionTypeGetter) -> vector<UnitId>;
// Whether at least requiredUnitCount active, non-undead attacker units can reach every destination
// without crossing water.
[[nodiscard]] auto HasEnoughNonUndeadAttackersWithoutWaterCrossing(
const GameStateW& gameState,
const CoordsSet& destinations,
const LandRegions& landRegions,
const APDCache& apdCache,
const BattalionTypeGetter& battalionTypeGetter,
std::size_t requiredUnitCount) -> bool;
// Units belonging to the player that are capable of creating water crossings
[[nodiscard]] auto UnitIdsToCreateWaterCrossing(
const GameStateW& gameState,
@@ -32,6 +32,10 @@ class AIWaterCrossingCalculatorTest : public ::testing::Test {
AddGenericUnit(0, 1, Coords(4, 0))};
const auto unitsOffset = fbb.CreateVectorOfSortedStructs(&unitsVec);
const auto playerInfoOffset = fbb.CreateVector(
std::vector<flatbuffers::Offset<net::eagle0::shardok::storage::fb::PlayerInfo>>{
AddPlayerInfo(fbb, 0, false, 1000),
AddPlayerInfo(fbb, 1, true, 1000)});
auto terrainTypes = TERRAIN_VALUES_FB;
terrainTypes[0 * 6 + 3] = net::eagle0::shardok::storage::fb::Terrain_::Type_RIVER;
@@ -52,6 +56,7 @@ class AIWaterCrossingCalculatorTest : public ::testing::Test {
net::eagle0::shardok::storage::fb::GameStateBuilder gsb(fbb);
gsb.add_units(unitsOffset);
gsb.add_hex_map(hexMapOffset);
gsb.add_player_infos(playerInfoOffset);
fbb.Finish(gsb.Finish());
gameState = GameStateW(fbb);
@@ -235,6 +240,94 @@ TEST_F(AIWaterCrossingCalculatorTest,
EXPECT_EQ(1, ActionPointDistancesCache::GetThreadLocalCacheSize());
}
TEST_F(AIWaterCrossingCalculatorTest,
EnoughAttackersWithoutWaterCrossing_staticRegionsShortCircuitWithoutDistances) {
unit0->mutable_location() = Coords(0, 4);
unit1->mutable_location() = Coords(0, 5);
CoordsSet destinations(hexMap);
destinations.Add(acrossDestination);
int battalionTypeLookups = 0;
EXPECT_TRUE(HasEnoughNonUndeadAttackersWithoutWaterCrossing(
gameState,
destinations,
alCache->GetLandRegions(),
apdCache,
[&](BattalionTypeId typeId) {
++battalionTypeLookups;
return GetGameSettings()->GetGetter().GetBattalionType(typeId);
},
1));
EXPECT_EQ(0, battalionTypeLookups);
EXPECT_EQ(0, ActionPointDistancesCache::GetThreadLocalCacheSize());
}
TEST_F(AIWaterCrossingCalculatorTest,
EnoughAttackersWithoutWaterCrossing_exactDistancesShortCircuitAtThreshold) {
auto& bridgeModifier = MutableTerrain(hexMap, Coords(1, 3))->mutable_modifier();
bridgeModifier.mutable_bridge().mutate_present(true);
bridgeModifier.mutable_bridge().mutate_integrity(100.0);
hexMap->mutate_modifier_hash(0);
hexMap->mutate_modifier_hash(ActionPointDistancesCache::GetMapId(hexMap).modifierId);
CoordsSet destinations(hexMap);
destinations.Add(acrossDestination);
int battalionTypeLookups = 0;
EXPECT_TRUE(HasEnoughNonUndeadAttackersWithoutWaterCrossing(
gameState,
destinations,
alCache->GetLandRegions(),
apdCache,
[&](BattalionTypeId typeId) {
++battalionTypeLookups;
return GetGameSettings()->GetGetter().GetBattalionType(typeId);
},
1));
EXPECT_EQ(1, battalionTypeLookups);
EXPECT_EQ(1, ActionPointDistancesCache::GetThreadLocalCacheSize());
}
TEST_F(AIWaterCrossingCalculatorTest, EnoughAttackersWithoutWaterCrossing_noLandPathReturnsFalse) {
CoordsSet destinations(hexMap);
destinations.Add(acrossDestination);
EXPECT_FALSE(HasEnoughNonUndeadAttackersWithoutWaterCrossing(
gameState,
destinations,
alCache->GetLandRegions(),
apdCache,
[](BattalionTypeId typeId) {
return GetGameSettings()->GetGetter().GetBattalionType(typeId);
},
1));
EXPECT_EQ(1, ActionPointDistancesCache::GetThreadLocalCacheSize());
}
TEST_F(AIWaterCrossingCalculatorTest,
EnoughAttackersWithoutWaterCrossing_ignoresDefendersAndUndead) {
unit0->mutate_player_id(1);
unit0->mutable_location() = Coords(0, 4);
unit1->mutable_battalion().mutate_type(
net::eagle0::shardok::storage::fb::BattalionTypeId_UNDEAD);
unit1->mutable_location() = Coords(0, 5);
CoordsSet destinations(hexMap);
destinations.Add(acrossDestination);
EXPECT_FALSE(HasEnoughNonUndeadAttackersWithoutWaterCrossing(
gameState,
destinations,
alCache->GetLandRegions(),
apdCache,
[](BattalionTypeId typeId) {
return GetGameSettings()->GetGetter().GetBattalionType(typeId);
},
1));
EXPECT_EQ(0, ActionPointDistancesCache::GetThreadLocalCacheSize());
}
TEST_F(AIWaterCrossingCalculatorTest, CanReach_sameSide_returnsTrue) {
EXPECT_TRUE(CanReach(origin, sameSideDestination, hexMap, mapId, apdCache, heavyCavalry));
}