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:
2026-05-08 19:51:38 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 5664f15dac
commit ba160b9669
2 changed files with 253 additions and 4 deletions
@@ -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
}
}