Prevent remote marches from acted provinces (#8743)

* Prevent remote marches from acted provinces

* Clear vassal action flags before player commands

* Preserve leader action flags after vassal phase
This commit is contained in:
2026-07-20 21:50:46 -07:00
committed by GitHub
parent d30dd07c28
commit 54283cbeb1
6 changed files with 81 additions and 7 deletions
@@ -122,6 +122,7 @@ object AvailableMarchCommandFactory extends ScalaAvailableCommandsFactory {
): Option[AvailableCommand] =
HeroUtils
.consideredProvinces(gameState, factionId, provinceId)
.filter(p => p.id == provinceId || !p.hasActed)
.flatMap(p => oneOriginProvinceCommand(gameState = gameState, originProvince = p)) match {
case items if items.isEmpty => None
case opcs =>
@@ -574,6 +574,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_hero_trait",
@@ -4,7 +4,9 @@ import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialResultsAction
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedFactionC}
import net.eagle0.eagle.model.action_result.types.ActionResultType.EndVassalCommandsPhase
import net.eagle0.eagle.model.action_result.ActionResultT
@@ -111,6 +113,11 @@ case class EndVassalCommandsPhaseAction(
ActionResultC(
actionResultType = EndVassalCommandsPhase,
newRoundPhase = Some(RoundPhase.PlayerCommands),
changedProvinces = gs.provinces.values
.filter(_.hasActed)
.filterNot(ProvinceUtils.ruledByFactionLeader(_, gs))
.map(p => ChangedProvinceC(provinceId = p.id, setHasActed = Some(false)))
.toVector,
removedNotifications = gs.deferredNotifications.map(_.withDeferred(true)),
newNotifications = gs.deferredNotifications.map(_.withDeferred(false)),
changedFactions = staleFocusClears
@@ -704,7 +704,7 @@ class AvailableMarchCommandFactoryTest extends AnyFlatSpec with BeforeAndAfterEa
destination.requiresRiverCrossing shouldBe a[Boolean]
}
it should "leave no units behind for a same-origin support attack" in {
it should "leave no units behind and exclude the acted origin from remote marches" in {
val originProvinceId = 2
val destinationProvinceId = 5
val factionHeadProvinceId = 14
@@ -791,10 +791,10 @@ class AvailableMarchCommandFactoryTest extends AnyFlatSpec with BeforeAndAfterEa
initialMarch.marchingUnits.map(_.heroId) should contain(localLeaderId)
initialMarch.marchingUnits.flatMap(_.battalionId).toSet shouldBe attackBattalions.map(_.id).toSet
val commandForOrigin = initialAvailable.oneProvinceCommands
val commandForOrigin = initialAvailable.oneProvinceCommands
.find(_.originProvinceId == originProvinceId)
.get
val marchResult = MarchCommand
val marchResult = MarchCommand
.make(
actingFactionId = actingPlayerId,
actingProvinceId = initialAvailable.actingProvinceId,
@@ -812,22 +812,37 @@ class AvailableMarchCommandFactoryTest extends AnyFlatSpec with BeforeAndAfterEa
currentRoundId = gameState.currentRoundId
)
.immediateExecute
val stateAfterMarch = ActionResultApplierImpl(None)
val stateAfterMarch = ActionResultApplierImpl(None)
.applyActionResult(gameState, marchResult)
.resultingState
val supportAvailable = getMarchCommand(
val actedOrigin = inside(stateAfterMarch.provinces(originProvinceId)) {
case province: ProvinceC => province
}
val stateWithUnactedOrigin = stateAfterMarch.copy(
provinces = stateAfterMarch.provinces.updated(
originProvinceId,
actedOrigin.copy(hasActed = false)
)
)
val supportAvailable = getMarchCommand(
AvailableMarchCommandFactory.availableCommand(
stateAfterMarch,
stateWithUnactedOrigin,
actingPlayerId,
factionHeadProvinceId
)
)
actedOrigin.hasActed shouldBe true
supportAvailable.oneProvinceCommands.map(_.originProvinceId) should contain(originProvinceId)
AttackCommandChooser.chosenSupportAttackCommand(
actingFactionId = actingPlayerId,
gameState = stateAfterMarch,
gameState = stateWithUnactedOrigin,
acs = Vector(supportAvailable)
) shouldBe empty
AvailableMarchCommandFactory.availableCommand(
stateAfterMarch,
actingPlayerId,
factionHeadProvinceId
) shouldBe empty
}
}
@@ -282,6 +282,7 @@ scala_test(
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier_impl",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action:end_vassal_commands_phase_action",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province/concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:action_result_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_faction_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/types",
@@ -2,6 +2,7 @@ package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.SeededRandom
import net.eagle0.eagle.library.actions.applier.ActionResultApplierImpl
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedFactionC}
import net.eagle0.eagle.model.action_result.types.ActionResultType.EndVassalCommandsPhase
import net.eagle0.eagle.model.state.{GameType, RoundPhase}
@@ -64,6 +65,54 @@ class EndVassalCommandsPhaseActionTest extends AnyFlatSpec with Matchers {
}.getOrElse(fail("expected a PlayerCommands transition action result"))
"EndVassalCommandsPhaseAction" should
"clear hasActed only for vassal-ruled provinces before entering PlayerCommands" in {
val leaderId = 1
val vassalId = 2
val factionId = 3
val actedVassal = ProvinceC(
id = 5,
rulingFactionId = Some(factionId),
rulingHeroId = Some(vassalId),
rulingFactionHeroIds = Vector(vassalId),
hasActed = true
)
val unactedVassal = actedVassal.copy(id = 17, hasActed = false)
val actedLeader = ProvinceC(
id = 23,
rulingFactionId = Some(factionId),
rulingHeroId = Some(leaderId),
rulingFactionHeroIds = Vector(leaderId),
hasActed = true
)
val faction = FactionC(
id = factionId,
factionHeadId = leaderId,
name = "Faction",
leaderIds = Vector(leaderId)
)
val heroes = Map(
leaderId -> HeroC(id = leaderId, factionId = Some(factionId)),
vassalId -> HeroC(id = vassalId, factionId = Some(factionId))
)
val results = EndVassalCommandsPhaseAction(
makeGameState(
provinces = Map(5 -> actedVassal, 17 -> unactedVassal, 23 -> actedLeader),
factions = Map(factionId -> faction),
heroes = heroes
),
actionResultApplier
).randomResults(seededRandom).newValue
val transition = endVassalResult(results)
inside(transition.changedProvinces.loneElement) {
case cp: ChangedProvinceC =>
cp.provinceId shouldBe 5
cp.setHasActed should contain(false)
}
}
it should
"clear focusProvinceId when a faction's focus points to an unruled province" in {
// Faction 1 still rules its home province (5) but its focus (17) is unruled.
val hero = HeroC(id = 1, factionId = Some(1))