Fix tribute co-attacker phase advancement (#8713)

This commit is contained in:
2026-07-19 08:36:59 -07:00
committed by GitHub
parent ce82c952c5
commit 5d21ee0915
5 changed files with 182 additions and 26 deletions
@@ -255,9 +255,9 @@ object AvailableAttackDecisionCommandFactory extends ScalaAvailableCommandsFacto
/**
* Sequential attack decision logic:
* - Sort undecided army groups by troop count (largest first, tiebreak by factionId)
* - If no army has advanced yet: only the largest undecided army gets the command
* - If an army has advanced: only allied undecided armies get a choice to join; hostile undecided armies are
* auto-bounced at end of phase
* - If no army has committed to attack yet: only the largest undecided army gets the command
* - If an army has advanced or demanded tribute: only allied undecided armies get a choice to join; hostile
* undecided armies are auto-bounced at end of phase
*/
private def isMyTurnToDecide(
pid: ProvinceId,
@@ -267,16 +267,16 @@ object AvailableAttackDecisionCommandFactory extends ScalaAvailableCommandsFacto
val province = gs.provinces(pid)
val hostileArmies = province.hostileArmies
val advancedArmies = hostileArmies.filter(_.status == HostileArmyGroupStatus.Attacking)
val committedArmies = hostileArmies.filter(group => HostileArmyGroupStatus.isCommittedToAttack(group.status))
val undecidedArmies = hostileArmies.filter(_.status == HostileArmyGroupStatus.AwaitingDecision)
undecidedArmies.exists(_.factionId == fid) && {
if advancedArmies.nonEmpty then {
// Someone has already advanced - only allies can join
val advancedFids = advancedArmies.map(_.factionId)
advancedFids.forall(advFid => areAllied(fid, advFid, gs))
if committedArmies.nonEmpty then {
// Someone has already committed to the attack - only allies can join
val committedFids = committedArmies.map(_.factionId)
committedFids.forall(committedFid => areAllied(fid, committedFid, gs))
} else {
// No one has advanced yet - only the largest undecided army gets the command
// No one has committed yet - only the largest undecided army gets the command
val sortedUndecided = undecidedArmies
.sortBy(g => armyGroupSortKey(g, gs))
@@ -42,26 +42,24 @@ case class EndAttackDecisionPhaseAction(
}
/**
* Auto-bounce: For occupied provinces where an army has advanced, set remaining hostile AwaitingDecision armies to
* Withdrawing.
* Auto-bounce: For occupied provinces where an army has advanced or demanded tribute, set remaining hostile
* AwaitingDecision armies to Withdrawing.
*/
private def autoBounceResults: Vector[ActionResultT] =
provinces.flatMap { p =>
val hasAttacker = p.hostileArmies.exists(_.status == HostileArmyGroupStatus.Attacking)
val isOccupied = p.rulingFactionId.nonEmpty
val undecidedFids = p.hostileArmies
val committedAttackerFids = p.hostileArmies
.filter(group => HostileArmyGroupStatus.isCommittedToAttack(group.status))
.map(_.factionId)
val isOccupied = p.rulingFactionId.nonEmpty
val undecidedFids = p.hostileArmies
.filter(_.status == HostileArmyGroupStatus.AwaitingDecision)
.map(_.factionId)
if hasAttacker && isOccupied && undecidedFids.nonEmpty then {
// Check which undecided armies are hostile to the advancing army
val attackingFids = p.hostileArmies
.filter(_.status == HostileArmyGroupStatus.Attacking)
.map(_.factionId)
if committedAttackerFids.nonEmpty && isOccupied && undecidedFids.nonEmpty then {
// Check which undecided armies are hostile to the committed attackers
val hostileUndecided = undecidedFids.filter { fid =>
!attackingFids.exists(advFid =>
fid == advFid || FactionUtils.hasAlliance(fid, advFid, factions.values.toVector)
!committedAttackerFids.forall(committedFid =>
fid == committedFid || FactionUtils.hasAlliance(fid, committedFid, factions.values.toVector)
)
}
@@ -83,8 +81,8 @@ case class EndAttackDecisionPhaseAction(
)
}
.toVector
} else if !hasAttacker && undecidedFids.size > 1 && hasNonAlliedPair(undecidedFids) then {
// No army advanced but non-allied factions remain — bounce all undecided
} else if committedAttackerFids.isEmpty && undecidedFids.size > 1 && hasNonAlliedPair(undecidedFids) then {
// No army committed but non-allied factions remain — bounce all undecided
Vector(
ActionResultC(
actionResultType = EndAttackDecisionPhase,
@@ -34,4 +34,10 @@ object HostileArmyGroupStatus {
case object Attacking extends HostileArmyGroupStatus
case object Withdrawing extends HostileArmyGroupStatus
case class SafePassageGranted(destinationProvinceId: ProvinceId) extends HostileArmyGroupStatus
def isCommittedToAttack(status: HostileArmyGroupStatus): Boolean =
status match {
case Attacking | _: TributeDemanded => true
case _ => false
}
}
@@ -6,7 +6,15 @@ import net.eagle0.eagle.library.settings.{
MinimumAgilityForStartFire,
MinimumWisdomForStartFire
}
import net.eagle0.eagle.model.state.{Army, CombatUnit, HostileArmyGroup, HostileArmyGroupStatus, MovingArmy, Supplies}
import net.eagle0.eagle.model.state.{
Army,
CombatUnit,
HostileArmyGroup,
HostileArmyGroupStatus,
MovingArmy,
Supplies,
TributeAmount
}
import net.eagle0.eagle.model.state.{GameType, RoundPhase}
import net.eagle0.eagle.model.state.battalion.concrete.BattalionC
import net.eagle0.eagle.model.state.command.available.AvailableCommand.{
@@ -636,6 +644,37 @@ class AvailableAttackDecisionCommandFactoryTest extends AnyFlatSpec with Matcher
result shouldBe empty
}
it should "block hostile army when another army has demanded tribute" in {
val hostileFid = attackerFid + defenderFid + 1
val defenderProvince = inside(baseGameState.provinces(defenderProvinceId)) { case province: ProvinceC => province }
val gs = baseGameState.copy(
provinces = baseGameState.provinces.updated(
defenderProvinceId,
defenderProvince.copy(
hostileArmies = Vector(
defenderProvince.hostileArmies.head.copy(
status = HostileArmyGroupStatus.TributeDemanded(TributeAmount(gold = 267, food = 4744))
),
HostileArmyGroup(
factionId = hostileFid,
status = HostileArmyGroupStatus.AwaitingDecision,
armies = Vector.empty
)
)
)
),
factions = baseGameState.factions + (hostileFid -> FactionC(
id = hostileFid,
factionHeadId = 9382,
name = "",
leaderIds = Vector.empty
))
)
AvailableAttackDecisionCommandFactory.availableCommand(gs, hostileFid, defenderProvinceId) shouldBe empty
}
it should "allow allied army to join when ally has advanced" in {
val alliedFid = attackerFid + defenderFid + 1
val defenderProvince = inside(baseGameState.provinces(defenderProvinceId)) { case province: ProvinceC => province }
@@ -13,7 +13,8 @@ import net.eagle0.eagle.model.state.{
HostileArmyGroupStatus,
MovingArmy,
RoundPhase,
Supplies
Supplies,
TributeAmount
}
import net.eagle0.eagle.model.state.date.Date
import net.eagle0.eagle.model.state.faction.concrete.FactionC
@@ -363,6 +364,47 @@ class EndAttackDecisionPhaseActionTest extends AnyFlatSpec with Matchers {
}
}
it should "bounce a hostile undecided army after tribute is demanded" in {
val provinces: Vector[ProvinceT] = Vector(
ProvinceC(
id = 4,
rulingFactionId = Some(31),
hostileArmies = Vector(
HostileArmyGroup(
factionId = 9,
status = HostileArmyGroupStatus.TributeDemanded(TributeAmount(gold = 267, food = 4744)),
armies = Vector.empty
),
HostileArmyGroup(
factionId = 15,
status = HostileArmyGroupStatus.AwaitingDecision,
armies = Vector.empty
)
)
)
)
val factions: Map[FactionId, FactionC] = Map(
9 -> FactionC(id = 9, factionHeadId = 3, name = "", leaderIds = Vector.empty),
15 -> FactionC(id = 15, factionHeadId = 7, name = "", leaderIds = Vector.empty)
)
val statusChanges = EndAttackDecisionPhaseAction(
gameId = gameId,
currentRoundId = currentRoundId,
currentDate = currentDate,
provinces = provinces,
factions = factions
).results
.flatMap(_.changedProvinces)
.collect { case cp: ChangedProvinceC => cp }
.flatMap(_.hostileArmyStatusChanges)
statusChanges should contain(
HostileArmyStatusChange(factionId = 15, newStatus = HostileArmyGroupStatus.Withdrawing)
)
}
it should "not bounce allied undecided armies" in {
val provinces: Vector[ProvinceT] = Vector(
ProvinceC(
@@ -454,6 +496,77 @@ class EndAttackDecisionPhaseActionTest extends AnyFlatSpec with Matchers {
bounceResults shouldBe empty
}
it should "bounce an undecided army allied with only one committed attacker" in {
val provinces: Vector[ProvinceT] = Vector(
ProvinceC(
id = 4,
rulingFactionId = Some(31),
hostileArmies = Vector(
HostileArmyGroup(factionId = 9, status = Attacking, armies = Vector.empty),
HostileArmyGroup(
factionId = 15,
status = HostileArmyGroupStatus.TributeDemanded(TributeAmount(gold = 50, food = 75)),
armies = Vector.empty
),
HostileArmyGroup(
factionId = 17,
status = HostileArmyGroupStatus.AwaitingDecision,
armies = Vector.empty
)
)
)
)
val factions: Map[FactionId, FactionC] = Map(
9 -> FactionC(
id = 9,
factionHeadId = 3,
name = "",
leaderIds = Vector.empty,
factionRelationships = Vector(
FactionRelationship(targetFactionId = 15, relationshipLevel = RelationshipLevel.Ally, resetDate = None),
FactionRelationship(targetFactionId = 17, relationshipLevel = RelationshipLevel.Ally, resetDate = None)
)
),
15 -> FactionC(
id = 15,
factionHeadId = 7,
name = "",
leaderIds = Vector.empty,
factionRelationships = Vector(
FactionRelationship(targetFactionId = 9, relationshipLevel = RelationshipLevel.Ally, resetDate = None)
)
),
17 -> FactionC(
id = 17,
factionHeadId = 8,
name = "",
leaderIds = Vector.empty,
factionRelationships = Vector(
FactionRelationship(targetFactionId = 9, relationshipLevel = RelationshipLevel.Ally, resetDate = None)
)
)
)
val results =
EndAttackDecisionPhaseAction(
gameId = gameId,
currentRoundId = currentRoundId,
currentDate = currentDate,
provinces = provinces,
factions = factions
).results
val statusChanges = results
.flatMap(_.changedProvinces)
.collect { case cp: ChangedProvinceC => cp }
.flatMap(_.hostileArmyStatusChanges)
statusChanges should contain(
HostileArmyStatusChange(factionId = 17, newStatus = HostileArmyGroupStatus.Withdrawing)
)
}
"auto-bounce with no attacker" should "bounce all non-allied undecided armies" in {
val provinces: Vector[ProvinceT] = Vector(
ProvinceC(