Version changed available command sets (#8687)

* Version changed available command sets

* Preserve active command across model refreshes
This commit is contained in:
2026-07-17 19:08:02 -07:00
committed by GitHub
parent e07cdc8123
commit 36915b8184
7 changed files with 192 additions and 56 deletions
@@ -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; }
}
}
@@ -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);
@@ -124,7 +124,8 @@ namespace eagle {
UpdateProvinceEffects(value);
eagleGameController.ProvinceWasSelected(_selectedProvinceId);
eagleGameController
.ProvinceWasSelected(_selectedProvinceId, selectRecommendedCommand: false);
}
}
@@ -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;
@@ -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,
@@ -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",
@@ -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),