Make AI attack marches more aggressive (#8738)

* Make AI attack marches more aggressive

* Test aggressive AI march sequence
This commit is contained in:
2026-07-20 11:42:38 -07:00
committed by GitHub
parent b0d1a8181b
commit e406229055
5 changed files with 193 additions and 10 deletions
@@ -2,6 +2,7 @@ package net.eagle0.eagle.library.util.command_choice_helpers
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.library.settings.{
MaxCombatUnitCountPerSide,
MinVigorForMarch,
RequiredRatioToAttack,
RequiredRatioToAttackWithoutTakingCastles
@@ -115,12 +116,10 @@ object AttackCommandChooser {
heroesWithSufficientVigorForAttack(opmc.originProvinceId, gameState)
val unitCount = Vector(
Math.max(
(heroes.length + 1) / 2,
ShardokMapInfo.shardokMapInfos(destinationProvince.id).castleCount
),
heroes.length,
originProvince.rulingFactionHeroIds.length - 1, // Don't abandon the origin province
battalions.length
battalions.length,
MaxCombatUnitCountPerSide.intValue
).min
val selectedUnits = CombatUnitSelector.selectedCombatBattalions(
@@ -72,6 +72,7 @@ scala_library(
srcs = ["AttackCommandChooser.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/actions/availability:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/util/command_choice_helpers:__pkg__",
],
exports = [
@@ -82,6 +83,7 @@ scala_library(
":combat_unit_selector",
":march_supplies_helpers",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:max_combat_unit_count_per_side",
"//src/main/scala/net/eagle0/eagle/library/settings:min_vigor_for_march",
"//src/main/scala/net/eagle0/eagle/library/settings:required_ratio_to_attack",
"//src/main/scala/net/eagle0/eagle/library/settings:required_ratio_to_attack_without_taking_castles",
@@ -1,12 +1,27 @@
package net.eagle0.eagle.library.actions.availability
import net.eagle0.eagle.library.settings.{MaxCombatUnitCountPerSide, MaxFoodTribute, MaxGoldTribute, MinVigorForMarch}
import net.eagle0.eagle.library.actions.applier.ActionResultApplierImpl
import net.eagle0.eagle.library.actions.impl.command.MarchCommand
import net.eagle0.eagle.library.settings.{
ActionVigorCost,
MarchWisdomXp,
MaxCombatUnitCountPerSide,
MaxFoodTribute,
MaxGoldTribute,
MinVigorForMarch,
RequiredRatioToAttack,
RequiredRatioToAttackWithoutTakingCastles,
TurnsToResolveConquest
}
import net.eagle0.eagle.library.util.command_choice_helpers.AttackCommandChooser
import net.eagle0.eagle.library.util.ShardokMapInfo
import net.eagle0.eagle.model.state.{GameType, RoundPhase}
import net.eagle0.eagle.model.state.{CombatUnit as ModelCombatUnit, GameType, RoundPhase}
import net.eagle0.eagle.model.state.battalion.concrete.BattalionC
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.selected.SelectedCommand.MarchSelected
import net.eagle0.eagle.model.state.date.Date
import net.eagle0.eagle.model.state.faction.concrete.FactionC
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.hero.HeroT
@@ -141,6 +156,11 @@ class AvailableMarchCommandFactoryTest extends AnyFlatSpec with BeforeAndAfterEa
MaxCombatUnitCountPerSide.setIntValue(10)
MaxGoldTribute.setIntValue(5555)
MaxFoodTribute.setIntValue(6666)
ActionVigorCost.setIntValue(15)
MarchWisdomXp.setIntValue(7)
RequiredRatioToAttack.setDoubleValue(1.5)
RequiredRatioToAttackWithoutTakingCastles.setDoubleValue(2.5)
TurnsToResolveConquest.setIntValue(2)
}
"availableCommand" should "include neighbor provinces ruled by other players as available destinations" in {
@@ -683,4 +703,131 @@ class AvailableMarchCommandFactoryTest extends AnyFlatSpec with BeforeAndAfterEa
// The requiresRiverCrossing should be a boolean value (we don't need to test the exact logic here)
destination.requiresRiverCrossing shouldBe a[Boolean]
}
it should "leave no units behind for a same-origin support attack" in {
val originProvinceId = 2
val destinationProvinceId = 5
val factionHeadProvinceId = 14
val factionHeadId = 9
val localLeaderId = 1
val enemyFactionId = 6
val enemyLeaderId = 50
val originHeroIds = (1 to 8).toVector
val attackBattalions = (101 to 107).toVector.map { id =>
BattalionC(id = id, size = 100, typeId = LightInfantry)
}
val attackHeroes = originHeroIds.map { id =>
HeroC(id = id, vigor = 100, factionId = Some(actingPlayerId))
}
val gameState = makeGameState(
provinces = Map(
originProvinceId -> ProvinceC(
id = originProvinceId,
neighbors = Vector(Neighbor(destinationProvinceId, startingPositionIndex = 7)),
rulingFactionId = Some(actingPlayerId),
rulingHeroId = Some(localLeaderId),
rulingFactionHeroIds = originHeroIds,
battalionIds = attackBattalions.map(_.id),
gold = 5000,
food = 5000
),
destinationProvinceId -> ProvinceC(
id = destinationProvinceId,
neighbors = Vector(Neighbor(originProvinceId, startingPositionIndex = 3)),
rulingFactionId = Some(enemyFactionId),
rulingHeroId = Some(enemyLeaderId),
rulingFactionHeroIds = Vector(enemyLeaderId)
),
factionHeadProvinceId -> ProvinceC(
id = factionHeadProvinceId,
rulingFactionId = Some(actingPlayerId),
rulingHeroId = Some(factionHeadId),
rulingFactionHeroIds = Vector(factionHeadId)
)
),
heroes = heroMap(
attackHeroes ++ Vector(
HeroC(id = factionHeadId, vigor = 100, factionId = Some(actingPlayerId)),
HeroC(id = enemyLeaderId, vigor = 100, factionId = Some(enemyFactionId))
)
),
battalions = battalionMap(attackBattalions),
battalionTypes = battalionTypes
).copy(
factions = Map(
actingPlayerId -> FactionC(
id = actingPlayerId,
factionHeadId = factionHeadId,
name = "Acting faction",
leaderIds = Vector(factionHeadId, localLeaderId)
),
enemyFactionId -> FactionC(
id = enemyFactionId,
factionHeadId = enemyLeaderId,
name = "Enemy faction",
leaderIds = Vector(enemyLeaderId)
)
)
)
val initialAvailable = getMarchCommand(
AvailableMarchCommandFactory.availableCommand(
gameState,
actingPlayerId,
originProvinceId
)
)
val initialSelection = AttackCommandChooser
.chosenAttackCommand(
actingFactionId = actingPlayerId,
gameState = gameState,
acs = Vector(initialAvailable),
knownEnemyHeroCount = _ => 1,
knownEnemyBattalions = _ => Vector.empty
)
.get
val initialMarch = inside(initialSelection.selected) { case march: MarchSelected => march }
initialMarch.marchingUnits should have size 7
initialMarch.marchingUnits.map(_.heroId) should contain(localLeaderId)
initialMarch.marchingUnits.flatMap(_.battalionId).toSet shouldBe attackBattalions.map(_.id).toSet
val commandForOrigin = initialAvailable.oneProvinceCommands
.find(_.originProvinceId == originProvinceId)
.get
val marchResult = MarchCommand
.make(
actingFactionId = actingPlayerId,
actingProvinceId = initialAvailable.actingProvinceId,
originProvinceId = initialMarch.originProvince,
destinationProvinceId = initialMarch.destinationProvinceId,
marchingUnits =
initialMarch.marchingUnits.map(unit => ModelCombatUnit(actingPlayerId, unit.heroId, unit.battalionId)),
gold = initialMarch.gold,
food = initialMarch.food,
availableDestinationProvinceIds = commandForOrigin.availableDestinationProvinces.map(_.provinceId),
availableHeroIds = commandForOrigin.availableHeroIds,
availableBattalionIds = commandForOrigin.availableBattalions.map(_.battalionId),
goldAvailable = commandForOrigin.goldAvailable,
foodAvailable = commandForOrigin.foodAvailable,
currentRoundId = gameState.currentRoundId
)
.immediateExecute
val stateAfterMarch = ActionResultApplierImpl(None)
.applyActionResult(gameState, marchResult)
.resultingState
val supportAvailable = getMarchCommand(
AvailableMarchCommandFactory.availableCommand(
stateAfterMarch,
actingPlayerId,
factionHeadProvinceId
)
)
supportAvailable.oneProvinceCommands.map(_.originProvinceId) should contain(originProvinceId)
AttackCommandChooser.chosenSupportAttackCommand(
actingFactionId = actingPlayerId,
gameState = stateAfterMarch,
acs = Vector(supportAvailable)
) shouldBe empty
}
}
@@ -375,19 +375,33 @@ scala_test(
srcs = ["AvailableMarchCommandFactoryTest.scala"],
deps = [
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier_impl",
"//src/main/scala/net/eagle0/eagle/library/actions/availability:available_march_command_factory",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/command:march_command",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_simple_action",
"//src/main/scala/net/eagle0/eagle/library/settings:action_vigor_cost",
"//src/main/scala/net/eagle0/eagle/library/settings:march_wisdom_xp",
"//src/main/scala/net/eagle0/eagle/library/settings:max_combat_unit_count_per_side",
"//src/main/scala/net/eagle0/eagle/library/settings:max_food_tribute",
"//src/main/scala/net/eagle0/eagle/library/settings:max_gold_tribute",
"//src/main/scala/net/eagle0/eagle/library/settings:min_vigor_for_march",
"//src/main/scala/net/eagle0/eagle/library/settings:required_ratio_to_attack",
"//src/main/scala/net/eagle0/eagle/library/settings:required_ratio_to_attack_without_taking_castles",
"//src/main/scala/net/eagle0/eagle/library/settings:turns_to_resolve_conquest",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:shardok_map_info",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:attack_command_chooser",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
"//src/main/scala/net/eagle0/eagle/model/state:combat_unit",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
"//src/main/scala/net/eagle0/eagle/model/state/battalion/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/faction/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
@@ -225,7 +225,7 @@ class AttackCommandChooserTest extends AnyFlatSpec with Matchers with BeforeAndA
behavior of "AttackCommandChooserTest"
"chosenAttackCommand" should "return an attack on a hostile neighbor if we have enough troops" in {
"chosenAttackCommand" should "take the largest army that leaves a hero behind" in {
val generatedCommand = AttackCommandChooser
.chosenAttackCommand(
actingFactionId = actingFactionId,
@@ -247,7 +247,8 @@ class AttackCommandChooserTest extends AnyFlatSpec with Matchers with BeforeAndA
destinationProvinceId = destinationProvinceId,
marchingUnits = Vector(
CombatUnit(heroId = 1, battalionId = Some(15)),
CombatUnit(heroId = 2, battalionId = Some(25))
CombatUnit(heroId = 2, battalionId = Some(25)),
CombatUnit(heroId = 3, battalionId = Some(35))
)
),
reason = "Marching to attack"
@@ -256,6 +257,25 @@ class AttackCommandChooserTest extends AnyFlatSpec with Matchers with BeforeAndA
generatedCommand shouldBe expectedCommand
}
it should "respect the maximum combat unit count" in {
MaxCombatUnitCountPerSide.setIntValue(2)
val generatedCommand = AttackCommandChooser
.chosenAttackCommand(
actingFactionId = actingFactionId,
gameState = gameState,
acs = Vector(marchAvailableCommand),
knownEnemyHeroCount = _ => 1,
knownEnemyBattalions = _ => Vector.empty
)
.get
inside(generatedCommand.selected) {
case march: MarchSelected =>
march.marchingUnits should have size 2
}
}
it should "return nothing if the destination province does not have a ruler" in {
val destinationProvince = inside(gameState.provinces(destinationProvinceId)) {
case province: ProvinceC =>
@@ -378,7 +398,8 @@ class AttackCommandChooserTest extends AnyFlatSpec with Matchers with BeforeAndA
destinationProvinceId = destinationProvinceId,
marchingUnits = Vector(
CombatUnit(heroId = 1, battalionId = Some(15)),
CombatUnit(heroId = 2, battalionId = Some(25))
CombatUnit(heroId = 2, battalionId = Some(25)),
CombatUnit(heroId = 3, battalionId = Some(35))
)
),
reason = "Marching to attack"