diff --git a/src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCalculator.cpp b/src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCalculator.cpp index d202030c74..f9fc41af33 100644 --- a/src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCalculator.cpp +++ b/src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCalculator.cpp @@ -30,19 +30,6 @@ auto HasNonWaterFire(const HexMap *hexMap) -> bool { }); } -auto HasTraversableRegionBoundaryModifier(const HexMap *hexMap) -> bool { - return std::ranges::any_of(*hexMap->terrain(), [](const auto *terrain) { - if (!IsWater(terrain->type()) && - terrain->type() != net::eagle0::shardok::storage::fb::Terrain_::Type_MOUNTAIN) { - return false; - } - - const auto &modifier = terrain->modifier(); - return (modifier.bridge().present() && modifier.bridge().integrity() > 0.0) || - modifier.ice().present(); - }); -} - auto StaticRegionsAreConclusiveFor(const BattalionType &battalionType) -> bool { return battalionType.costToEnterWater.type == ActionCost::impossible && battalionType.costToEnterMountain.type == ActionCost::impossible; @@ -64,35 +51,39 @@ auto CanReachAllWithDistances( }); } -auto DefinitelyReachesAllWithoutWater( - const LandRegions &landRegions, +auto DefinitelyReachesAllWithoutCreatingCrossing( + const EffectiveLandRegions &effectiveLandRegions, const Coords &origin, const CoordsSet &destinations) -> bool { return std::ranges::all_of(destinations, [&](const Coords &destination) { - return landRegions.DefinitelyConnectedWithoutWater(origin, destination); + return effectiveLandRegions.DefinitelyConnectedWithoutCreatingCrossing(origin, destination); }); } -auto DefinitelyReachesAllWithoutWater( - const GameStateW &gameState, +auto DefinitelyReachesAllWithoutCreatingCrossing( + const HexMap *map, const net::eagle0::shardok::storage::fb::Unit *unit, const CoordsSet &destinations, - const LandRegions &landRegions) -> bool { + const EffectiveLandRegions &effectiveLandRegions) -> bool { if (unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) { - return DefinitelyReachesAllWithoutWater(landRegions, unit->location(), destinations); + return DefinitelyReachesAllWithoutCreatingCrossing( + effectiveLandRegions, + unit->location(), + destinations); } - const auto *startingPositions = gameState->hex_map() - ->attacker_starting_positions() - ->Get(unit->starting_position_index()) - ->positions(); + const auto *startingPositions = + map->attacker_starting_positions()->Get(unit->starting_position_index())->positions(); return std::ranges::all_of(*startingPositions, [&](const Coords *position) { - return DefinitelyReachesAllWithoutWater(landRegions, *position, destinations); + return DefinitelyReachesAllWithoutCreatingCrossing( + effectiveLandRegions, + *position, + destinations); }); } auto CanReachAllWithoutWater( - const GameStateW &gameState, + const HexMap *map, const net::eagle0::shardok::storage::fb::Unit *unit, const CoordsSet &destinations, const ActionPointDistances *distances) -> bool { @@ -100,17 +91,15 @@ auto CanReachAllWithoutWater( return CanReachAllWithDistances(unit->location(), destinations, distances); } - const auto *startingPositions = gameState->hex_map() - ->attacker_starting_positions() - ->Get(unit->starting_position_index()) - ->positions(); + const auto *startingPositions = + 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 GameState *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) { @@ -150,6 +139,7 @@ auto UnitIdsRequiringWaterCrossing( const GameState *state = gameState.Get(); const auto *units = state->units(); const HexMap *map = state->hex_map(); + const EffectiveLandRegions effectiveLandRegions(landRegions, map); std::vector unitsToCheck{}; unitsToCheck.reserve(units->size()); @@ -159,7 +149,10 @@ auto UnitIdsRequiringWaterCrossing( if (unit->player_id() != pid) continue; if (unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) { - if (!DefinitelyReachesAllWithoutWater(landRegions, unit->location(), destinations)) { + if (!DefinitelyReachesAllWithoutCreatingCrossing( + effectiveLandRegions, + unit->location(), + destinations)) { unitsToCheck.push_back(unit); } } else if (unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT) { @@ -169,7 +162,10 @@ auto UnitIdsRequiringWaterCrossing( ->Get(unit->starting_position_index()) ->positions(); for (const Coords *position : *startingPositions) { - if (!DefinitelyReachesAllWithoutWater(landRegions, *position, destinations)) { + if (!DefinitelyReachesAllWithoutCreatingCrossing( + effectiveLandRegions, + *position, + destinations)) { startingPositionIndices[unit->starting_position_index()] = 1; break; } @@ -184,7 +180,6 @@ auto UnitIdsRequiringWaterCrossing( if (unitsToCheck.empty()) return {}; MapId mapId = ActionPointDistancesCache::GetMapId(map); - const bool staticRegionsCanProveDisconnection = !HasTraversableRegionBoundaryModifier(map); fb::HexMapW fireClearedMap; bool mapPreparedForDistances = false; @@ -223,16 +218,14 @@ auto UnitIdsRequiringWaterCrossing( vector unitIdsToReturn{}; unitIdsToReturn.reserve(unitsToCheck.size()); - // The static region pass above removed every unit that is definitely able to reach every - // destination without water. Resolve full APDs only for the inconclusive units. + // The effective-region pass above removed every unit that can reach every destination through + // static land and current bridges. Resolve full APDs only for battalions that can traverse raw + // water or mountains. startingPositionIndices.fill(-1); for (const auto *unit : unitsToCheck) { const auto &battType = battalionTypeGetter(unit->battalion().type()); - // If this battalion cannot traverse either kind of static region boundary, and no bridge - // or ice currently makes one traversable, the failed region check above conclusively means - // that at least one destination is unreachable without creating a water crossing. - if (staticRegionsCanProveDisconnection && StaticRegionsAreConclusiveFor(*battType)) { + if (StaticRegionsAreConclusiveFor(*battType)) { unitIdsToReturn.push_back(unit->unit_id()); continue; } @@ -282,13 +275,22 @@ auto HasEnoughNonUndeadAttackersWithoutWaterCrossing( const std::size_t requiredUnitCount) -> bool { if (requiredUnitCount == 0) return true; + const GameState *state = gameState.Get(); + const auto *units = state->units(); + const HexMap *baseMap = state->hex_map(); + const EffectiveLandRegions effectiveLandRegions(landRegions, baseMap); + std::size_t eligibleUnitCount = 0; std::size_t reachableUnitCount = 0; - for (const auto *unit : *gameState->units()) { - if (!IsActiveNonUndeadAttacker(gameState, unit)) continue; + for (const auto *unit : *units) { + if (!IsActiveNonUndeadAttacker(state, unit)) continue; ++eligibleUnitCount; - if (DefinitelyReachesAllWithoutWater(gameState, unit, destinations, landRegions) && + if (DefinitelyReachesAllWithoutCreatingCrossing( + baseMap, + unit, + destinations, + effectiveLandRegions) && ++reachableUnitCount >= requiredUnitCount) { return true; } @@ -296,29 +298,33 @@ auto HasEnoughNonUndeadAttackersWithoutWaterCrossing( if (eligibleUnitCount < requiredUnitCount) return false; - const HexMap *map = gameState->hex_map(); - MapId mapId = ActionPointDistancesCache::GetMapId(map); - + const HexMap *map = baseMap; + MapId mapId = ActionPointDistancesCache::GetMapId(baseMap); 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( - fireClearedMap->mutable_terrain()->GetMutableObject(index)) - ->mutable_modifier() - .mutable_fire() - .mutate_present(false); - } - - map = fireClearedMap.Get(); - mapId = RecomputeModifierHash(fireClearedMap.Get()); - } - + bool mapPreparedForDistances = false; std::array distancesByBattalionType{}; const auto distancesFor = [&](const BattalionTypeSPtr &battalionType) { + if (!mapPreparedForDistances) { + mapPreparedForDistances = true; + + 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( + fireClearedMap->mutable_terrain()->GetMutableObject(index)) + ->mutable_modifier() + .mutable_fire() + .mutate_present(false); + } + + map = fireClearedMap.Get(); + mapId = RecomputeModifierHash(fireClearedMap.Get()); + } + } + auto &distances = distancesByBattalionType[battalionType->typeId]; if (distances == nullptr) { distances = apdCache->GetRaw(map, mapId, battalionType, false); @@ -326,12 +332,20 @@ auto HasEnoughNonUndeadAttackersWithoutWaterCrossing( return distances; }; - for (const auto *unit : *gameState->units()) { - if (!IsActiveNonUndeadAttacker(gameState, unit)) continue; - if (DefinitelyReachesAllWithoutWater(gameState, unit, destinations, landRegions)) continue; + for (const auto *unit : *units) { + if (!IsActiveNonUndeadAttacker(state, unit)) continue; + if (DefinitelyReachesAllWithoutCreatingCrossing( + baseMap, + unit, + destinations, + effectiveLandRegions)) { + continue; + } const auto &battalionType = battalionTypeGetter(unit->battalion().type()); - if (CanReachAllWithoutWater(gameState, unit, destinations, distancesFor(battalionType)) && + if (StaticRegionsAreConclusiveFor(*battalionType)) continue; + + if (CanReachAllWithoutWater(map, unit, destinations, distancesFor(battalionType)) && ++reachableUnitCount >= requiredUnitCount) { return true; } diff --git a/src/main/cpp/net/eagle0/shardok/library/map/LandRegions.cpp b/src/main/cpp/net/eagle0/shardok/library/map/LandRegions.cpp index d4354943f5..0f58915f13 100644 --- a/src/main/cpp/net/eagle0/shardok/library/map/LandRegions.cpp +++ b/src/main/cpp/net/eagle0/shardok/library/map/LandRegions.cpp @@ -4,6 +4,7 @@ #include "LandRegions.hpp" +#include #include #include #include @@ -38,6 +39,19 @@ auto ToIndex(const int row, const int column, const int columnCount) -> int { return row * columnCount + column; } +auto CountRegions(const std::span regions) -> int { + int count = 0; + for (const LandRegionId region : regions) { + if (region != kNoLandRegion) { count = std::max(count, static_cast(region) + 1); } + } + return count; +} + +auto HasUsableBridge(const net::eagle0::shardok::storage::fb::Terrain& terrain) -> bool { + const auto& bridge = terrain.modifier().bridge(); + return bridge.present() && bridge.integrity() > 0.0; +} + } // namespace auto ComputeLandRegions( @@ -99,11 +113,13 @@ auto ComputeLandRegions( LandRegions::LandRegions(const net::eagle0::shardok::storage::fb::HexMap* map) : rowCount(map->row_count()), - columnCount(map->column_count()) { + columnCount(map->column_count()), + regionCount(0) { std::vector terrain; terrain.reserve(map->terrain()->size()); for (const auto* tile : *map->terrain()) { terrain.push_back(*tile); } regions = ComputeLandRegions(rowCount, columnCount, terrain); + regionCount = CountRegions(regions); } LandRegions::LandRegions( @@ -112,7 +128,10 @@ LandRegions::LandRegions( const std::span terrain) : rowCount(rowCount), columnCount(columnCount), - regions(ComputeLandRegions(rowCount, columnCount, terrain)) {} + regionCount(0), + regions(ComputeLandRegions(rowCount, columnCount, terrain)) { + regionCount = CountRegions(regions); +} auto LandRegions::RegionAt(const Coords& coords) const -> LandRegionId { if (coords.row() < 0 || coords.column() < 0 || coords.row() >= rowCount || @@ -129,4 +148,137 @@ auto LandRegions::DefinitelyConnectedWithoutWater(const Coords& origin, const Co return originRegion != kNoLandRegion && originRegion == destinationRegion; } +template +void EffectiveLandRegions::Initialize( + const int rowCount, + const int columnCount, + TerrainAt&& terrainAt) { + const int tileCount = rowCount * columnCount; + if (rowCount != landRegions.rowCount || columnCount != landRegions.columnCount || + tileCount < 0 || tileCount > static_cast(kMaximumLandRegionTileCount)) { + throw std::invalid_argument("Effective land region map dimensions do not match"); + } + + nodeCount = landRegions.regionCount + tileCount; + for (int node = 0; node < nodeCount; ++node) { + parents[static_cast(node)] = static_cast(node); + } + + for (int index = 0; index < tileCount; ++index) { + bridgeTiles[static_cast(index)] = + landRegions.regions[static_cast(index)] == kNoLandRegion && + HasUsableBridge(terrainAt(index)); + } + + for (int index = 0; index < tileCount; ++index) { + if (!bridgeTiles[static_cast(index)]) continue; + + const int row = index / columnCount; + const int column = index % columnCount; + const auto& offsets = row % 2 == 0 ? kEvenRowNeighborOffsets : kOddRowNeighborOffsets; + const int bridgeNode = landRegions.regionCount + index; + for (const auto& [rowOffset, columnOffset] : offsets) { + const int neighborRow = row + rowOffset; + const int neighborColumn = column + columnOffset; + if (neighborRow < 0 || neighborRow >= rowCount || neighborColumn < 0 || + neighborColumn >= columnCount) { + continue; + } + + const int neighborIndex = ToIndex(neighborRow, neighborColumn, columnCount); + const LandRegionId neighborRegion = + landRegions.regions[static_cast(neighborIndex)]; + if (neighborRegion != kNoLandRegion) { + Union(bridgeNode, neighborRegion); + } else if (bridgeTiles[static_cast(neighborIndex)]) { + Union(bridgeNode, landRegions.regionCount + neighborIndex); + } + } + } + + for (int node = 0; node < nodeCount; ++node) { + parents[static_cast(node)] = static_cast(Root(node)); + } +} + +EffectiveLandRegions::EffectiveLandRegions( + const LandRegions& landRegions, + const net::eagle0::shardok::storage::fb::HexMap* map) + : landRegions(landRegions), + nodeCount(0) { + if (map == nullptr) { throw std::invalid_argument("Effective land region map is null"); } + Initialize( + map->row_count(), + map->column_count(), + [&](const int index) -> const auto& { return *map->terrain()->Get(index); }); +} + +EffectiveLandRegions::EffectiveLandRegions( + const LandRegions& landRegions, + const int rowCount, + const int columnCount, + const std::span terrain) + : landRegions(landRegions), + nodeCount(0) { + if (terrain.size() != static_cast(rowCount * columnCount)) { + throw std::invalid_argument("Effective land region terrain size does not match"); + } + Initialize( + rowCount, + columnCount, + [&](const int index) -> const auto& { return terrain[static_cast(index)]; }); +} + +auto EffectiveLandRegions::Root(const int node) const -> int { + int current = node; + while (parents[static_cast(current)] != current) { + current = parents[static_cast(current)]; + } + return current; +} + +auto EffectiveLandRegions::Root(const int node) -> int { + int root = node; + while (parents[static_cast(root)] != root) { + root = parents[static_cast(root)]; + } + + int current = node; + while (parents[static_cast(current)] != current) { + const int next = parents[static_cast(current)]; + parents[static_cast(current)] = static_cast(root); + current = next; + } + return root; +} + +void EffectiveLandRegions::Union(const int first, const int second) { + const int firstRoot = Root(first); + const int secondRoot = Root(second); + if (firstRoot != secondRoot) { + parents[static_cast(secondRoot)] = static_cast(firstRoot); + } +} + +auto EffectiveLandRegions::NodeAt(const Coords& coords) const -> int { + if (coords.row() < 0 || coords.column() < 0 || coords.row() >= landRegions.rowCount || + coords.column() >= landRegions.columnCount) { + return -1; + } + + const int index = ToIndex(coords.row(), coords.column(), landRegions.columnCount); + const LandRegionId region = landRegions.regions[static_cast(index)]; + if (region != kNoLandRegion) return region; + if (bridgeTiles[static_cast(index)]) return landRegions.regionCount + index; + return -1; +} + +auto EffectiveLandRegions::DefinitelyConnectedWithoutCreatingCrossing( + const Coords& origin, + const Coords& destination) const -> bool { + const int originNode = NodeAt(origin); + const int destinationNode = NodeAt(destination); + return originNode >= 0 && destinationNode >= 0 && Root(originNode) == Root(destinationNode); +} + } // namespace shardok diff --git a/src/main/cpp/net/eagle0/shardok/library/map/LandRegions.hpp b/src/main/cpp/net/eagle0/shardok/library/map/LandRegions.hpp index 34ec25deb5..27514a909e 100644 --- a/src/main/cpp/net/eagle0/shardok/library/map/LandRegions.hpp +++ b/src/main/cpp/net/eagle0/shardok/library/map/LandRegions.hpp @@ -5,6 +5,8 @@ #ifndef EAGLE0_SHARDOK_LIBRARY_MAP_LAND_REGIONS_HPP #define EAGLE0_SHARDOK_LIBRARY_MAP_LAND_REGIONS_HPP +#include +#include #include #include #include @@ -18,6 +20,7 @@ namespace shardok { using LandRegionId = int8_t; constexpr LandRegionId kNoLandRegion = -1; +constexpr size_t kMaximumLandRegionTileCount = size_t{12} * 14; [[nodiscard]] auto ComputeLandRegions( int rowCount, @@ -29,8 +32,11 @@ class LandRegions { private: int rowCount; int columnCount; + int regionCount; std::vector regions; + friend class EffectiveLandRegions; + public: explicit LandRegions(const net::eagle0::shardok::storage::fb::HexMap* map); @@ -50,6 +56,42 @@ public: [[nodiscard]] auto size() const -> size_t { return regions.size(); } }; +// Dynamic connectivity derived from immutable land regions plus the usable bridges in one +// speculative map state. Ice is intentionally excluded because the AI treats frozen crossings as +// crossing-strategy targets rather than ordinary land paths. +class EffectiveLandRegions { +private: + static constexpr size_t kMaximumNodeCount = kMaximumLandRegionTileCount * 2; + + const LandRegions& landRegions; + int nodeCount; + std::array parents{}; + std::array bridgeTiles{}; + + template + void Initialize(int rowCount, int columnCount, TerrainAt&& terrainAt); + + [[nodiscard]] auto Root(int node) const -> int; + auto Root(int node) -> int; + void Union(int first, int second); + [[nodiscard]] auto NodeAt(const Coords& coords) const -> int; + +public: + EffectiveLandRegions( + const LandRegions& landRegions, + const net::eagle0::shardok::storage::fb::HexMap* map); + + EffectiveLandRegions( + const LandRegions& landRegions, + int rowCount, + int columnCount, + std::span terrain); + + [[nodiscard]] auto DefinitelyConnectedWithoutCreatingCrossing( + const Coords& origin, + const Coords& destination) const -> bool; +}; + } // namespace shardok #endif // EAGLE0_SHARDOK_LIBRARY_MAP_LAND_REGIONS_HPP diff --git a/src/test/cpp/net/eagle0/shardok/ai/AIDefenderStrategySelector_test.cpp b/src/test/cpp/net/eagle0/shardok/ai/AIDefenderStrategySelector_test.cpp index 0fa19b7da0..4b5424d41d 100644 --- a/src/test/cpp/net/eagle0/shardok/ai/AIDefenderStrategySelector_test.cpp +++ b/src/test/cpp/net/eagle0/shardok/ai/AIDefenderStrategySelector_test.cpp @@ -13,18 +13,6 @@ #include "src/test/cpp/net/eagle0/shardok/library/GameStart_test_data.hpp" #include "src/test/cpp/net/eagle0/shardok/library/HexMap_test_data.hpp" -namespace { - -auto AllWaterLandRegions(const HexMap* map) -> shardok::LandRegions { - std::vector terrain(map->terrain()->size()); - for (auto& tile : terrain) { - tile.mutate_type(net::eagle0::shardok::storage::fb::Terrain_::Type_STILL_WATER); - } - return shardok::LandRegions(map->row_count(), map->column_count(), terrain); -} - -} // namespace - class AIStrategySelectorTest : public ::testing::Test { void SetUp() override { InitializeGameSettings(); @@ -194,7 +182,7 @@ TEST_F(AIStrategySelectorTest, } TEST_F(AIStrategySelectorTest, - DefenderUnder15PercentOfAttacker_enoughAttackerUnits_checksWaterCrossingAndReturnsFlee) { + DefenderUnder15PercentOfAttacker_enoughConnectedAttackerUnitsReturnsFleeWithoutPathLookup) { CoordsSet criticalTileCoords(6, 6); criticalTileCoords.AddMapIndexUnchecked(0); criticalTileCoords.AddMapIndexUnchecked(1); @@ -208,7 +196,6 @@ TEST_F(AIStrategySelectorTest, defender->mutable_battalion().mutate_size(134); - const auto disconnectedLandRegions = AllWaterLandRegions(hexMap); int battalionTypeLookups = 0; EXPECT_EQ( @@ -218,13 +205,13 @@ TEST_F(AIStrategySelectorTest, criticalTileCoords, GetGameSettings()->GetGetter().Backing().max_rounds(), apdCache, - disconnectedLandRegions, + alCache->GetLandRegions(), [&battalionTypeLookups](BattalionTypeId typeId) { ++battalionTypeLookups; return GetGameSettings()->GetGetter().GetBattalionType(typeId); }) .strategyType); - EXPECT_GT(battalionTypeLookups, 0); + EXPECT_EQ(battalionTypeLookups, 0); } TEST_F(AIStrategySelectorTest, @@ -273,7 +260,6 @@ TEST_F(AIStrategySelectorTest, defender->mutable_battalion().mutate_size(134); defender->mutate_can_flee(false); - const auto disconnectedLandRegions = AllWaterLandRegions(hexMap); int battalionTypeLookups = 0; EXPECT_EQ( @@ -283,7 +269,7 @@ TEST_F(AIStrategySelectorTest, criticalTileCoords, GetGameSettings()->GetGetter().Backing().max_rounds(), apdCache, - disconnectedLandRegions, + alCache->GetLandRegions(), [&battalionTypeLookups](BattalionTypeId typeId) { ++battalionTypeLookups; return GetGameSettings()->GetGetter().GetBattalionType(typeId); diff --git a/src/test/cpp/net/eagle0/shardok/ai/AIWaterCrossingCalculator_test.cpp b/src/test/cpp/net/eagle0/shardok/ai/AIWaterCrossingCalculator_test.cpp index 57c346a1e9..f99be39ba9 100644 --- a/src/test/cpp/net/eagle0/shardok/ai/AIWaterCrossingCalculator_test.cpp +++ b/src/test/cpp/net/eagle0/shardok/ai/AIWaterCrossingCalculator_test.cpp @@ -10,6 +10,7 @@ #include #include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp" +#include "src/main/cpp/net/eagle0/shardok/library/map/HexMapHasher.hpp" #include "src/test/cpp/net/eagle0/shardok/library/GameSettings_test_utils.hpp" #include "src/test/cpp/net/eagle0/shardok/library/GameStart_test_data.hpp" #include "src/test/cpp/net/eagle0/shardok/library/HexMap_test_data.hpp" @@ -162,11 +163,10 @@ TEST_F(AIWaterCrossingCalculatorTest, UnitsRequiringWaterCrossing_oneOtherSide_r TEST_F(AIWaterCrossingCalculatorTest, UnitsRequiringWaterCrossing_fireChanges_reusesFireClearedDistances) { - auto& bridgeModifier = MutableTerrain(hexMap, Coords(0, 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); + unit0->mutable_battalion().mutate_type( + net::eagle0::shardok::storage::fb::BattalionTypeId_UNDEAD); + unit1->mutable_battalion().mutate_type( + net::eagle0::shardok::storage::fb::BattalionTypeId_UNDEAD); CoordsSet destinations(hexMap); destinations.Add(acrossDestination); @@ -247,7 +247,7 @@ TEST_F(AIWaterCrossingCalculatorTest, } TEST_F(AIWaterCrossingCalculatorTest, - UnitsRequiringWaterCrossing_bridgeBetweenRegions_fallsBackToDistances) { + UnitsRequiringWaterCrossing_bridgeBetweenRegions_skipsDistances) { auto& bridgeModifier = MutableTerrain(hexMap, Coords(1, 3))->mutable_modifier(); bridgeModifier.mutable_bridge().mutate_present(true); bridgeModifier.mutable_bridge().mutate_integrity(100.0); @@ -267,7 +267,55 @@ TEST_F(AIWaterCrossingCalculatorTest, return GetGameSettings()->GetGetter().GetBattalionType(typeId); }) .empty()); - EXPECT_EQ(1, ActionPointDistancesCache::GetThreadLocalCacheSize()); + EXPECT_EQ(0, ActionPointDistancesCache::GetThreadLocalCacheSize()); +} + +TEST_F(AIWaterCrossingCalculatorTest, + UnitsRequiringWaterCrossing_irrelevantBridgeStillSkipsDistances) { + auto& bridgeModifier = MutableTerrain(hexMap, Coords(3, 5))->mutable_modifier(); + bridgeModifier.mutable_bridge().mutate_present(true); + bridgeModifier.mutable_bridge().mutate_integrity(100.0); + + CoordsSet destinations(hexMap); + destinations.Add(acrossDestination); + + const auto expected = std::vector{0, 1}; + EXPECT_EQ( + expected, + UnitIdsRequiringWaterCrossing( + gameState, + 0, + destinations, + alCache->GetLandRegions(), + apdCache, + [](BattalionTypeId typeId) { + return GetGameSettings()->GetGetter().GetBattalionType(typeId); + })); + EXPECT_EQ(0, ActionPointDistancesCache::GetThreadLocalCacheSize()); +} + +TEST_F(AIWaterCrossingCalculatorTest, + UnitsRequiringWaterCrossing_iceRemainsACrossingStrategyTarget) { + auto& iceModifier = MutableTerrain(hexMap, Coords(1, 3))->mutable_modifier(); + iceModifier.mutable_ice().mutate_present(true); + iceModifier.mutable_ice().mutate_integrity(100.0); + + CoordsSet destinations(hexMap); + destinations.Add(acrossDestination); + + const auto expected = std::vector{0, 1}; + EXPECT_EQ( + expected, + UnitIdsRequiringWaterCrossing( + gameState, + 0, + destinations, + alCache->GetLandRegions(), + apdCache, + [](BattalionTypeId typeId) { + return GetGameSettings()->GetGetter().GetBattalionType(typeId); + })); + EXPECT_EQ(0, ActionPointDistancesCache::GetThreadLocalCacheSize()); } TEST_F(AIWaterCrossingCalculatorTest, @@ -292,6 +340,73 @@ TEST_F(AIWaterCrossingCalculatorTest, EXPECT_EQ(1, ActionPointDistancesCache::GetThreadLocalCacheSize()); } +TEST_F(AIWaterCrossingCalculatorTest, + EffectiveRegionsMatchExactDistancesForEverySingleBridgePlacement) { + const auto terrainAt = [&](const Coords& coords) { + return hexMap->terrain()->Get(coords.row() * hexMap->column_count() + coords.column()); + }; + std::vector> bridgePlacements{std::nullopt}; + for (int row = 0; row < hexMap->row_count(); ++row) { + for (int column = 0; column < hexMap->column_count(); ++column) { + const Coords coords(row, column); + if (IsWater(terrainAt(coords)->type())) bridgePlacements.emplace_back(coords); + } + } + + for (const auto& bridgePlacement : bridgePlacements) { + for (uint32_t index = 0; index < hexMap->terrain()->size(); ++index) { + auto* terrain = const_cast( + hexMap->mutable_terrain()->GetMutableObject(index)); + terrain->mutable_modifier().mutable_bridge().mutate_present(false); + } + if (bridgePlacement.has_value()) { + auto& bridge = + MutableTerrain(hexMap, *bridgePlacement)->mutable_modifier().mutable_bridge(); + bridge.mutate_present(true); + bridge.mutate_integrity(100.0); + } + hexMap->mutate_modifier_hash(GetModifierHash(hexMap)); + + const EffectiveLandRegions effectiveRegions(alCache->GetLandRegions(), hexMap); + const auto* distances = apdCache->GetRaw( + hexMap, + ActionPointDistancesCache::GetMapId(hexMap), + heavyCavalry, + false); + + for (int originRow = 0; originRow < hexMap->row_count(); ++originRow) { + for (int originColumn = 0; originColumn < hexMap->column_count(); ++originColumn) { + const Coords originCoords(originRow, originColumn); + const auto* originTerrain = terrainAt(originCoords); + if (IsWater(originTerrain->type()) && + !originTerrain->modifier().bridge().present()) { + continue; + } + + for (int destinationRow = 0; destinationRow < hexMap->row_count(); + ++destinationRow) { + for (int destinationColumn = 0; destinationColumn < hexMap->column_count(); + ++destinationColumn) { + const Coords destinationCoords(destinationRow, destinationColumn); + const auto* destinationTerrain = terrainAt(destinationCoords); + if (IsWater(destinationTerrain->type()) && + !destinationTerrain->modifier().bridge().present()) { + continue; + } + + EXPECT_EQ( + distances->Distance(originCoords, destinationCoords) != + ActionPointDistances::IMPOSSIBLE, + effectiveRegions.DefinitelyConnectedWithoutCreatingCrossing( + originCoords, + destinationCoords)); + } + } + } + } + } +} + TEST_F(AIWaterCrossingCalculatorTest, EnoughAttackersWithoutWaterCrossing_staticRegionsShortCircuitWithoutDistances) { unit0->mutable_location() = Coords(0, 4); @@ -316,7 +431,7 @@ TEST_F(AIWaterCrossingCalculatorTest, } TEST_F(AIWaterCrossingCalculatorTest, - EnoughAttackersWithoutWaterCrossing_exactDistancesShortCircuitAtThreshold) { + EnoughAttackersWithoutWaterCrossing_bridgeShortCircuitsWithoutDistances) { auto& bridgeModifier = MutableTerrain(hexMap, Coords(1, 3))->mutable_modifier(); bridgeModifier.mutable_bridge().mutate_present(true); bridgeModifier.mutable_bridge().mutate_integrity(100.0); @@ -337,8 +452,8 @@ TEST_F(AIWaterCrossingCalculatorTest, return GetGameSettings()->GetGetter().GetBattalionType(typeId); }, 1)); - EXPECT_EQ(1, battalionTypeLookups); - EXPECT_EQ(1, ActionPointDistancesCache::GetThreadLocalCacheSize()); + EXPECT_EQ(0, battalionTypeLookups); + EXPECT_EQ(0, ActionPointDistancesCache::GetThreadLocalCacheSize()); } TEST_F(AIWaterCrossingCalculatorTest, EnoughAttackersWithoutWaterCrossing_noLandPathReturnsFalse) { @@ -354,7 +469,7 @@ TEST_F(AIWaterCrossingCalculatorTest, EnoughAttackersWithoutWaterCrossing_noLand return GetGameSettings()->GetGetter().GetBattalionType(typeId); }, 1)); - EXPECT_EQ(1, ActionPointDistancesCache::GetThreadLocalCacheSize()); + EXPECT_EQ(0, ActionPointDistancesCache::GetThreadLocalCacheSize()); } TEST_F(AIWaterCrossingCalculatorTest, diff --git a/src/test/cpp/net/eagle0/shardok/ai/RealBattleDecisionRegressionTest.cpp b/src/test/cpp/net/eagle0/shardok/ai/RealBattleDecisionRegressionTest.cpp index 3348384da1..1eb9dc040b 100644 --- a/src/test/cpp/net/eagle0/shardok/ai/RealBattleDecisionRegressionTest.cpp +++ b/src/test/cpp/net/eagle0/shardok/ai/RealBattleDecisionRegressionTest.cpp @@ -177,6 +177,30 @@ TEST_F(RealBattleDecisionRegressionTest, ChipingiaWaterCrossingFindsUsefulBridge EXPECT_FALSE(crossingTiles.empty()); } +TEST_F(RealBattleDecisionRegressionTest, AlahBridgeConnectsAttackersToEveryCastle) { + flatbuffers::FlatBufferBuilder fbb; + fbb.ForceDefaults(true); + const auto mapOffset = fb::ConvertHexMapProto(fbb, LoadMap("Alah")); + const auto* map = flatbuffers::GetTemporaryPointer(fbb, mapOffset); + const LandRegions staticRegions(map); + const EffectiveLandRegions effectiveRegions(staticRegions, map); + const CoordsSet castles = AllCastleCoords(map); + const auto* attackerPositions = map->attacker_starting_positions()->Get(0)->positions(); + + ASSERT_FALSE(attackerPositions->empty()); + ASSERT_FALSE(castles.empty()); + EXPECT_FALSE(staticRegions.DefinitelyConnectedWithoutWater( + **attackerPositions->begin(), + *castles.begin())); + for (const Coords* attackerPosition : *attackerPositions) { + for (const Coords& castle : castles) { + EXPECT_TRUE(effectiveRegions.DefinitelyConnectedWithoutCreatingCrossing( + *attackerPosition, + castle)); + } + } +} + TEST_F(RealBattleDecisionRegressionTest, RegiaAtSigkarlFirstAttackerTurnDoesNotFlee) { std::vector units{ MakeUnit( diff --git a/src/test/cpp/net/eagle0/shardok/library/map/LandRegions_test.cpp b/src/test/cpp/net/eagle0/shardok/library/map/LandRegions_test.cpp index c7e2244019..0adbc231f7 100644 --- a/src/test/cpp/net/eagle0/shardok/library/map/LandRegions_test.cpp +++ b/src/test/cpp/net/eagle0/shardok/library/map/LandRegions_test.cpp @@ -20,6 +20,11 @@ auto TerrainGrid(const int size, const TerrainType type = TerrainType::Type_PLAI return std::vector(static_cast(size), Terrain(type, TileModifier())); } +void AddBridge(Terrain& terrain, const double integrity = 100.0) { + terrain.mutable_modifier().mutable_bridge().mutate_present(true); + terrain.mutable_modifier().mutable_bridge().mutate_integrity(integrity); +} + TEST(LandRegionsTest, ConnectedLandSharesOneRegion) { const auto terrain = TerrainGrid(9); @@ -58,5 +63,71 @@ TEST(LandRegionsTest, MountainsAlsoSeparateGuaranteedLandRegions) { EXPECT_FALSE(regions.DefinitelyConnectedWithoutWater(Coords(1, 0), Coords(1, 2))); } +TEST(EffectiveLandRegionsTest, BridgeMergesStaticRegions) { + auto terrain = TerrainGrid(9); + terrain[1].mutate_type(TerrainType::Type_RIVER); + terrain[4].mutate_type(TerrainType::Type_RIVER); + terrain[7].mutate_type(TerrainType::Type_RIVER); + const LandRegions staticRegions(3, 3, terrain); + + AddBridge(terrain[4]); + const EffectiveLandRegions effectiveRegions(staticRegions, 3, 3, terrain); + + EXPECT_TRUE(effectiveRegions.DefinitelyConnectedWithoutCreatingCrossing( + Coords(1, 0), + Coords(1, 2))); + EXPECT_TRUE(effectiveRegions.DefinitelyConnectedWithoutCreatingCrossing( + Coords(1, 1), + Coords(1, 2))); +} + +TEST(EffectiveLandRegionsTest, AdjacentBridgesCanSpanWideWater) { + auto terrain = TerrainGrid(12); + for (int row = 0; row < 3; ++row) { + terrain[static_cast(row * 4 + 1)].mutate_type(TerrainType::Type_RIVER); + terrain[static_cast(row * 4 + 2)].mutate_type(TerrainType::Type_RIVER); + } + const LandRegions staticRegions(3, 4, terrain); + + AddBridge(terrain[5]); + AddBridge(terrain[6]); + const EffectiveLandRegions effectiveRegions(staticRegions, 3, 4, terrain); + + EXPECT_TRUE(effectiveRegions.DefinitelyConnectedWithoutCreatingCrossing( + Coords(1, 0), + Coords(1, 3))); +} + +TEST(EffectiveLandRegionsTest, BrokenBridgeDoesNotMergeRegions) { + auto terrain = TerrainGrid(9); + terrain[1].mutate_type(TerrainType::Type_RIVER); + terrain[4].mutate_type(TerrainType::Type_RIVER); + terrain[7].mutate_type(TerrainType::Type_RIVER); + const LandRegions staticRegions(3, 3, terrain); + + AddBridge(terrain[4], 0.0); + const EffectiveLandRegions effectiveRegions(staticRegions, 3, 3, terrain); + + EXPECT_FALSE(effectiveRegions.DefinitelyConnectedWithoutCreatingCrossing( + Coords(1, 0), + Coords(1, 2))); +} + +TEST(EffectiveLandRegionsTest, IceDoesNotMergeRegions) { + auto terrain = TerrainGrid(9); + terrain[1].mutate_type(TerrainType::Type_RIVER); + terrain[4].mutate_type(TerrainType::Type_RIVER); + terrain[7].mutate_type(TerrainType::Type_RIVER); + const LandRegions staticRegions(3, 3, terrain); + + terrain[4].mutable_modifier().mutable_ice().mutate_present(true); + terrain[4].mutable_modifier().mutable_ice().mutate_integrity(100.0); + const EffectiveLandRegions effectiveRegions(staticRegions, 3, 3, terrain); + + EXPECT_FALSE(effectiveRegions.DefinitelyConnectedWithoutCreatingCrossing( + Coords(1, 0), + Coords(1, 2))); +} + } // namespace } // namespace shardok