diff --git a/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/CommandSelectors/CommandSelector.cs b/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/CommandSelectors/CommandSelector.cs index a393e9b14c..45efcc77cf 100644 --- a/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/CommandSelectors/CommandSelector.cs +++ b/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/CommandSelectors/CommandSelector.cs @@ -114,5 +114,7 @@ namespace eagle { // Note: Command tutorials are now opened explicitly instead of automatic popups. // See CommandPanelController.ShowCurrentCommandHelp() for the help-panel flow. } + + public void UpdateModel(IGameModel model) { _model = model; } } } diff --git a/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/EagleGameController.cs b/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/EagleGameController.cs index 21bdf58805..71e233ab53 100644 --- a/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/EagleGameController.cs +++ b/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/EagleGameController.cs @@ -436,10 +436,10 @@ namespace eagle { Model.AvailableCommandsByProvince.Count > 0); } - public void ProvinceWasSelected(ProvinceId? pid) { + public void ProvinceWasSelected(ProvinceId? pid, bool selectRecommendedCommand = true) { if (Model == null) { return; } if (pid.HasValue) { - SelectRecommendedCommand(pid.Value); + if (selectRecommendedCommand) { SelectRecommendedCommand(pid.Value); } var currentProvince = Model.Provinces[pid.Value]; provinceInfoPanelController.Province = currentProvince; freeHeroesTableController.CurrentProvince = currentProvince; @@ -449,7 +449,7 @@ namespace eagle { factionsTableControllerWide.CurrentProvince = currentProvince; heroesAndBattalionsPanelController.CurrentProvince = currentProvince; } else { - SelectRecommendedCommand(null); + if (selectRecommendedCommand) { SelectRecommendedCommand(null); } provinceInfoPanelController.Province = null; freeHeroesTableController.CurrentProvince = null; movingArmiesTableControllerNarrow.CurrentProvince = null; @@ -586,8 +586,7 @@ namespace eagle { // Preserve the user's selection across non-strategic refreshes. A result-bearing // strategic update must still flow through even when its command token is unchanged: - // automatic results can change visible state and command availability without - // advancing the posted-command token. + // automatic results can change visible state without changing available commands. if (hasExistingCommands && hasIncomingCommands && !commandTokenChanged && !strategicUpdateChanged) { mapController.UpdateBattleEffects(_newModel); @@ -596,8 +595,16 @@ namespace eagle { } var oldModel = Model; + var activeCommandStillAvailable = CommandStillAvailable( + _newModel, + SelectedProvince, + _commandPanelController.AvailableCommand); Model = _newModel; + if (availableCommandsChanged && !commandTokenChanged) { + LogCommandTokenInvariantViolation(oldModel, _newModel); + } + provinceInfoPanelController.Model = _newModel; freeHeroesTableController.Model = _newModel; movingArmiesTableControllerNarrow.Model = _newModel; @@ -610,6 +617,10 @@ namespace eagle { dominionPanelControllerWide.Model = _newModel; mapController.Model = _newModel; + if (activeCommandStillAvailable) { + _commandPanelController.Selector.UpdateModel(Model); + } + if (Model == null) { battleProgressPanel.gameObject.SetActive(false); return; @@ -627,49 +638,11 @@ namespace eagle { SetMusic(); - if ((oldModel == null || commandTokenChanged || availableCommandsChanged) && + if (!activeCommandStillAvailable && + (oldModel == null || commandTokenChanged || availableCommandsChanged) && Model.AvailableCommandsByProvince.Count > 0) { if (oldModel != null && oldModel.AvailableCommandsByProvince.Count > 0 && (commandTokenChanged || availableCommandsChanged)) { - string str = "We got replacement commands, but we already had commands. " + - oldModel.CommandToken + " " + _newModel.CommandToken; - - var oldProvinces = oldModel.AvailableCommandsByProvince.Keys.ToHashSet(); - var newProvinces = _newModel.AvailableCommandsByProvince.Keys.ToHashSet(); - - if (!oldProvinces.SetEquals(newProvinces)) { - var oldNotNew = oldProvinces.Except(newProvinces).ToList(); - if (oldNotNew.Any()) { - str += $"\nWe used to have commands for provinces {string.Join(", ", oldNotNew)} but now we don't"; - } - - var newNotOld = newProvinces.Except(oldProvinces).ToList(); - if (newNotOld.Any()) { - str += $"\nWe now have commands for provinces {string.Join(", ", newNotOld)} where we didn't before"; - } - } - foreach (var kv in oldModel.AvailableCommandsByProvince) { - if (_newModel.AvailableCommandsByProvince.ContainsKey(kv.Key)) { - var oldCommands = kv.Value; - var newCommands = _newModel.AvailableCommandsByProvince[kv.Key]; - if (oldCommands.Commands.Count != newCommands.Commands.Count) { - str += $"\nWe had {oldCommands.Commands.Count} but now we have {newCommands.Commands.Count}"; - } - - foreach (var cmd in oldCommands.Commands) { - if (!newCommands.Commands.Contains(cmd)) { - str += $"\nWe had {cmd} but now we don't"; - } - } - - foreach (var cmd in newCommands.Commands) { - if (!oldCommands.Commands.Contains(cmd)) { - str += $"\nWe have {cmd}, we didn't before"; - } - } - } - } - Debug.LogWarning(str); ClearActiveCommandUi(); } @@ -767,6 +740,60 @@ namespace eagle { return true; } + private static bool + CommandStillAvailable(IGameModel model, ProvinceId? provinceId, AvailableCommand command) { + return model != null && provinceId is ProvinceId pid && command != null && + model.AvailableCommandsByProvince.TryGetValue(pid, out var commands) && + commands.Commands.Contains(command); + } + + private static void LogCommandTokenInvariantViolation( + IGameModel oldModel, + IGameModel newModel) { + string str = "Available commands changed without command token advancement. Token " + + oldModel.CommandToken; + + var oldProvinces = oldModel.AvailableCommandsByProvince.Keys.ToHashSet(); + var newProvinces = newModel.AvailableCommandsByProvince.Keys.ToHashSet(); + + var oldNotNew = oldProvinces.Except(newProvinces).ToList(); + if (oldNotNew.Any()) { + str += $"\nWe used to have commands for provinces {string.Join(", ", oldNotNew)} but now we don't"; + } + + var newNotOld = newProvinces.Except(oldProvinces).ToList(); + if (newNotOld.Any()) { + str += $"\nWe now have commands for provinces {string.Join(", ", newNotOld)} where we didn't before"; + } + + foreach (var kv in oldModel.AvailableCommandsByProvince) { + if (!newModel.AvailableCommandsByProvince.TryGetValue( + kv.Key, + out var newCommands)) { + continue; + } + + var oldCommands = kv.Value; + if (oldCommands.Commands.Count != newCommands.Commands.Count) { + str += $"\nWe had {oldCommands.Commands.Count} but now we have {newCommands.Commands.Count}"; + } + + foreach (var cmd in oldCommands.Commands) { + if (!newCommands.Commands.Contains(cmd)) { + str += $"\nWe had {cmd} but now we don't"; + } + } + + foreach (var cmd in newCommands.Commands) { + if (!oldCommands.Commands.Contains(cmd)) { + str += $"\nWe have {cmd}, we didn't before"; + } + } + } + + Debug.LogError(str); + } + private void UpdateBattleProgressDisplay(IGameModel model) { if (model.ShardokBattles.Any()) { battleProgressPanel.gameObject.SetActive(true); diff --git a/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/MapController.cs b/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/MapController.cs index eddc2463fe..a8af1b47a7 100644 --- a/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/MapController.cs +++ b/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/MapController.cs @@ -124,7 +124,8 @@ namespace eagle { UpdateProvinceEffects(value); - eagleGameController.ProvinceWasSelected(_selectedProvinceId); + eagleGameController + .ProvinceWasSelected(_selectedProvinceId, selectRecommendedCommand: false); } } diff --git a/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Editor/GameModelUpdaterTests.cs b/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Editor/GameModelUpdaterTests.cs index a6255e2740..b11dc9e574 100644 --- a/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Editor/GameModelUpdaterTests.cs +++ b/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Editor/GameModelUpdaterTests.cs @@ -45,7 +45,7 @@ namespace eagle0.Tests { } [Test] - public void ResultBearingUpdateReplacesCommandsWhenTokenIsUnchanged() { + public void ResultBearingUpdateDefensivelyReplacesCommandsWhenTokenIsUnchanged() { const long gameId = 7124; const long token = 17; const int provinceId = 34; diff --git a/src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImpl.scala b/src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImpl.scala index 128c5116d5..71b07c22e8 100644 --- a/src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImpl.scala +++ b/src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImpl.scala @@ -1,11 +1,15 @@ package net.eagle0.eagle.library.actions.applier +import scala.collection.immutable.SortedMap + import net.eagle0.eagle.{FactionId, HeroId, ProvinceId} +import net.eagle0.eagle.library.actions.availability.AvailableCommandsFactory import net.eagle0.eagle.library.util.validations.Validator import net.eagle0.eagle.model.action_result.{ActionResultT, NotificationDetails, NotificationT} import net.eagle0.eagle.model.action_result.concrete.NotificationC import net.eagle0.eagle.model.action_result.generated_text_request.LlmRequestT import net.eagle0.eagle.model.state.battalion.BattalionT +import net.eagle0.eagle.model.state.command.available.OneProvinceAvailableCommands import net.eagle0.eagle.model.state.date.Date import net.eagle0.eagle.model.state.game_state.GameState import net.eagle0.eagle.model.state.hero.HeroT @@ -47,6 +51,8 @@ object ActionResultApplierImpl { class ActionResultApplierImpl(validator: Option[Validator]) extends ActionResultApplier { import ActionResultApplierImpl.{Validatable, ValidatableWithGameState, given} + private val availableCommandsFactory = new AvailableCommandsFactory() + // Generic single-argument validate def validate[T: Validatable](value: T): T = validator.fold(value)(v => summon[Validatable[T]].validate(v, value)) @@ -108,8 +114,9 @@ class ActionResultApplierImpl(validator: Option[Validator]) extends ActionResult } } - // Apply all remaining entity changes using extension methods - val finalState = stateAfterChangedBattalions + // Apply all remaining entity changes using extension methods. Command tokens are + // updated afterward so they version the derived command sets produced by this state. + val stateBeforeCommandTokenUpdate = stateAfterChangedBattalions .applyProvinceActed(result.provinceIdActed) .applyLastCommand(result.provinceId, result.lastCommandTypeForActingProvince) .applyNewBattalions(result.newBattalions, result.provinceId) @@ -126,11 +133,20 @@ class ActionResultApplierImpl(validator: Option[Validator]) extends ActionResult .applyRemovedNotifications(result.removedNotifications) .applyNewSeed(result.newRandomSeed) .applyChronicleEntry(result.newChronicleEntry) - .applyCommandCountUpdate( - Option.when(result.lastCommandTypeForActingProvince.nonEmpty)(result.actingFactionId).flatten - ) .applyNewBattalionTypes(result.newBattalionTypes) + val factionsWithChangedCommands = stateBeforeCommandTokenUpdate.factions.keySet.filter { factionId => + availableCommands(startingState, factionId) != availableCommands(stateBeforeCommandTokenUpdate, factionId) + } + val factionThatPostedCommand = + Option.when(result.lastCommandTypeForActingProvince.nonEmpty)(result.actingFactionId).flatten.toSet + val factionsWithNewCommandTokens = factionsWithChangedCommands ++ factionThatPostedCommand + + val finalState = factionsWithNewCommandTokens.toVector.sorted + .foldLeft(stateBeforeCommandTokenUpdate) { (state, factionId) => + state.applyCommandCountUpdate(Some(factionId)) + } + val enhancedResult = withHeroReadyToJoinNotifications(result, startingState, finalState) // Validate final game state @@ -142,6 +158,14 @@ class ActionResultApplierImpl(validator: Option[Validator]) extends ActionResult ) } + private def availableCommands( + gameState: GameState, + factionId: FactionId + ): SortedMap[ProvinceId, OneProvinceAvailableCommands] = + if gameState.factions.contains(factionId) then + availableCommandsFactory.availablePlayerCommands(gameState, factionId) + else SortedMap.empty + private def withHeroReadyToJoinNotifications( result: ActionResultT, startingState: GameState, diff --git a/src/main/scala/net/eagle0/eagle/library/actions/applier/BUILD.bazel b/src/main/scala/net/eagle0/eagle/library/actions/applier/BUILD.bazel index 1f642869b1..c065d3d279 100644 --- a/src/main/scala/net/eagle0/eagle/library/actions/applier/BUILD.bazel +++ b/src/main/scala/net/eagle0/eagle/library/actions/applier/BUILD.bazel @@ -198,6 +198,7 @@ scala_library( deps = [ ":action_result_applier", ":game_state_extensions", + "//src/main/scala/net/eagle0/eagle/library/actions/availability", "//src/main/scala/net/eagle0/eagle/library/util/validations:validator", "//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait", "//src/main/scala/net/eagle0/eagle/model/action_result/concrete:notification_concrete", diff --git a/src/test/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImplTest.scala b/src/test/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImplTest.scala index 4cc3e8972c..f83284c6ca 100644 --- a/src/test/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImplTest.scala +++ b/src/test/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImplTest.scala @@ -27,7 +27,7 @@ import net.eagle0.eagle.model.state.game_state.GameState import net.eagle0.eagle.model.state.hero.backstory_version.BackstoryVersion import net.eagle0.eagle.model.state.hero.concrete.HeroC import net.eagle0.eagle.model.state.province.concrete.ProvinceC -import net.eagle0.eagle.model.state.quest.AllianceQuest +import net.eagle0.eagle.model.state.quest.{AllianceQuest, DefeatFactionQuest} import net.eagle0.eagle.model.state.run_status.RunStatus import net.eagle0.eagle.model.state.shardok_battle.{BattleType, ShardokBattle} import net.eagle0.eagle.model.state.unaffiliated_hero.{RecruitmentInfo, UnaffiliatedHeroType} @@ -78,6 +78,36 @@ class ActionResultApplierImplTest extends AnyFlatSpec with Matchers with BeforeA ) ) + private def gameStateWithReturnCommand: GameState = { + val factionId = 11 + val leaderId = 12 + val provinceId = 13 + + baseGameState.copy( + provinces = Map( + provinceId -> ProvinceC( + id = provinceId, + rulingFactionId = Some(factionId), + rulingHeroId = Some(leaderId), + rulingFactionHeroIds = Vector(leaderId), + rulerIsVisitingTown = true + ) + ), + heroes = Map( + leaderId -> HeroC(id = leaderId, factionId = Some(factionId)) + ), + factions = Map( + factionId -> FactionC( + id = factionId, + factionHeadId = leaderId, + name = "Test Faction", + leaderIds = Vector(leaderId) + ) + ), + factionCommandCounts = Map(factionId -> 7) + ) + } + // ============ Basic State Updates ============ "any result" should "increment the action result count" in { @@ -90,10 +120,11 @@ class ActionResultApplierImplTest extends AnyFlatSpec with Matchers with BeforeA } it should "not advance command tokens for automatic results with an acting faction" in { - val startingState = baseGameState.copy(factionCommandCounts = Map(11 -> 7)) + val startingState = gameStateWithReturnCommand val actionResult = ActionResultC( actionResultType = ActionResultType.PrisonerMoveTookEffect, - actingFactionId = Some(11) + actingFactionId = Some(11), + newRandomSeed = Some(2L) ) val result = applier.applyActionResult(startingState, actionResult) @@ -101,8 +132,58 @@ class ActionResultApplierImplTest extends AnyFlatSpec with Matchers with BeforeA result.resultingState.factionCommandCounts shouldBe Map(11 -> 7) } + it should "advance command tokens when an automatic result changes available commands" in { + val startingState = gameStateWithReturnCommand + val actionResult = ActionResultC( + actionResultType = ActionResultType.EndPlayerCommandsPhase, + newRoundPhase = Some(RoundPhase.FoodConsumption) + ) + + val result = applier.applyActionResult(startingState, actionResult) + + result.resultingState.factionCommandCounts shouldBe Map(11 -> 8) + } + + it should "advance command tokens when automatic quest fulfillment replaces the decline command" in { + val heroWithQuest = UnaffiliatedHeroC( + heroId = 14, + unaffiliatedHeroType = UnaffiliatedHeroType.Resident, + recruitmentInfo = RecruitmentInfo.HasQuest(DefeatFactionQuest(targetFactionId = 99)), + factionBiases = Map(11 -> 200.0) + ) + val startingState = gameStateWithReturnCommand.copy( + provinces = Map( + 13 -> ProvinceC( + id = 13, + rulingFactionId = Some(11), + rulingHeroId = Some(12), + rulingFactionHeroIds = Vector(12), + rulerIsVisitingTown = true, + unaffiliatedHeroes = Vector(heroWithQuest) + ) + ), + heroes = gameStateWithReturnCommand.heroes.updated(14, HeroC(id = 14)) + ) + val actionResult = ActionResultC( + actionResultType = ActionResultType.QuestFulfilled, + actingFactionId = Some(11), + changedProvinces = Vector( + ChangedProvinceC( + provinceId = 13, + changedUnaffiliatedHeroes = Vector( + heroWithQuest.copy(recruitmentInfo = RecruitmentInfo.WouldJoin) + ) + ) + ) + ) + + val result = applier.applyActionResult(startingState, actionResult) + + result.resultingState.factionCommandCounts shouldBe Map(11 -> 8) + } + it should "advance command tokens for posted command results" in { - val startingState = baseGameState.copy(factionCommandCounts = Map(11 -> 7)) + val startingState = gameStateWithReturnCommand val actionResult = ActionResultC( actionResultType = ActionResultType.OrdersIssued, actingFactionId = Some(11),