mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-28 22:35:42 +00:00
AI: break alliance with weakest bordering ally when boxed in (#6700)
When the midgame AI has no neutral or hostile neighbors anywhere on its border, it has no path to growth and gets stuck in the chosenNoNeutralNeighborsCommand fallback resting and improving forever. The upstream allianceOfferAcceptanceChance guard refuses alliances that would immediately box us in, but that state can still emerge later -- e.g., a shared hostile neighbor gets conquered by our ally. Add a chooser that, in that state, finds the weakest bordering ally (measured by province count, since post-truce conquest is the goal) and emits a DiplomacyAvailable BreakAlliance command targeting them. Inserted just before chosenNoNeutralNeighborsCommand so the recovery path runs ahead of the catch-all rest/improve. Truces are intentionally not treated as boxing-in: they expire, and breaking would burn trust unnecessarily -- waiting them out is cheaper. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -28,9 +28,9 @@ import net.eagle0.eagle.library.util.ProvinceDistances
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand.*
|
||||
import net.eagle0.eagle.model.state.command.available.OneProvinceAvailableCommands
|
||||
import net.eagle0.eagle.model.state.command.common.ImprovementType
|
||||
import net.eagle0.eagle.model.state.command.common.{DiplomacyOptionType, ImprovementType}
|
||||
import net.eagle0.eagle.model.state.command.selected.CombatUnit
|
||||
import net.eagle0.eagle.model.state.command.selected.SelectedCommand.{MarchSelected, ReconSelected}
|
||||
import net.eagle0.eagle.model.state.command.selected.SelectedCommand.{DiplomacySelected, MarchSelected, ReconSelected}
|
||||
import net.eagle0.eagle.model.state.date.Date.given
|
||||
import net.eagle0.eagle.model.state.faction.FactionRelationship as FactionRelationshipC
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
@@ -662,6 +662,15 @@ object MidGameAIClient {
|
||||
}
|
||||
}
|
||||
),
|
||||
(actingFactionId, gameState, availableCommands, functionalRandom) =>
|
||||
RandomState(
|
||||
maybeBreakAllianceWhenBoxedInCommand(
|
||||
actingFactionId,
|
||||
gameState,
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
),
|
||||
(fid, gs, acs, fr) => chosenNoNeutralNeighborsCommand(fid, gs, acs, fr)
|
||||
),
|
||||
functionalRandom = functionalRandom
|
||||
@@ -1006,6 +1015,72 @@ object MidGameAIClient {
|
||||
)
|
||||
}.map(_.withReason("recon a neighbor"))
|
||||
|
||||
/**
|
||||
* Break an alliance with a bordering ally when we have no expansion options anywhere on our border. Without this, the
|
||||
* AI sits forever in `chosenNoNeutralNeighborsCommand` resting/improving with no path to growth. Targets the weakest
|
||||
* bordering ally so the post-truce conquest is more likely to succeed. The upstream `allianceOfferAcceptanceChance`
|
||||
* guard prevents accepting an alliance that immediately boxes us in, but boxing-in can still emerge later (a shared
|
||||
* hostile neighbor gets conquered by the ally, etc.) — this is the recovery path.
|
||||
*/
|
||||
def maybeBreakAllianceWhenBoxedInCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
acs: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] = {
|
||||
val ourProvinces = FactionUtils.provinces(actingFactionId, gameState.provinces.values.toVector)
|
||||
val factions = gameState.factions.values.toVector
|
||||
|
||||
val haveExpansionOption = ourProvinces.exists { p =>
|
||||
FactionUtils.neutralNeighbors(p, gameState.provinces).nonEmpty ||
|
||||
FactionUtils.hostileNeighbors(p, actingFactionId, gameState.provinces, factions).nonEmpty
|
||||
}
|
||||
|
||||
MoreOption.flatWhen(!haveExpansionOption) {
|
||||
val borderingAllyFactionIds: Set[FactionId] = (for {
|
||||
ourProvince <- ourProvinces
|
||||
neighborInfo <- ourProvince.neighbors
|
||||
neighborFid <- gameState.provinces(neighborInfo.provinceId).rulingFactionId
|
||||
if neighborFid != actingFactionId
|
||||
if FactionUtils.hasAlliance(actingFactionId, neighborFid, factions)
|
||||
} yield neighborFid).toSet
|
||||
|
||||
// Weakest = fewest provinces. Power proxy chosen for stability across game phases.
|
||||
val weakestAllyFid = borderingAllyFactionIds.toVector
|
||||
.minByOption(fid => FactionUtils.provinceCount(fid, gameState.provinces.values))
|
||||
|
||||
weakestAllyFid.flatMap { allyFid =>
|
||||
flatSelectionForType[DiplomacyAvailable](acs) { da =>
|
||||
MoreOption
|
||||
.flatWhen(
|
||||
da.options.exists(opt =>
|
||||
opt.targetFactionId == allyFid &&
|
||||
opt.optionTypes.contains(DiplomacyOptionType.BreakAlliance)
|
||||
)
|
||||
) {
|
||||
val leaderIds =
|
||||
gameState.factions.get(actingFactionId).map(_.leaderIds.toSet).getOrElse(Set.empty)
|
||||
val expendableHeroes = da.availableHeroIds.filterNot(leaderIds.contains)
|
||||
expendableHeroes
|
||||
.minByOption(hid => HeroUtils.power(gameState.heroes(hid)))
|
||||
.map { heroId =>
|
||||
CommandSelection(
|
||||
actingFactionId = actingFactionId,
|
||||
actingProvinceId = da.actingProvinceId,
|
||||
available = da,
|
||||
selected = DiplomacySelected(
|
||||
selectedOption = DiplomacyOptionType.BreakAlliance,
|
||||
targetFactionId = allyFid,
|
||||
sentHeroId = heroId
|
||||
),
|
||||
reason = "break alliance with weakest ally because we have nowhere to expand"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def maybeSpreadLeadersCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
|
||||
@@ -9,16 +9,24 @@ import net.eagle0.eagle.model.state.battalion.BattalionT
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand.{
|
||||
AvailableDestinationProvince,
|
||||
DiplomacyAvailable,
|
||||
DiplomacyOption,
|
||||
ImproveAvailable,
|
||||
MarchAvailable,
|
||||
MarchCommandFromOneProvince,
|
||||
ReconAvailable
|
||||
}
|
||||
import net.eagle0.eagle.model.state.command.common.ImprovementType
|
||||
import net.eagle0.eagle.model.state.command.selected.SelectedCommand.{ImproveSelected, MarchSelected, ReconSelected}
|
||||
import net.eagle0.eagle.model.state.command.common.{DiplomacyOptionType, ImprovementType}
|
||||
import net.eagle0.eagle.model.state.command.selected.SelectedCommand.{
|
||||
DiplomacySelected,
|
||||
ImproveSelected,
|
||||
MarchSelected,
|
||||
ReconSelected
|
||||
}
|
||||
import net.eagle0.eagle.model.state.date.Date
|
||||
import net.eagle0.eagle.model.state.faction.{FactionRelationship, FactionT}
|
||||
import net.eagle0.eagle.model.state.faction.concrete.FactionC
|
||||
import net.eagle0.eagle.model.state.faction.FactionRelationship.RelationshipLevel
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.hero.{HeroT, Profession}
|
||||
import net.eagle0.eagle.model.state.hero.concrete.HeroC
|
||||
@@ -1081,4 +1089,170 @@ class MidGameAIClientTest extends AnyFlatSpec with Matchers with BeforeAndAfterE
|
||||
acs = Vector(spreadLeadersAC)
|
||||
) shouldBe empty
|
||||
}
|
||||
|
||||
// -- maybeBreakAllianceWhenBoxedInCommand --
|
||||
|
||||
// The AI faction. Owns province 100.
|
||||
private val boxedInFid: FactionId = 21
|
||||
private val boxedInLeader: HeroId = 1
|
||||
private val boxedInVassal: HeroId = 2
|
||||
// Bordering ally. We're allied; no expansion path because their border tiles are all we touch.
|
||||
private val allyFid: FactionId = 30
|
||||
// A second bordering ally, used for "weakest" selection tests.
|
||||
private val secondAllyFid: FactionId = 31
|
||||
|
||||
private val boxedInDiplomacyAC: DiplomacyAvailable = DiplomacyAvailable(
|
||||
options = Vector(
|
||||
DiplomacyOption(
|
||||
targetFactionId = allyFid,
|
||||
optionTypes = Vector(DiplomacyOptionType.BreakAlliance)
|
||||
)
|
||||
),
|
||||
availableHeroIds = Vector(boxedInLeader, boxedInVassal),
|
||||
actingProvinceId = 100,
|
||||
recommendedHeroId = boxedInLeader
|
||||
)
|
||||
|
||||
private def boxedInGameState(
|
||||
ourNeighbors: Vector[Neighbor] = Vector(neighbor(200)),
|
||||
theirRulingFid: Option[FactionId] = Some(allyFid),
|
||||
ourRelationships: Vector[FactionRelationship] = Vector(
|
||||
FactionRelationship(targetFactionId = allyFid, relationshipLevel = RelationshipLevel.Ally)
|
||||
),
|
||||
additionalProvinces: Vector[ProvinceC] = Vector.empty
|
||||
): GameState = {
|
||||
val ourProvince = ProvinceC(
|
||||
id = 100,
|
||||
rulingFactionId = Some(boxedInFid),
|
||||
rulingFactionHeroIds = Vector(boxedInLeader, boxedInVassal),
|
||||
neighbors = ourNeighbors
|
||||
)
|
||||
val theirProvince = ProvinceC(
|
||||
id = 200,
|
||||
rulingFactionId = theirRulingFid
|
||||
)
|
||||
makeGameState(
|
||||
provinces = mapifyProvinces((Vector(ourProvince, theirProvince) ++ additionalProvinces)*),
|
||||
heroes = mapifyHeroes(
|
||||
HeroC(id = boxedInLeader, factionId = Some(boxedInFid), strength = 80, agility = 80),
|
||||
HeroC(id = boxedInVassal, factionId = Some(boxedInFid), strength = 10, agility = 10)
|
||||
),
|
||||
factions = mapifyFactions(
|
||||
makeFaction(id = boxedInFid, leaderIds = Vector(boxedInLeader), factionRelationships = ourRelationships),
|
||||
makeFaction(id = allyFid),
|
||||
makeFaction(id = secondAllyFid)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
"maybeBreakAllianceWhenBoxedInCommand" should "break the alliance with a bordering ally when boxed in" in {
|
||||
val gs = boxedInGameState()
|
||||
val selected = MidGameAIClient
|
||||
.maybeBreakAllianceWhenBoxedInCommand(
|
||||
actingFactionId = boxedInFid,
|
||||
gameState = gs,
|
||||
acs = Vector(boxedInDiplomacyAC)
|
||||
)
|
||||
|
||||
inside(selected.get.selected) {
|
||||
case DiplomacySelected(option, target, sentHeroId) =>
|
||||
option shouldBe DiplomacyOptionType.BreakAlliance
|
||||
target shouldBe allyFid
|
||||
sentHeroId shouldBe boxedInVassal // skip the leader
|
||||
}
|
||||
}
|
||||
|
||||
it should "return None if we have a neutral neighbor" in {
|
||||
val gs = boxedInGameState(
|
||||
ourNeighbors = Vector(neighbor(200), neighbor(300)),
|
||||
additionalProvinces = Vector(ProvinceC(id = 300, rulingFactionId = None))
|
||||
)
|
||||
MidGameAIClient
|
||||
.maybeBreakAllianceWhenBoxedInCommand(
|
||||
actingFactionId = boxedInFid,
|
||||
gameState = gs,
|
||||
acs = Vector(boxedInDiplomacyAC)
|
||||
) shouldBe empty
|
||||
}
|
||||
|
||||
it should "return None if we have a hostile neighbor" in {
|
||||
val hostileFid: FactionId = 99
|
||||
val gs = boxedInGameState(
|
||||
ourNeighbors = Vector(neighbor(200), neighbor(400)),
|
||||
additionalProvinces = Vector(ProvinceC(id = 400, rulingFactionId = Some(hostileFid)))
|
||||
)
|
||||
MidGameAIClient
|
||||
.maybeBreakAllianceWhenBoxedInCommand(
|
||||
actingFactionId = boxedInFid,
|
||||
gameState = gs.copy(factions = gs.factions + (hostileFid -> makeFaction(id = hostileFid))),
|
||||
acs = Vector(boxedInDiplomacyAC)
|
||||
) shouldBe empty
|
||||
}
|
||||
|
||||
it should "return None if the only bordering faction is a truce partner, not an ally" in {
|
||||
// Truces expire; we should wait them out instead of burning trust by breaking. (Per design choice in PR thread.)
|
||||
val gs = boxedInGameState(
|
||||
ourRelationships = Vector(
|
||||
FactionRelationship(targetFactionId = allyFid, relationshipLevel = RelationshipLevel.Truce)
|
||||
)
|
||||
)
|
||||
MidGameAIClient
|
||||
.maybeBreakAllianceWhenBoxedInCommand(
|
||||
actingFactionId = boxedInFid,
|
||||
gameState = gs,
|
||||
acs = Vector(boxedInDiplomacyAC)
|
||||
) shouldBe empty
|
||||
}
|
||||
|
||||
it should "target the weakest bordering ally when bordering multiple" in {
|
||||
// allyFid owns one province (200). secondAllyFid owns three (210, 211, 212). Weaker = allyFid.
|
||||
val gs = boxedInGameState(
|
||||
ourNeighbors = Vector(neighbor(200), neighbor(210)),
|
||||
ourRelationships = Vector(
|
||||
FactionRelationship(targetFactionId = allyFid, relationshipLevel = RelationshipLevel.Ally),
|
||||
FactionRelationship(targetFactionId = secondAllyFid, relationshipLevel = RelationshipLevel.Ally)
|
||||
),
|
||||
additionalProvinces = Vector(
|
||||
ProvinceC(id = 210, rulingFactionId = Some(secondAllyFid)),
|
||||
ProvinceC(id = 211, rulingFactionId = Some(secondAllyFid)),
|
||||
ProvinceC(id = 212, rulingFactionId = Some(secondAllyFid))
|
||||
)
|
||||
)
|
||||
val acsWithBoth = Vector(
|
||||
boxedInDiplomacyAC.copy(
|
||||
options = Vector(
|
||||
DiplomacyOption(
|
||||
targetFactionId = allyFid,
|
||||
optionTypes = Vector(DiplomacyOptionType.BreakAlliance)
|
||||
),
|
||||
DiplomacyOption(
|
||||
targetFactionId = secondAllyFid,
|
||||
optionTypes = Vector(DiplomacyOptionType.BreakAlliance)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val selected = MidGameAIClient
|
||||
.maybeBreakAllianceWhenBoxedInCommand(
|
||||
actingFactionId = boxedInFid,
|
||||
gameState = gs,
|
||||
acs = acsWithBoth
|
||||
)
|
||||
|
||||
inside(selected.get.selected) {
|
||||
case DiplomacySelected(_, target, _) =>
|
||||
target shouldBe allyFid
|
||||
}
|
||||
}
|
||||
|
||||
it should "return None if no DiplomacyAvailable BreakAlliance option exists for the bordering ally" in {
|
||||
val gs = boxedInGameState()
|
||||
MidGameAIClient
|
||||
.maybeBreakAllianceWhenBoxedInCommand(
|
||||
actingFactionId = boxedInFid,
|
||||
gameState = gs,
|
||||
acs = Vector.empty
|
||||
) shouldBe empty
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user