diff --git a/CSharpSourceCode/CampaignMechanics/CharacterCreation/SpecializationOption.cs b/CSharpSourceCode/CampaignMechanics/CharacterCreation/SpecializationOption.cs index f8a49cc..0c39219 100644 --- a/CSharpSourceCode/CampaignMechanics/CharacterCreation/SpecializationOption.cs +++ b/CSharpSourceCode/CampaignMechanics/CharacterCreation/SpecializationOption.cs @@ -93,3 +93,4 @@ public class SpecializationOption public string[] AttributesToIncrease; } } + diff --git a/CSharpSourceCode/CampaignMechanics/Crafting/PriestBehavior.cs b/CSharpSourceCode/CampaignMechanics/Crafting/PriestBehavior.cs index 969724e..f271035 100644 --- a/CSharpSourceCode/CampaignMechanics/Crafting/PriestBehavior.cs +++ b/CSharpSourceCode/CampaignMechanics/Crafting/PriestBehavior.cs @@ -203,7 +203,10 @@ bool PlayerMeetsRequirements(string cult) { if (!Hero.MainHero.HasAnyReligion()) return false; - if (Hero.MainHero.IsPriest() && Hero.MainHero.GetDominantReligion().Affinity == ReligionAffinity.Order) return true; + // A priest can access any priest of the same Pantheon + var cultReligion = ReligionObject.All.FirstOrDefault(x => x.StringId == cult); + if (Hero.MainHero.IsPriest() && cultReligion != null && + Hero.MainHero.GetDominantReligion().Pantheon == cultReligion.Pantheon) return true; if (Hero.MainHero.GetDominantReligion().StringId == cult) return true; diff --git a/CSharpSourceCode/CampaignMechanics/Diplomacy/HonorAllianceDecision.cs b/CSharpSourceCode/CampaignMechanics/Diplomacy/HonorAllianceDecision.cs new file mode 100644 index 0000000..6c5cd77 --- /dev/null +++ b/CSharpSourceCode/CampaignMechanics/Diplomacy/HonorAllianceDecision.cs @@ -0,0 +1,411 @@ +using System.Collections.Generic; +using System.Linq; +using TaleWorlds.CampaignSystem; +using TaleWorlds.CampaignSystem.Actions; +using TaleWorlds.CampaignSystem.CampaignBehaviors; +using TaleWorlds.CampaignSystem.CharacterDevelopment; +using TaleWorlds.CampaignSystem.Election; +using TaleWorlds.Core.ImageIdentifiers; +using TaleWorlds.Library; +using TaleWorlds.Localization; +using TaleWorlds.SaveSystem; +using TOR_Core.Models; + +namespace TOR_Core.CampaignMechanics.Diplomacy +{ + /// + /// Total War-style alliance decision: When an ally is attacked, you must choose to + /// either join the war or break the alliance. There is no option to refuse and keep the alliance. + /// + public class HonorAllianceDecision : KingdomDecision + { + [SaveableField(101)] + public readonly Kingdom AttackedAlly; + + [SaveableField(102)] + public readonly Kingdom Attacker; + + public HonorAllianceDecision(Clan proposerClan, Kingdom attackedAlly, Kingdom attacker) + : base(proposerClan) + { + AttackedAlly = attackedAlly; + Attacker = attacker; + } + + // Immediate decision - no waiting + protected override int HoursToWait => 0; + + public override bool IsAllowed() + { + // Check if the alliance still exists and the war is still ongoing + return Kingdom.IsAllyWith(AttackedAlly) && + AttackedAlly.IsAtWarWith(Attacker) && + !Kingdom.IsAtWarWith(Attacker) && + !AttackedAlly.IsEliminated && + !Attacker.IsEliminated; + } + + // No influence cost - this is an automatic obligation + public override int GetProposalInfluenceCost() => 0; + + public override TextObject GetGeneralTitle() + { + var text = new TextObject("{=TOR_Honor_Alliance_Title}Honor your alliance with {ALLY} against {ATTACKER}"); + text.SetTextVariable("ALLY", AttackedAlly.Name); + text.SetTextVariable("ATTACKER", Attacker.Name); + return text; + } + + public override TextObject GetSupportTitle() + { + var text = new TextObject("{=TOR_Honor_Alliance_Support}Vote on whether to honor the alliance with {ALLY} by joining the war against {ATTACKER}, or to break the alliance."); + text.SetTextVariable("ALLY", AttackedAlly.Name); + text.SetTextVariable("ATTACKER", Attacker.Name); + return text; + } + + public override TextObject GetChooseTitle() + { + var text = new TextObject("{=TOR_Honor_Alliance_Choose}Honor Alliance with {ALLY} Against {ATTACKER}"); + text.SetTextVariable("ALLY", AttackedAlly.Name); + text.SetTextVariable("ATTACKER", Attacker.Name); + return text; + } + + public override TextObject GetSupportDescription() + { + var text = new TextObject("{=TOR_Honor_Alliance_Support_Desc}{KINGDOM_LEADER} must decide: Will your realm honor its alliance with {ALLY} by joining the war against {ATTACKER}, or will the alliance be dissolved?"); + text.SetTextVariable("KINGDOM_LEADER", DetermineChooser().Leader.Name); + text.SetTextVariable("ALLY", AttackedAlly.Name); + text.SetTextVariable("ATTACKER", Attacker.Name); + return text; + } + + public override TextObject GetChooseDescription() + { + var text = new TextObject("{=TOR_Honor_Alliance_Choose_Desc}Your ally {ALLY} has been attacked by {ATTACKER}. As a true ally, you must choose: Join the war to defend your ally, or dissolve the alliance and stay out of the conflict."); + text.SetTextVariable("ALLY", AttackedAlly.Name); + text.SetTextVariable("ATTACKER", Attacker.Name); + return text; + } + + public override IEnumerable DetermineInitialCandidates() + { + // Option 1: Join the war (honor alliance) + yield return new HonorAllianceOutcome(true, Kingdom, AttackedAlly, Attacker); + // Option 2: Break the alliance (refuse to join) + yield return new HonorAllianceOutcome(false, Kingdom, AttackedAlly, Attacker); + } + + public override Clan DetermineChooser() => Kingdom.RulingClan; + + protected override bool ShouldBeCancelledInternal() + { + // Only cancel if the decision conditions are no longer valid + // Do NOT cancel based on proposer support (base class does this) + return !CanMakeDecision(out _, false); + } + + // Allow proposer clan to change opinion without cancelling the decision + // This is important for HonorAllianceDecision - even if the ruling clan + // changes their mind, the decision should still be voted on + protected override bool CanProposerClanChangeOpinion() => true; + + public override void DetermineSponsors(MBReadOnlyList possibleOutcomes) + { + foreach (var outcome in possibleOutcomes) + { + var honorOutcome = (HonorAllianceOutcome)outcome; + if (honorOutcome.ShouldJoinWar) + { + // Proposer sponsors joining the war + outcome.SetSponsor(ProposerClan); + } + else + { + AssignDefaultSponsor(outcome); + } + } + } + + public override float DetermineSupport(Clan clan, DecisionOutcome possibleOutcome) + { + var honorOutcome = (HonorAllianceOutcome)possibleOutcome; + float support = CalculateJoinWarSupport(clan); + + // Return positive support for join, negative for break (or vice versa) + return honorOutcome.ShouldJoinWar ? support : -support; + } + + /// + /// Public accessor for AI decision resolution. + /// + public float CalculateJoinWarSupportPublic(Clan clan) => CalculateJoinWarSupport(clan); + + /// + /// Calculate how much a clan supports joining the war. + /// Delegates to TORAllianceModel for trait-modified scoring. + /// + private float CalculateJoinWarSupport(Clan clan) + { + var allianceModel = Campaign.Current?.Models?.AllianceModel as TORAllianceModel; + if (allianceModel != null) + { + return allianceModel.CalculateHonorAllianceSupport(clan, Kingdom, AttackedAlly, Attacker); + } + + // Fallback if model not available + return 0f; + } + + public override void ApplyChosenOutcome(DecisionOutcome chosenOutcome) + { + var honorOutcome = (HonorAllianceOutcome)chosenOutcome; + + if (honorOutcome.ShouldJoinWar) + { + // Mark as alliance war (doesn't count toward offensive war limit) + var allianceWarBehavior = Campaign.Current.GetCampaignBehavior(); + allianceWarBehavior?.MarkAsAllianceWar(Kingdom, Attacker); + + // Declare war + //DeclareWarAction.ApplyByKingdomDecision(Kingdom, Attacker); + + DeclareWarAction.ApplyByCallToWarAgreement(Kingdom, Attacker); + + // Notify + if (Kingdom == Clan.PlayerClan?.Kingdom) + { + var message = new TextObject("{=TOR_Alliance_War_Joined}Your kingdom has joined the war against {ATTACKER} to honor your alliance with {ALLY}."); + message.SetTextVariable("ATTACKER", Attacker.Name); + message.SetTextVariable("ALLY", AttackedAlly.Name); + InformationManager.DisplayMessage(new InformationMessage(message.ToString(), Colors.Yellow)); + } + } + else + { + // Break the alliance + var allianceBehavior = Campaign.Current.GetCampaignBehavior(); + allianceBehavior?.EndAlliance(Kingdom, AttackedAlly); + + // Major relationship penalty for breaking alliance in time of need + // This is a betrayal - the ally will remember + ApplyBrokenAllianceRelationPenalty(); + + // Notify + if (Kingdom == Clan.PlayerClan?.Kingdom) + { + var message = new TextObject("{=TOR_Alliance_Broken}Your alliance with {ALLY} has been dissolved - your kingdom refused to join the war against {ATTACKER}. This betrayal will not be forgotten."); + message.SetTextVariable("ALLY", AttackedAlly.Name); + message.SetTextVariable("ATTACKER", Attacker.Name); + InformationManager.DisplayMessage(new InformationMessage(message.ToString(), Colors.Red)); + } + } + } + + /// + /// Applies relationship penalties when breaking an alliance during wartime. + /// This is considered a major betrayal. + /// + private void ApplyBrokenAllianceRelationPenalty() + { + // Major penalty between rulers (-40 is significant) + const int rulerPenalty = -40; + const int clanPenalty = -20; + + // Ruler to ruler relationship damage + if (Kingdom.Leader != null && AttackedAlly.Leader != null) + { + ChangeRelationAction.ApplyRelationChangeBetweenHeroes( + Kingdom.Leader, + AttackedAlly.Leader, + rulerPenalty, + true); + } + + // All clans in the betrayed kingdom lose respect for the betrayer's ruler + foreach (var clan in AttackedAlly.Clans) + { + if (clan.Leader != null && clan.Leader != AttackedAlly.Leader && Kingdom.Leader != null) + { + ChangeRelationAction.ApplyRelationChangeBetweenHeroes( + Kingdom.Leader, + clan.Leader, + clanPenalty, + false); // Don't show notification for each clan + } + } + + // Only honorable lords in the betrayer's kingdom disapprove of the dishonorable act + foreach (var clan in Kingdom.Clans) + { + if (clan != Kingdom.RulingClan && clan.Leader != null && Kingdom.Leader != null) + { + int honorLevel = clan.Leader.GetTraitLevel(DefaultTraits.Honor); + + // Only honorable lords (honor > 0) care about this betrayal + if (honorLevel > 0) + { + int disapproval = -10 * honorLevel; // -10 per honor level (max -30 at honor 3) + + ChangeRelationAction.ApplyRelationChangeBetweenHeroes( + Kingdom.Leader, + clan.Leader, + disapproval, + false); + } + } + } + } + + public override void ApplySecondaryEffects(MBReadOnlyList possibleOutcomes, DecisionOutcome chosenOutcome) + { + // Relation changes are handled in ApplyChosenOutcome via ApplyBrokenAllianceRelationPenalty + } + + public override TextObject GetSecondaryEffects() + { + return new TextObject("{=TOR_Honor_Alliance_Effects}Breaking the alliance may damage your realm's reputation."); + } + + public override TextObject GetChosenOutcomeText(DecisionOutcome chosenOutcome, SupportStatus supportStatus, bool isShortVersion = false) + { + var honorOutcome = (HonorAllianceOutcome)chosenOutcome; + TextObject text; + + if (honorOutcome.ShouldJoinWar) + { + text = new TextObject("{=TOR_Honor_Alliance_Joined}{KINGDOM} has honored its alliance with {ALLY} and joined the war against {ATTACKER}."); + } + else + { + text = new TextObject("{=TOR_Honor_Alliance_Dissolved}{KINGDOM} has dissolved its alliance with {ALLY} rather than join the war against {ATTACKER}."); + } + + text.SetTextVariable("KINGDOM", Kingdom.Name); + text.SetTextVariable("ALLY", AttackedAlly.Name); + text.SetTextVariable("ATTACKER", Attacker.Name); + return text; + } + + public override DecisionOutcome GetQueriedDecisionOutcome(MBReadOnlyList possibleOutcomes) + { + // Default query is for joining the war + return possibleOutcomes.FirstOrDefault(t => ((HonorAllianceOutcome)t).ShouldJoinWar); + } + + public override bool CanMakeDecision(out TextObject reason, bool includeReason = false) + { + reason = includeReason ? TextObject.GetEmpty() : null; + + if (AttackedAlly.IsEliminated || Kingdom.IsEliminated || Attacker.IsEliminated) + { + if (includeReason) + reason = new TextObject("{=TOR_Realm_Eliminated}That realm has been eliminated."); + return false; + } + + if (Kingdom.IsAtWarWith(Attacker)) + { + if (includeReason) + { + reason = new TextObject("{=TOR_Already_At_War}Your realm is already at war with {KINGDOM}."); + reason.SetTextVariable("KINGDOM", Attacker.Name); + } + return false; + } + + if (!AttackedAlly.IsAtWarWith(Attacker)) + { + if (includeReason) + { + reason = new TextObject("{=TOR_War_Ended}{ALLY} is no longer at war with {ATTACKER}."); + reason.SetTextVariable("ALLY", AttackedAlly.Name); + reason.SetTextVariable("ATTACKER", Attacker.Name); + } + return false; + } + + if (!Kingdom.IsAllyWith(AttackedAlly)) + { + if (includeReason) + { + reason = new TextObject("{=TOR_No_Alliance}Your realm is no longer allied with {ALLY}."); + reason.SetTextVariable("ALLY", AttackedAlly.Name); + } + return false; + } + + return true; + } + + /// + /// Decision outcome for honoring alliance. + /// + public class HonorAllianceOutcome : DecisionOutcome + { + [SaveableField(100)] + public readonly bool ShouldJoinWar; + + [SaveableField(101)] + public readonly Kingdom Kingdom; + + [SaveableField(102)] + public readonly Kingdom AttackedAlly; + + [SaveableField(103)] + public readonly Kingdom Attacker; + + public HonorAllianceOutcome(bool shouldJoinWar, Kingdom kingdom, Kingdom attackedAlly, Kingdom attacker) + { + ShouldJoinWar = shouldJoinWar; + Kingdom = kingdom; + AttackedAlly = attackedAlly; + Attacker = attacker; + } + + public override TextObject GetDecisionTitle() + { + if (ShouldJoinWar) + return new TextObject("{=TOR_Join_War}Join the War"); + else + return new TextObject("{=TOR_Break_Alliance}Break the Alliance"); + } + + public override TextObject GetDecisionDescription() + { + if (ShouldJoinWar) + { + var text = new TextObject("{=TOR_Join_War_Desc}Honor our alliance with {ALLY} and declare war on {ATTACKER}."); + text.SetTextVariable("ALLY", AttackedAlly.Name); + text.SetTextVariable("ATTACKER", Attacker.Name); + return text; + } + else + { + var text = new TextObject("{=TOR_Break_Alliance_Desc}Dissolve our alliance with {ALLY} rather than join this war."); + text.SetTextVariable("ALLY", AttackedAlly.Name); + return text; + } + } + + public override string GetDecisionLink() => null; + + public override ImageIdentifier GetDecisionImageIdentifier() => null; + } + } + + /// + /// Type definer for save/load of HonorAllianceDecision. + /// + public class HonorAllianceDecisionTypeDefiner : SaveableTypeDefiner + { + public HonorAllianceDecisionTypeDefiner() : base(789_124) { } + + protected override void DefineClassTypes() + { + AddClassDefinition(typeof(HonorAllianceDecision), 1); + AddClassDefinition(typeof(HonorAllianceDecision.HonorAllianceOutcome), 2); + } + } +} diff --git a/CSharpSourceCode/CampaignMechanics/Diplomacy/TORAllianceWarBehavior.cs b/CSharpSourceCode/CampaignMechanics/Diplomacy/TORAllianceWarBehavior.cs new file mode 100644 index 0000000..f44f4fb --- /dev/null +++ b/CSharpSourceCode/CampaignMechanics/Diplomacy/TORAllianceWarBehavior.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using TaleWorlds.CampaignSystem; +using TaleWorlds.CampaignSystem.Actions; +using TaleWorlds.CampaignSystem.CampaignBehaviors; +using TaleWorlds.Core; +using TaleWorlds.Library; +using TaleWorlds.Localization; +using TaleWorlds.SaveSystem; +using TOR_Core.Extensions; +using TOR_Core.Utilities; + +namespace TOR_Core.CampaignMechanics.Diplomacy +{ + /// + /// Handles Total War-style alliance behavior where allies must join wars or break the alliance. + /// Also tracks which wars are "alliance wars" (defensive) vs "offensive wars" for war limit purposes. + /// + public class TORAllianceWarBehavior : CampaignBehaviorBase + { + // Track which wars were joined due to alliance obligations (kingdom StringId -> list of enemy kingdom StringIds) + private Dictionary> _allianceWars = new(); + + public override void RegisterEvents() + { + CampaignEvents.WarDeclared.AddNonSerializedListener(this, OnWarDeclared); + CampaignEvents.MakePeace.AddNonSerializedListener(this, OnPeaceMade); + } + + public override void SyncData(IDataStore dataStore) + { + dataStore.SyncData("_allianceWars", ref _allianceWars); + } + + /// + /// Checks if a war is an alliance war (defensive) rather than an offensive war. + /// + public bool IsAllianceWar(Kingdom kingdom, Kingdom enemy) + { + if (kingdom == null || enemy == null) return false; + + if (_allianceWars.TryGetValue(kingdom.StringId, out var enemies)) + { + return enemies.Contains(enemy.StringId); + } + return false; + } + + /// + /// Gets the number of offensive wars (excluding alliance/defensive wars and Chaos wars). + /// Chaos wars don't count because they are forced/eternal and cannot be ended through diplomacy. + /// + public int GetOffensiveWarCount(Kingdom kingdom) + { + if (kingdom == null) return 0; + + int offensiveWars = 0; + + foreach (var enemy in Kingdom.All) + { + if (enemy == kingdom || !kingdom.IsAtWarWith(enemy)) continue; + + // Skip Chaos wars - they're forced and eternal, don't count towards limit + if (enemy.Culture?.StringId == TORConstants.Cultures.CHAOS) continue; + + // Skip alliance wars + if (_allianceWars.TryGetValue(kingdom.StringId, out var allianceEnemies) && + allianceEnemies.Contains(enemy.StringId)) + { + continue; + } + + offensiveWars++; + } + + return offensiveWars; + } + + private void OnWarDeclared(IFaction faction1, IFaction faction2, DeclareWarAction.DeclareWarDetail detail) + { + if (!faction1.IsKingdomFaction || !faction2.IsKingdomFaction) return; + + var attacker = (Kingdom)faction1; + var defender = (Kingdom)faction2; + + // Get all allies of the defender - they must decide to join or break alliance + var defenderAllies = defender.AlliedKingdoms.ToList(); + + foreach (var ally in defenderAllies) + { + if (ally == attacker) continue; // Shouldn't happen, but safety check + if (ally.IsAtWarWith(attacker)) continue; // Already at war + + // Create an internal kingdom decision for the ally + // Total War style: must join or break alliance + var decision = new HonorAllianceDecision(ally.RulingClan, defender, attacker); + + // For AI kingdoms, resolve immediately + // For player kingdom, add as enforced decision requiring player choice + if (ally == Clan.PlayerClan?.Kingdom) + { + // Player gets to choose - add as enforced decision + decision.IsEnforced = true; + ally.AddDecision(decision, true); // true = ignoreInfluenceCost + } + else + { + // AI resolves immediately based on scoring + ResolveAIDecision(ally, decision); + } + } + + // Also handle allies of the attacker (offensive alliance call) + // These are less obligatory - don't break alliance for refusing + var attackerAllies = attacker.AlliedKingdoms.ToList(); + foreach (var ally in attackerAllies) + { + if (ally == defender) continue; + if (ally.IsAtWarWith(defender)) continue; + + // For offensive calls, use the native Call to War system + // or simply have AI decide without breaking alliance + if (ally != Clan.PlayerClan?.Kingdom) + { + bool willJoin = ShouldAllyJoinOffensiveWar(ally, attacker, defender); + if (willJoin) + { + MarkAsAllianceWar(ally, defender); + DeclareWarAction.ApplyByKingdomDecision(ally, defender); + } + } + // Note: Not creating decision for offensive calls - less obligatory + } + } + + /// + /// Resolves the HonorAllianceDecision for AI kingdoms immediately. + /// + private void ResolveAIDecision(Kingdom kingdom, HonorAllianceDecision decision) + { + if (!decision.IsAllowed()) return; + + // Calculate total support for joining war + float joinSupport = 0f; + float breakSupport = 0f; + int clanCount = 0; + + foreach (var clan in kingdom.Clans) + { + if (clan.IsUnderMercenaryService) continue; + + float clanSupport = decision.CalculateJoinWarSupportPublic(clan); + + // Weight by clan tier/power + float weight = 1f + clan.Tier * 0.2f; + if (clan == kingdom.RulingClan) weight *= 2f; // Ruler has more say + + if (clanSupport > 0) + joinSupport += clanSupport * weight; + else + breakSupport += -clanSupport * weight; + + clanCount++; + } + + // Decide based on total support + bool shouldJoin = joinSupport >= breakSupport; + + // Apply the outcome + if (shouldJoin) + { + MarkAsAllianceWar(kingdom, decision.Attacker); + DeclareWarAction.ApplyByKingdomDecision(kingdom, decision.Attacker); + } + else + { + var allianceBehavior = Campaign.Current.GetCampaignBehavior(); + allianceBehavior?.EndAlliance(kingdom, decision.AttackedAlly); + } + } + + /// + /// Determines if an ally should join an offensive war (ally declared war). + /// Less obligatory than defensive. + /// + private bool ShouldAllyJoinOffensiveWar(Kingdom ally, Kingdom attackingAlly, Kingdom target) + { + // Player kingdom - don't auto-join offensive wars + if (ally == Clan.PlayerClan?.Kingdom) + { + return false; + } + + // Chaos factions + if (ally.Culture.StringId == TORConstants.Cultures.CHAOS) + { + return false; + } + + var allyReligion = ally.Leader?.GetDominantReligion(); + var targetReligion = target.Leader?.GetDominantReligion(); + + if (allyReligion != null && targetReligion != null) + { + if (allyReligion.HostileReligions?.Contains(targetReligion) == true) + { + return true; + } + } + + return MBRandom.RandomFloat < 0.4f; + } + + /// + /// Marks a war as an alliance war (defensive) so it doesn't count toward offensive war limits. + /// + public void MarkAsAllianceWar(Kingdom kingdom, Kingdom enemy) + { + if (!_allianceWars.ContainsKey(kingdom.StringId)) + { + _allianceWars[kingdom.StringId] = new List(); + } + + if (!_allianceWars[kingdom.StringId].Contains(enemy.StringId)) + { + _allianceWars[kingdom.StringId].Add(enemy.StringId); + } + } + + private void OnPeaceMade(IFaction faction1, IFaction faction2, MakePeaceAction.MakePeaceDetail detail) + { + if (!faction1.IsKingdomFaction || !faction2.IsKingdomFaction) return; + + var kingdom1 = (Kingdom)faction1; + var kingdom2 = (Kingdom)faction2; + + // Remove from alliance war tracking + RemoveAllianceWarTracking(kingdom1, kingdom2); + RemoveAllianceWarTracking(kingdom2, kingdom1); + } + + private void RemoveAllianceWarTracking(Kingdom kingdom, Kingdom enemy) + { + if (_allianceWars.TryGetValue(kingdom.StringId, out var enemies)) + { + enemies.Remove(enemy.StringId); + } + } + } + + /// + /// Type definer for save/load of alliance war tracking. + /// + public class TORAllianceWarBehaviorTypeDefiner : SaveableTypeDefiner + { + public TORAllianceWarBehaviorTypeDefiner() : base(789_123) { } + + protected override void DefineContainerDefinitions() + { + ConstructContainerDefinition(typeof(Dictionary>)); + ConstructContainerDefinition(typeof(List)); + } + } +} diff --git a/CSharpSourceCode/CampaignMechanics/Diplomacy/TORKingdomDecisionsCampaignBehavior.cs b/CSharpSourceCode/CampaignMechanics/Diplomacy/TORKingdomDecisionsCampaignBehavior.cs new file mode 100644 index 0000000..66cb2b7 --- /dev/null +++ b/CSharpSourceCode/CampaignMechanics/Diplomacy/TORKingdomDecisionsCampaignBehavior.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NLog; +using TaleWorlds.CampaignSystem; +using TaleWorlds.CampaignSystem.Actions; +using TaleWorlds.CampaignSystem.CampaignBehaviors; +using TaleWorlds.CampaignSystem.Election; +using TaleWorlds.Core; +using TaleWorlds.LinQuick; +using TOR_Core.CampaignMechanics.Religion; +using TOR_Core.Extensions; +using TOR_Core.Models; +using TOR_Core.Utilities; + +namespace TOR_Core.CampaignMechanics.Diplomacy +{ + public class TORKingdomDecisionsCampaignBehavior : CampaignBehaviorBase + { + // War/Peace decision settings + private List _kingdomDecisionsList = []; + private const float MinDaysBetweenDecisions = 10f; + private const float VariationDaysBetweenDecisions = 10f; //0-10 days + private const float AgreementConsiderationChance = 0.15f; //0-10 days + private Dictionary _lastDecisionTime = []; + private float _influenceReserveToKeep = 300f; + private float _outnumberRatioForEmergencyPeace = 5f; + + + public override void RegisterEvents() + { + CampaignEvents.DailyTickClanEvent.AddNonSerializedListener(this, DailyTickClan); + CampaignEvents.HourlyTickEvent.AddNonSerializedListener(this, HourlyTick); + CampaignEvents.DailyTickEvent.AddNonSerializedListener(this, DailyTick); + CampaignEvents.MakePeace.AddNonSerializedListener(this, OnPeaceMade); + CampaignEvents.WarDeclared.AddNonSerializedListener(this, OnWarDeclared); + CampaignEvents.OnSessionLaunchedEvent.AddNonSerializedListener(this, OnSessionLaunched); + CampaignEvents.KingdomCreatedEvent.AddNonSerializedListener(this, new Action(this.KingdomCreation)); + } + + /// + /// Add player created kingdom's to the _lastDecisionTime dictionary immediately. + /// + private void KingdomCreation(Kingdom kingdom) + { + if (!_lastDecisionTime.TryGetValue(kingdom.StringId, out _)) + { + _lastDecisionTime.Add(kingdom.StringId, CampaignTime.Now); + } + } + + private void OnSessionLaunched(CampaignGameStarter starter) + { + Kingdom.All.ForEach(k => _lastDecisionTime[k.StringId] = CampaignTime.Now); + } + + private void DailyTickClan(Clan clan) + { + if (clan == null || clan.IsEliminated || clan.Kingdom == null) + return; + + if (_lastDecisionTime.Keys.ContainsQ(clan.Kingdom.StringId)) + { + if (_lastDecisionTime[clan.Kingdom.StringId].ElapsedDaysUntilNow < MinDaysBetweenDecisions + MBRandom.RandomFloatRanged(0,VariationDaysBetweenDecisions)) + { + return; + } + } + + // War/Peace decisions - all eligible clans + if (!IsEligibleForDecisionMaking(clan)) return; + + // Agreement consideration - only ruling clan, separate timing + if (clan.Kingdom != Clan.PlayerClan?.Kingdom && + ShouldConsiderAgreementsToday(clan.Kingdom)) + { + if (MBRandom.RandomFloat < 0.5f) + { + ConsiderTradeAgreements(clan); + } + else + { + ConsiderAlliances(clan); + } + } + + + + if (Campaign.Current?.Models?.DiplomacyModel is not TORDiplomacyModel model) return; + + var kingdom = clan.Kingdom; + Kingdom peaceCandidate = null; + Kingdom warCandidate; + + // Emergency peace check - bypass normal cooldowns + if (!kingdom.UnresolvedDecisions.AnyQ(x => x is MakePeaceKingdomDecision) && ConsiderEmergencyPeace(kingdom)) + { + peaceCandidate = model.GetPeaceDeclarationTargetCandidate(kingdom, true); + if (peaceCandidate != null && !peaceCandidate.UnresolvedDecisions.AnyQ(x => x is MakePeaceKingdomDecision)) + { + var peaceDecision = new MakePeaceKingdomDecision(clan, peaceCandidate, MBRandom.RandomInt(1000, 3000)); + _kingdomDecisionsList.Add(peaceDecision); + clan.Kingdom.AddDecision(peaceDecision, true); + _lastDecisionTime[kingdom.StringId] = CampaignTime.Now; + } + } + else + { + if (_lastDecisionTime[clan.Kingdom.StringId].ElapsedDaysUntilNow < MinDaysBetweenDecisions) return; + + // Calculate candidates only if there isn't already another clan that has proposed a decision + KingdomDecision decision = null; + + // Check for peace + if (clan.Influence > model.GetInfluenceCostOfProposingPeace(clan) + _influenceReserveToKeep && + !kingdom.UnresolvedDecisions.AnyQ(x => x is MakePeaceKingdomDecision)) + { + peaceCandidate = model.GetPeaceDeclarationTargetCandidate(kingdom); + if (peaceCandidate != null) + { + decision = new MakePeaceKingdomDecision(clan, peaceCandidate); + } + } + + // Check for war if no peace decision + if (decision == null && + clan.Influence > model.GetInfluenceCostOfProposingWar(clan) + _influenceReserveToKeep && + !kingdom.UnresolvedDecisions.AnyQ(x => x is DeclareWarDecision)) + { + warCandidate = model.GetWarDeclarationTargetCandidate(kingdom); + if (warCandidate != null) + { + decision = new DeclareWarDecision(clan, warCandidate); + } + } + + if (decision != null) + { + _kingdomDecisionsList.Add(decision); + clan.Kingdom.AddDecision(decision, false); + _lastDecisionTime[kingdom.StringId] = CampaignTime.Now; + } + } + + UpdateKingdomDecisions(clan.Kingdom); + } + + private bool ConsiderEmergencyPeace(Kingdom kingdom) + { + if (kingdom.GetTotalEnemyAllianceStrength() > kingdom.GetAllianceTotalStrength() * _outnumberRatioForEmergencyPeace) return true; + return false; + } + + private bool IsEligibleForDecisionMaking(Clan clan) + { + return CampaignTime.Now.ToDays > 5f && + !clan.IsEliminated && + !clan.IsBanditFaction && + clan != Clan.PlayerClan && + clan.CurrentTotalStrength > 0f && + clan.Kingdom != null && + clan.Influence > 0f && + !clan.IsMinorFaction && + !clan.IsUnderMercenaryService; + } + + private void HourlyTick() + { + if (Clan.PlayerClan.Kingdom != null) + { + UpdateKingdomDecisions(Clan.PlayerClan.Kingdom); + } + } + + private void DailyTick() + { + // Clean up old decisions + if (_kingdomDecisionsList != null) + { + int count = _kingdomDecisionsList.Count; + int num = 0; + for (int i = 0; i < count; i++) + { + if (_kingdomDecisionsList[i - num].TriggerTime.ElapsedDaysUntilNow > 15f) + { + _kingdomDecisionsList.RemoveAt(i - num); + num++; + } + } + } + } + + private void OnPeaceMade(IFaction side1Faction, IFaction side2Faction, MakePeaceAction.MakePeaceDetail detail) + { + HandleDiplomaticChangeBetweenFactions(side1Faction, side2Faction); + } + + private void OnWarDeclared(IFaction side1Faction, IFaction side2Faction, DeclareWarAction.DeclareWarDetail detail) + { + HandleDiplomaticChangeBetweenFactions(side1Faction, side2Faction); + } + + private void HandleDiplomaticChangeBetweenFactions(IFaction side1Faction, IFaction side2Faction) + { + if (side1Faction.IsKingdomFaction && side2Faction.IsKingdomFaction) + { + UpdateKingdomDecisions((Kingdom)side1Faction); + UpdateKingdomDecisions((Kingdom)side2Faction); + } + } + + public void UpdateKingdomDecisions(Kingdom kingdom) + { + List cancelList = []; + List electionList = []; + foreach (KingdomDecision kingdomDecision in kingdom.UnresolvedDecisions) + { + if (kingdomDecision.ShouldBeCancelled()) + { + cancelList.Add(kingdomDecision); + } + else if (kingdomDecision.TriggerTime.IsPast && Clan.PlayerClan.IsUnderMercenaryService) + { + electionList.Add(kingdomDecision); + } + } + foreach (KingdomDecision decisionToCancel in cancelList) + { + bool isPlayerInvolved; + if (!decisionToCancel.DetermineChooser().Leader.IsHumanPlayerCharacter) + { + isPlayerInvolved = decisionToCancel.DetermineSupporters().Any((Supporter x) => x.IsPlayer); + } + else + { + isPlayerInvolved = true; + } + kingdom.RemoveDecision(decisionToCancel); + CampaignEventDispatcher.Instance.OnKingdomDecisionCancelled(decisionToCancel, isPlayerInvolved); + } + foreach (KingdomDecision decisionToVote in electionList) + { + new KingdomElection(decisionToVote).StartElectionWithoutPlayer(); + _lastDecisionTime[kingdom.StringId] = CampaignTime.Now; + } + } + + public override void SyncData(IDataStore dataStore) + { + dataStore.SyncData("_kingdomDecisionsList", ref _kingdomDecisionsList); + } + + /// + /// Updates war/peace status for all allies of a kingdom when diplomatic status changes. + /// + public static void UpdateWarPeaceForAlliance(IFaction kingdom) + { + var allKingdoms = Kingdom.All.Where(k => !k.IsEliminated); + var allAllies = kingdom.GetAlliedKingdoms().ToList(); + + foreach (var ally in allAllies) + { + foreach (var otherKingdom in allKingdoms.Where(k => k != kingdom && k != ally)) + { + if (kingdom.GetStanceWith(otherKingdom).IsAtWar != ally.GetStanceWith(otherKingdom).IsAtWar) + { + if (kingdom.IsAtWarWith(otherKingdom)) + { + ally.SetAllyTriggered(true); + DeclareWarAction.ApplyByKingdomDecision(ally, otherKingdom); + } + else + { + ally.SetAllyTriggered(true); + MakePeaceAction.ApplyByKingdomDecision(ally, otherKingdom, 0, 0); + } + } + } + } + } + + + private void ConsiderTradeAgreements(Clan consideringClan) + { + var tradeModel = Campaign.Current?.Models?.TradeAgreementModel as TORTradeAgreementModel; + if (tradeModel == null) + return; + + Kingdom kingdom = consideringClan.Kingdom; + + var potentialPartners = tradeModel.GetPotentialTradePartners(kingdom); + if (!potentialPartners.Any()) + return; + + foreach (var targetKingdom in potentialPartners) + { + float score = tradeModel.GetScoreOfStartingTradeAgreement(kingdom, targetKingdom, consideringClan, out _); + + if (MBRandom.RandomFloat * 100f < score) + { + kingdom.AddDecision(new TradeAgreementDecision(consideringClan, targetKingdom), true); + _lastDecisionTime[kingdom.StringId] = CampaignTime.Now; + return; + } + } + } + + + private void ConsiderAlliances(Clan consideringClan) + { + var allianceModel = Campaign.Current?.Models?.AllianceModel as TORAllianceModel; + if (allianceModel == null) + return; + + var kingdom = consideringClan.Kingdom; + + var potentialAllies = allianceModel.GetPotentialAlliancePartners(kingdom); + if (!potentialAllies.Any()) + return; + + var alliesByScore = new List<(Kingdom kingdom, float value)>(); + foreach (var targetKingdom in potentialAllies) + { + float score = allianceModel.GetScoreOfStartingAlliance(kingdom, targetKingdom, kingdom.RulingClan, out _).ResultNumber; + alliesByScore.Add((targetKingdom, (int)score)); + } + + var bestCandidate = alliesByScore.MaxBy(value => value.value).kingdom; + var result = alliesByScore.MaxBy(value => value.value).value; + + if (result > 50 && MBRandom.RandomFloat * 1000f < result) + { + kingdom.AddDecision(new StartAllianceDecision(kingdom.RulingClan, bestCandidate), true); + _lastDecisionTime[kingdom.StringId] = CampaignTime.Now; + } + } + + private bool ShouldConsiderAgreementsToday(Kingdom kingdom) + { + if (MBRandom.RandomFloat < AgreementConsiderationChance) return false; + + return true; + } + + + + } +} \ No newline at end of file diff --git a/CSharpSourceCode/CampaignMechanics/Diplomacy/TORTradeAgreementAIBehavior.cs b/CSharpSourceCode/CampaignMechanics/Diplomacy/TORTradeAgreementAIBehavior.cs new file mode 100644 index 0000000..7cb07d1 --- /dev/null +++ b/CSharpSourceCode/CampaignMechanics/Diplomacy/TORTradeAgreementAIBehavior.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using TaleWorlds.CampaignSystem; +using TaleWorlds.CampaignSystem.Election; +using TaleWorlds.Core; +using TOR_Core.CampaignMechanics.Religion; +using TOR_Core.Extensions; +using TOR_Core.Utilities; + +namespace TOR_Core.CampaignMechanics.Diplomacy +{ + /// + /// AI behavior for proactively proposing trade agreements. + /// Vanilla Bannerlord has no AI logic for this - this adds it. + /// + public class TORTradeAgreementAIBehavior : CampaignBehaviorBase + { + // How often AI considers trade agreements (in days) + private const int ConsiderationIntervalMinDays = 5; + private const int ConsiderationIntervalMaxDays = 10; + + public override void RegisterEvents() + { + CampaignEvents.DailyTickClanEvent.AddNonSerializedListener(this, OnDailyTickClan); + } + + public override void SyncData(IDataStore dataStore) + { + // No persistent data needed for now + } + + /// + /// Daily tick for each clan - ruling clans trigger kingdom-wide trade considerations. + /// + private void OnDailyTickClan(Clan clan) + { + if (clan == null || clan.IsEliminated || clan.Kingdom == null) + return; + + // Only ruling clan triggers the kingdom's trade considerations + if (clan != clan.Kingdom.RulingClan) + return; + + // Skip player kingdom - let player decide + if (clan.Kingdom == Clan.PlayerClan?.Kingdom) + return; + + // Each kingdom has its own consideration interval based on its ID + // This spreads out trade considerations across different days + if (!ShouldConsiderTradeToday(clan.Kingdom)) + return; + + ConsiderTradeAgreements(clan.Kingdom); + } + + /// + /// Main entry point for a kingdom considering trade agreements. + /// Selects lords to evaluate and processes their recommendations. + /// + private void ConsiderTradeAgreements(Kingdom kingdom) + { + if (!CanKingdomConsiderTrade(kingdom)) + return; + + var potentialPartners = GetPotentialTradePartners(kingdom); + if (!potentialPartners.Any()) + return; + + var lordsConsidering = GetLordsConsideringTrade(kingdom); + + foreach (var targetKingdom in potentialPartners) + { + foreach (var lord in lordsConsidering) + { + float chance = CalculateTradeProposalChance(lord, kingdom, targetKingdom); + + if (MBRandom.RandomFloat * 100f < chance) + { + ProposeTradeAgreement(kingdom, targetKingdom, lord); + return; // Only one proposal per tick + } + } + } + } + + /// + /// Checks if a kingdom is allowed to consider trade agreements at all. + /// Handles lore restrictions (Chaos, Greenskins, etc.) + /// + private bool CanKingdomConsiderTrade(Kingdom kingdom) + { + var pantheon = GetKingdomPantheon(kingdom); + + // Chaos never trades + if (pantheon == Pantheon.Chaos) + return false; + + // Greenskins never trade + if (pantheon == Pantheon.Greenskin) + return false; + + // All other factions (including Undead) can trade + return true; + } + + /// + /// Gets list of potential trade partners for a kingdom. + /// Takes 5 closest kingdoms, filters valid targets, scores them, returns top 3. + /// + private List GetPotentialTradePartners(Kingdom kingdom) + { + var tradeModel = Campaign.Current?.Models?.TradeAgreementModel; + if (tradeModel == null) + return new List(); + + // Get all kingdoms with distance + var kingdomsWithDistance = Kingdom.All + .Where(k => k != kingdom && !k.IsEliminated) + .Select(k => new { Kingdom = k, Distance = GetKingdomDistance(kingdom, k) }) + .OrderBy(x => x.Distance) + .Take(5) // Take 5 closest + .Select(x => x.Kingdom) + .ToList(); + + // Filter by lore and game rules + var validPartners = kingdomsWithDistance + .Where(k => IsTradeLoreCompatible(GetKingdomPantheon(kingdom), GetKingdomPantheon(k))) + .Where(k => tradeModel.CanMakeTradeAgreement(kingdom, k, true, out _)) + .ToList(); + + if (!validPartners.Any()) + return new List(); + + // Score each valid partner and take top 3 + var scoredPartners = validPartners + .Select(k => new + { + Kingdom = k, + Score = tradeModel.GetScoreOfStartingTradeAgreement(kingdom, k, kingdom.RulingClan, out _) + }) + .Where(x => x.Score > 0) + .OrderByDescending(x => x.Score) + .Take(3) + .Select(x => x.Kingdom) + .ToList(); + + return scoredPartners; + } + + /// + /// Gets the lords who will consider trade this tick. + /// Always includes faction leader, plus 1-2 random influential clan leaders. + /// + private List GetLordsConsideringTrade(Kingdom kingdom) + { + var lords = new List(); + + // Always include the faction leader + if (kingdom.Leader != null && kingdom.Leader.IsAlive) + { + lords.Add(kingdom.Leader); + } + + // Add 1-2 random clan leaders + var otherClanLeaders = kingdom.Clans + .Where(c => c != kingdom.RulingClan && !c.IsUnderMercenaryService && c.Leader?.IsAlive == true) + .Select(c => c.Leader) + .ToList(); + + int additionalLords = Math.Min(MBRandom.RandomInt(1, 3), otherClanLeaders.Count); + + for (int i = 0; i < additionalLords; i++) + { + var randomLord = otherClanLeaders.GetRandomElement(); + if (randomLord != null && !lords.Contains(randomLord)) + { + lords.Add(randomLord); + otherClanLeaders.Remove(randomLord); + } + } + + return lords; + } + + /// + /// Calculates the chance (0-100) that a lord will propose a trade agreement. + /// Uses the model's score which already includes culture, religion, and pantheon factors. + /// + private float CalculateTradeProposalChance(Hero lord, Kingdom proposingKingdom, Kingdom targetKingdom) + { + var tradeModel = Campaign.Current?.Models?.TradeAgreementModel; + if (tradeModel == null) + return 0f; + + // Use the model's score directly - it already includes all the factors + float modelScore = tradeModel.GetScoreOfStartingTradeAgreement( + proposingKingdom, targetKingdom, lord.Clan, out _); + + // Model score is 0-100, use it as our chance + return modelScore; + } + + /// + /// Actually proposes the trade agreement by creating a kingdom decision. + /// + private void ProposeTradeAgreement(Kingdom proposer, Kingdom target, Hero proposingLord) + { + if (proposingLord?.Clan == null) + return; + + // Create the trade agreement decision + var decision = new TradeAgreementDecision(proposingLord.Clan, target); + + // Add to kingdom's unresolved decisions for council vote + proposer.AddDecision(decision, true); + } + + #region Helper Methods + + /// + /// Determines if a kingdom should consider trade today based on its unique interval. + /// Each kingdom has a different interval (5-10 days) and offset, spreading out considerations. + /// + private bool ShouldConsiderTradeToday(Kingdom kingdom) + { + // Use hash of kingdom ID for consistent but varied timing per kingdom + int hash = kingdom.StringId?.GetHashCode() ?? 0; + if (hash < 0) hash = -hash; + + // Each kingdom gets an interval between min and max days + int intervalRange = ConsiderationIntervalMaxDays - ConsiderationIntervalMinDays + 1; + int kingdomInterval = ConsiderationIntervalMinDays + (hash % intervalRange); + + // Each kingdom also gets a unique offset within its interval + int kingdomOffset = (hash / intervalRange) % kingdomInterval; + + int currentDay = (int)CampaignTime.Now.ToDays; + return (currentDay + kingdomOffset) % kingdomInterval == 0; + } + + /// + /// Gets the approximate distance between two kingdoms based on their mid settlements. + /// + private float GetKingdomDistance(Kingdom kingdom1, Kingdom kingdom2) + { + if (kingdom1.FactionMidSettlement == null || kingdom2.FactionMidSettlement == null) + return float.MaxValue; + + return kingdom1.FactionMidSettlement.Position.Distance(kingdom2.FactionMidSettlement.Position); + } + + /// + /// Gets the dominant pantheon for a kingdom based on its leader's religion or culture. + /// + private Pantheon GetKingdomPantheon(Kingdom kingdom) + { + var leaderReligion = kingdom.Leader?.GetDominantReligion(); + if (leaderReligion != null) + return leaderReligion.Pantheon; + + return ReligionObjectHelper.GetPantheon(kingdom.Culture?.StringId); + } + + /// + /// Checks if trade is allowed between two pantheons based on lore. + /// + private bool IsTradeLoreCompatible(Pantheon pantheon1, Pantheon pantheon2) + { + // Chaos never trades + if (pantheon1 == Pantheon.Chaos || pantheon2 == Pantheon.Chaos) + return false; + + // Greenskins never trade + if (pantheon1 == Pantheon.Greenskin || pantheon2 == Pantheon.Greenskin) + return false; + + // All other factions (including Undead) can trade with each other + return true; + } + + #endregion + } +} \ No newline at end of file diff --git a/CSharpSourceCode/CampaignMechanics/Religion/ReligionCampaignBehavior.cs b/CSharpSourceCode/CampaignMechanics/Religion/ReligionCampaignBehavior.cs index a94cbce..e18a0d9 100644 --- a/CSharpSourceCode/CampaignMechanics/Religion/ReligionCampaignBehavior.cs +++ b/CSharpSourceCode/CampaignMechanics/Religion/ReligionCampaignBehavior.cs @@ -23,6 +23,12 @@ namespace TOR_Core.CampaignMechanics.Religion { public class ReligionCampaignBehavior : CampaignBehaviorBase, IDisposable { + // Initial relation adjustment weights for religion compatibility + private const int SameReligionBonusMin = 25; + private const int SameReligionBonusMax = 50; + private const int CompatiblePantheonBonusMax = 25; + private const int HostilePantheonMalusMax = 75; + public override void RegisterEvents() { CampaignEvents.OnNewGameCreatedPartialFollowUpEvent.AddNonSerializedListener(this, AfterNewGameStart); @@ -312,28 +318,23 @@ private void ChangeRelationBasedOnReligion(Hero hero, Hero otherHero) if (heroDomReligion == otherHeroDomReligion) { - var bonus = MBRandom.RandomInt(25, 50); + var bonus = MBRandom.RandomInt(SameReligionBonusMin, SameReligionBonusMax); hero.SetPersonalRelation(otherHero, currentRelation + bonus); return; } - if (heroDomReligion.Affinity == otherHeroDomReligion.Affinity) - {//this causes chaos religions to have a bonus towards each other despite being on each other's hostile religions list - shouldn't matter until chaos is implemented more fully - var bonus = MBRandom.RandomInt(0, 25); + + // Use Pantheon compatibility for relation adjustments + float compatibility = ReligionObjectHelper.GetPantheonCompatibility(heroDomReligion.Pantheon, otherHeroDomReligion.Pantheon); + if (compatibility > 0) + { + var bonus = MBRandom.RandomInt(0, (int)(CompatiblePantheonBonusMax * compatibility)); hero.SetPersonalRelation(otherHero, currentRelation + bonus); - return; } - else //this is the default for all heroes who have different religions and different affinities (order, chaos, or nagash) + else if (compatibility < 0) { - var malus = MBRandom.RandomInt(25, 75); + var malus = MBRandom.RandomInt(0, (int)(HostilePantheonMalusMax * -compatibility)); hero.SetPersonalRelation(otherHero, currentRelation - malus); } - //this doesn't make sense in the context of how affinities and hostile religions are grouped - all religion pairs where affinities differ contain one another and could instead be covered by a more efficient affinity1 != affinity2 check except for chaos cults which have already received a relation bonus because of identical affinities - //if (heroDomReligion.HostileReligions.Contains(otherHeroDomReligion)) - //{ - // var malus = MBRandom.RandomInt(25, 75); - // hero.SetPersonalRelation(otherHero, currentRelation - malus); - // return; - //} } } } \ No newline at end of file diff --git a/CSharpSourceCode/CampaignMechanics/Religion/ReligionObject.cs b/CSharpSourceCode/CampaignMechanics/Religion/ReligionObject.cs index d590b32..ff5ea9d 100644 --- a/CSharpSourceCode/CampaignMechanics/Religion/ReligionObject.cs +++ b/CSharpSourceCode/CampaignMechanics/Religion/ReligionObject.cs @@ -29,7 +29,7 @@ public class ReligionObject : MBObjectBase public List EliteUnits { get; private set; } = []; public List ReligiousArtifacts { get; private set; } = []; public List InitialClans { get; private set; } = []; - public ReligionAffinity Affinity { get; private set; } + public Pantheon Pantheon { get; private set; } public static MBReadOnlyList All => _all ?? []; public static void FillAll() => _all = MBObjectManager.Instance.GetObjectTypeList(); @@ -41,17 +41,16 @@ public class ReligionObject : MBObjectBase public TextObject EncyclopediaLinkWithName => HyperlinkTexts.GetSettlementHyperlinkText(EncyclopediaLink, Name); /// - /// Gets similarity score between this and another religion + /// Gets the hostility factor between this religion and another. + /// Returns a negative value if the other religion is in the HostileReligions list. + /// This is meant to be added on top of Pantheon compatibility calculations. /// - /// The other to calculare similarity with - /// Similarity score between -1 (hostile) and 1 (same culture, same religion) - public float GetSimilarityScore(ReligionObject other) + /// The other religion to check hostility against + /// -0.5 if hostile, 0 otherwise + public float GetHostilityFactor(ReligionObject other) { - if (HostileReligions.Contains(other) && Culture != other.Culture) return -1f; - else if (HostileReligions.Contains(other) && Culture == other.Culture) return -0.75f; - else if (!HostileReligions.Contains(other) && Culture != other.Culture) return 0.25f; - else if (!HostileReligions.Contains(other) && Culture == other.Culture) return 1f; - else return 0f; + if (other == null) return 0f; + return HostileReligions.Contains(other) ? -0.5f : 0f; } public override void Deserialize(MBObjectManager objectManager, XmlNode node) @@ -60,7 +59,7 @@ public override void Deserialize(MBObjectManager objectManager, XmlNode node) Name = new TextObject(node.Attributes.GetNamedItem("Name").Value); DeityName = new TextObject(node.Attributes.GetNamedItem("DeityName").Value); Culture = MBObjectManager.Instance.ReadObjectReferenceFromXml("Culture", node); - Affinity = (ReligionAffinity)Enum.Parse(typeof(ReligionAffinity), node.Attributes.GetNamedItem("Affinity").Value); + Pantheon = (Pantheon)Enum.Parse(typeof(Pantheon), node.Attributes.GetNamedItem("Pantheon").Value); LoreText = GameTexts.FindText("tor_religion_description", StringId); if (GameTexts.TryGetText("tor_religion_blessing_name", out var blessingName, this.StringId)) @@ -140,11 +139,23 @@ public enum DevotionLevel Fanatic } - public enum ReligionAffinity + /// + /// Represents the broad pantheon/faction grouping for diplomatic compatibility. + /// Used to determine cultural and religious alignment between factions. + /// + public enum Pantheon { - Order, - Chaos, - Vampire, - Destruction + /// Human gods: Sigmar, Ulric, Morr, Taal, Lady of the Lake, etc. + Human, + /// Elven gods: Isha, Kurnous, Lileath, Khaine, etc. + Elven, + /// Dwarven Ancestor Gods: Grungni, Valaya, Grimnir + Dwarven, + /// Undead worship: Nagash, dark necromancy + Undead, + /// Greenskin gods: Gork and Mork + Greenskin, + /// Chaos gods: Khorne, Nurgle, Tzeentch, Slaanesh + Chaos } } \ No newline at end of file diff --git a/CSharpSourceCode/CampaignMechanics/Religion/ReligionObjectHelper.cs b/CSharpSourceCode/CampaignMechanics/Religion/ReligionObjectHelper.cs index cf54e6b..8301e44 100644 --- a/CSharpSourceCode/CampaignMechanics/Religion/ReligionObjectHelper.cs +++ b/CSharpSourceCode/CampaignMechanics/Religion/ReligionObjectHelper.cs @@ -1,10 +1,128 @@ -namespace TOR_Core.CampaignMechanics.Religion; +using TaleWorlds.CampaignSystem; +using TaleWorlds.TwoDimension; +using TOR_Core.Utilities; -public static class ReligionObjectHelper +namespace TOR_Core.CampaignMechanics.Religion { - public static float CalculateSimilarityScore(ReligionObject x, ReligionObject y) + /// + /// Helper class for religion and pantheon-based compatibility calculations. + /// Returns scores from -1.0 (bitter enemies) to +1.0 (same religion/strong allies). + /// + public static class ReligionObjectHelper { - if (x == null || y == null) return 0; - return x.GetSimilarityScore(y); + /// + /// Calculates the overall compatibility score between two religions. + /// Combines: same religion check, Pantheon compatibility, and specific hostility factors. + /// + /// Score from -1.0 (enemies) to +1.0 (same religion) + public static float CalculateReligionCompatibility(ReligionObject x, ReligionObject y) + { + if (x == null || y == null) return 0f; + + // Same religion = perfect compatibility + if (x == y) return 1.0f; + + // Base compatibility from Pantheon + float compatibility = GetPantheonCompatibility(x.Pantheon, y.Pantheon); + + // Add hostility factor if religions are specifically hostile to each other + compatibility += x.GetHostilityFactor(y); + + // Clamp to valid range + return Mathf.Clamp(compatibility, -1.0f, 1.0f); + } + + /// + /// Gets the Pantheon for a culture string ID. + /// + public static Pantheon GetPantheon(string cultureId) + { + if (string.IsNullOrEmpty(cultureId)) + return Pantheon.Human; + + return cultureId switch + { + TORConstants.Cultures.EMPIRE => Pantheon.Human, + TORConstants.Cultures.BRETONNIA => Pantheon.Human, + TORConstants.Cultures.ASRAI => Pantheon.Elven, + TORConstants.Cultures.EONIR => Pantheon.Elven, + TORConstants.Cultures.DRUCHII => Pantheon.Elven, + TORConstants.Cultures.DAWI => Pantheon.Dwarven, + TORConstants.Cultures.SYLVANIA => Pantheon.Undead, + TORConstants.Cultures.MOUSILLON => Pantheon.Undead, + TORConstants.Cultures.GREENSKIN => Pantheon.Greenskin, + TORConstants.Cultures.CHAOS => Pantheon.Chaos, + TORConstants.Cultures.BEASTMEN => Pantheon.Chaos, + _ => Pantheon.Human + }; + } + + /// + /// Gets the compatibility score between two Pantheons. + /// + public static float GetPantheonCompatibility(Pantheon p1, Pantheon p2) + { + if (p1 == p2) + { + return p1 switch + { + Pantheon.Human => 0.5f, + Pantheon.Elven => 0.5f, + Pantheon.Dwarven => 1f, + Pantheon.Undead => 0.3f, + Pantheon.Greenskin => 0.1f, + Pantheon.Chaos => 0.1f, + _ => 0.5f + }; + } + + // Normalize ordering for consistent lookups + var (first, second) = p1 < p2 ? (p1, p2) : (p2, p1); + + return (first, second) switch + { + // Order vs Chaos - eternal enemies + (Pantheon.Human, Pantheon.Chaos) => -1.0f, + (Pantheon.Elven, Pantheon.Chaos) => -1.0f, + (Pantheon.Dwarven, Pantheon.Chaos) => -1.0f, + + // Order vs Greenskin + (Pantheon.Human, Pantheon.Greenskin) => -0.8f, + (Pantheon.Elven, Pantheon.Greenskin) => -0.8f, + (Pantheon.Dwarven, Pantheon.Greenskin) => -1.0f, + + // Order vs Undead + (Pantheon.Human, Pantheon.Undead) => -0.8f, + (Pantheon.Elven, Pantheon.Undead) => -0.8f, + (Pantheon.Dwarven, Pantheon.Undead) => -0.8f, + + // Destruction forces + (Pantheon.Chaos, Pantheon.Greenskin) => -0.4f, + (Pantheon.Chaos, Pantheon.Undead) => -0.8f, + (Pantheon.Greenskin, Pantheon.Undead) => -0.4f, + + // Order factions + (Pantheon.Human, Pantheon.Elven) => 0.1f, + (Pantheon.Human, Pantheon.Dwarven) => 0.4f, + (Pantheon.Dwarven, Pantheon.Elven) => -0.1f, + + _ => 0f + }; + } + + /// + /// Calculates the cultural compatibility score between two cultures. + /// Returns a value from -1.0 (bitter enemies) to +1.0 (same culture/strong allies). + /// + public static float CalculateCultureCompatibility(string culture1Id, string culture2Id) + { + if (string.IsNullOrEmpty(culture1Id) || string.IsNullOrEmpty(culture2Id)) + return 0f; + + if (culture1Id == culture2Id) + return 1.0f; + + return GetPantheonCompatibility(GetPantheon(culture1Id), GetPantheon(culture2Id)); + } } -} \ No newline at end of file +} diff --git a/CSharpSourceCode/CampaignMechanics/TORCustomSettlement/CustomSettlementMenus/ShrineMenuLogic.cs b/CSharpSourceCode/CampaignMechanics/TORCustomSettlement/CustomSettlementMenus/ShrineMenuLogic.cs index 82d3519..b65fc24 100644 --- a/CSharpSourceCode/CampaignMechanics/TORCustomSettlement/CustomSettlementMenus/ShrineMenuLogic.cs +++ b/CSharpSourceCode/CampaignMechanics/TORCustomSettlement/CustomSettlementMenus/ShrineMenuLogic.cs @@ -248,9 +248,10 @@ private bool DonationCondition(MenuCallbackArgs args) return true; } - // Only Order affinity + Greenskins (Destruction) can donate + // Undead, Chaos, and Greenskin pantheon cannot donate var playerReligion = Hero.MainHero.GetDominantReligion(); - if (playerReligion != null && playerReligion.Affinity != ReligionAffinity.Order && playerReligion.Affinity != ReligionAffinity.Destruction) + if (playerReligion != null && (playerReligion.Pantheon == Pantheon.Undead || + playerReligion.Pantheon == Pantheon.Chaos || playerReligion.Pantheon == Pantheon.Greenskin)) { args.Tooltip = TORTextHelper.GetTextObject("tor_custom_settlement_shrine_no_donate_affinity", "You do not honor the gods through offerings."); args.IsEnabled = false; @@ -486,7 +487,7 @@ private void DefileResultConsequence() } } - if (shrineReligion.Affinity == dominantReligion.Affinity) hero.SetPersonalRelation(Hero.MainHero, (int)relation - 10); + if (shrineReligion.Pantheon == dominantReligion.Pantheon) hero.SetPersonalRelation(Hero.MainHero, (int)relation - 10); } Campaign.Current.GetCampaignBehavior().SetLastDefileTime(Hero.MainHero, (int)CampaignTime.Now.ToDays); } @@ -550,37 +551,21 @@ private void LootResultConsequence() } } - if (shrineReligion.Affinity == dominantReligion.Affinity) hero.SetPersonalRelation(Hero.MainHero, (int)relation - 10); + if (shrineReligion.Pantheon == dominantReligion.Pantheon) hero.SetPersonalRelation(Hero.MainHero, (int)relation - 10); } Campaign.Current.GetCampaignBehavior().SetLastDefileTime(Hero.MainHero, (int)CampaignTime.Now.ToDays); } /// - /// Checks if a hero's culture is compatible with a shrine's religion culture for praying. + /// Checks if a hero's culture is compatible with a shrine's religion for praying. + /// Uses Pantheon matching - Undead cultures (Mousillon/Sylvania) won't match Human shrines. /// private static bool IsCultureCompatibleWithShrine(Hero hero, ReligionObject religion) { - if (hero == null || religion == null || religion.Culture == null) return false; + if (hero == null || religion == null) return false; - // Direct culture match - if (hero.Culture == religion.Culture) return true; - - var heroCultureId = hero.Culture.StringId; - var religionCultureId = religion.Culture.StringId; - - // Special case: Wood Elves (battania) and High Elves (eonir) can pray at each other's shrines - bool heroIsElf = heroCultureId == "battania" || heroCultureId == "eonir"; - bool shrineIsElf = religionCultureId == "battania" || religionCultureId == "eonir"; - if (heroIsElf && shrineIsElf) return true; - - // Special case: Human cultures (Empire, Bretonnia) share the same pantheon - // Note: Mousillon and Sylvania are NOT included - they cannot pray at human shrines - bool heroIsHuman = heroCultureId == "empire" || heroCultureId == "vlandia"; - bool shrineIsHuman = religionCultureId == "empire" || religionCultureId == "vlandia"; - if (heroIsHuman && shrineIsHuman) return true; - - return false; + return ReligionObjectHelper.GetPantheon(hero.Culture?.StringId) == religion.Pantheon; } public static bool CanPartyGoToShrine(MobileParty party) diff --git a/CSharpSourceCode/CampaignMechanics/TORFactionDiscontinuationCampaignBehavior.cs b/CSharpSourceCode/CampaignMechanics/TORFactionDiscontinuationCampaignBehavior.cs index 2860601..9880ce8 100644 --- a/CSharpSourceCode/CampaignMechanics/TORFactionDiscontinuationCampaignBehavior.cs +++ b/CSharpSourceCode/CampaignMechanics/TORFactionDiscontinuationCampaignBehavior.cs @@ -7,6 +7,7 @@ using TaleWorlds.Core; using TaleWorlds.LinQuick; using TOR_Core.Extensions; +using TOR_Core.Utilities; namespace TOR_Core.CampaignMechanics { @@ -69,7 +70,7 @@ private void DailyTickClan(Clan clan) } if (MBRandom.RandomFloat > 0.7f) { - var candidateKingdoms = Kingdom.All.WhereQ(x => !x.IsEliminated && x.Culture == clan.Culture); + var candidateKingdoms = GetCandidateKingdomsForJoiningClan(clan); if (candidateKingdoms != null && candidateKingdoms.Count() > 0) { var targetKingdom = candidateKingdoms.MinBy(x => x.CurrentTotalStrength); @@ -84,6 +85,36 @@ private void DailyTickClan(Clan clan) } } + /// + /// Gets candidate kingdoms for a destroyed clan to join. + /// Includes special faction pairings for closely-related factions. + /// + private IEnumerable GetCandidateKingdomsForJoiningClan(Clan clan) + { + var originalKingdomId = clan.StringId?.Split('_')[0]; // Extract faction from clan ID (e.g., "laurelorn" from "laurelorn_clan_1") + + // Special faction pairings - these factions should merge with each other + var specialPairings = new Dictionary + { + { TORConstants.Factions.LAURELORN, TORConstants.Factions.ATHEL_LOREN }, + { TORConstants.Factions.ATHEL_LOREN, TORConstants.Factions.LAURELORN }, + { TORConstants.Factions.MOUSILLON, TORConstants.Factions.SYLVANIA }, + { TORConstants.Factions.SYLVANIA, TORConstants.Factions.MOUSILLON } + }; + + if (specialPairings.TryGetValue(originalKingdomId, out string pairedFactionId)) + { + var pairedKingdom = Kingdom.All.FirstOrDefault(k => !k.IsEliminated && k.StringId == pairedFactionId); + if (pairedKingdom != null) + { + // Prioritize the paired faction - return only it + return [pairedKingdom]; + } + } + + return Kingdom.All.WhereQ(x => !x.IsEliminated && x.Culture == clan.Culture); + } + private bool CanKingdomBeDiscontinued(Kingdom kingdom) { bool flag = !kingdom.IsEliminated && kingdom != Clan.PlayerClan.Kingdom && kingdom.Settlements.IsEmpty(); diff --git a/CSharpSourceCode/CampaignMechanics/TORStartupBehavior.cs b/CSharpSourceCode/CampaignMechanics/TORStartupBehavior.cs index e819a78..218dfe1 100644 --- a/CSharpSourceCode/CampaignMechanics/TORStartupBehavior.cs +++ b/CSharpSourceCode/CampaignMechanics/TORStartupBehavior.cs @@ -6,6 +6,7 @@ using TaleWorlds.Core; using TOR_Core.CampaignMechanics.Diplomacy; using TOR_Core.Extensions; +using TOR_Core.Utilities; namespace TOR_Core.CampaignMechanics { @@ -13,7 +14,7 @@ public class TORStartupBehavior : CampaignBehaviorBase { public override void RegisterEvents() { - //CampaignEvents.OnNewGameCreatedEvent.AddNonSerializedListener(this, OnNewGameCreated); + CampaignEvents.OnNewGameCreatedEvent.AddNonSerializedListener(this, OnNewGameCreated); CampaignEvents.OnNewGameCreatedPartialFollowUpEvent.AddNonSerializedListener(this, new Action(this.SpawnAiHeroParties)); } @@ -22,13 +23,13 @@ public override void RegisterEvents() /// /// /// The game already calls this through the OnNewGameCreatedPartialFollowUp event in HeroSpawnCampaignBehavior with the OnNonBanditClanDailyTick method call, but evidently that's occuring too soon as tor's nobles aren't spawning with parties. - /// + /// /// LordPartyComponent.InitializeLordPartyProperties will check if GameStarted != true (ie. during the campaign initialization), then it will pass through a larger value to InitializeMobilePartyAroundPosition(PartyTemplate, ...) which in turn passes it into FillPartyStacks /// SpawnLordParty after Game start will grant the minimum between 19 troops + leader or 10% of their max party size - /// + /// /// Sly : This could be at risk of causing a crash due to mobParty data caching not being fast enough to complete before the method will attempt to spawn parties; no issues so far, but I ran into a crash with another mod when attempting to run a similar method on the CharacterCreationOverEvent where the parallel methods for data caching were too slow compared to the time it took to exit char creation. /// - /// + /// //Sly : this is likely resolved by clans/kingdoms having an initial home settlement now private void SpawnAiHeroParties(CampaignGameStarter starter, int i) @@ -49,16 +50,34 @@ public override void SyncData(IDataStore dataStore) { } - /* hunharibo: Need to redo alliances as base game has a complete overhaul in 1.3 + /// + /// Sets up initial alliances when a new game is created. + /// private void OnNewGameCreated(CampaignGameStarter starter) { - var moot = Kingdom.All.Find(m => m.StringId == "moot"); - var stirland = Kingdom.All.Find(s => s.StringId == "stirland"); - moot.SetAlliance(stirland); + // Set up initial alliances between friendly kingdoms + SetupInitialAlliances(); + } + + /// + /// Creates initial alliances between kingdoms that should start the game allied. + /// + private void SetupInitialAlliances() + { + // Moot and Stirland alliance (Dwarves and Empire) + var moot = Kingdom.All.Find(m => m.StringId == TORConstants.Factions.MOOT); + var stirland = Kingdom.All.Find(s => s.StringId == TORConstants.Factions.STIRLAND); + if (moot != null && stirland != null) + { + moot.SetAlliance(stirland); + TORKingdomDecisionsCampaignBehavior.UpdateWarPeaceForAlliance(stirland); + stirland.SetAllyTriggered(false); + } - TORKingdomDecisionsCampaignBehavior.UpdateWarPeaceForAlliance(stirland); - stirland.SetAllyTriggered(false); + // Additional alliances can be added here as needed + // Example: Empire provinces that should be allied + // var reikland = Kingdom.All.Find(k => k.StringId == "reikland"); + // var middenland = Kingdom.All.Find(k => k.StringId == "middenland"); } - */ } } \ No newline at end of file diff --git a/CSharpSourceCode/CharacterDevelopment/CareerSystem/Choices/BloodKnightCareerChoices.cs b/CSharpSourceCode/CharacterDevelopment/CareerSystem/Choices/BloodKnightCareerChoices.cs index ba2fc5a..778c057 100644 --- a/CSharpSourceCode/CharacterDevelopment/CareerSystem/Choices/BloodKnightCareerChoices.cs +++ b/CSharpSourceCode/CharacterDevelopment/CareerSystem/Choices/BloodKnightCareerChoices.cs @@ -425,7 +425,8 @@ public override void InitialCareerSetup() Hero.MainHero.Culture = sylvaniaCulture; } - var religions = ReligionObject.All.FindAll(x => x.Affinity == ReligionAffinity.Order); + // Become hostile to Human pantheon religions (your former faith) + var religions = ReligionObject.All.FindAll(x => x.Pantheon == Pantheon.Human); foreach (var religion in religions) { Hero.MainHero.AddReligiousInfluence(religion, -100, true); diff --git a/CSharpSourceCode/CharacterDevelopment/CareerSystem/Choices/NecrarchCareerChoices.cs b/CSharpSourceCode/CharacterDevelopment/CareerSystem/Choices/NecrarchCareerChoices.cs index 23990dc..7e392a2 100644 --- a/CSharpSourceCode/CharacterDevelopment/CareerSystem/Choices/NecrarchCareerChoices.cs +++ b/CSharpSourceCode/CharacterDevelopment/CareerSystem/Choices/NecrarchCareerChoices.cs @@ -307,7 +307,8 @@ public override void InitialCareerSetup() Hero.MainHero.Culture = sylvaniaCulture; } - var religions = ReligionObject.All.FindAll(x => x.Affinity == ReligionAffinity.Order); + // Become hostile to Human pantheon religions (your former faith) + var religions = ReligionObject.All.FindAll(x => x.Pantheon == Pantheon.Human); foreach (var religion in religions) { Hero.MainHero.AddReligiousInfluence(religion, -100, true); diff --git a/CSharpSourceCode/CharacterDevelopment/CareerSystem/Choices/NecromancerCareerChoices.cs b/CSharpSourceCode/CharacterDevelopment/CareerSystem/Choices/NecromancerCareerChoices.cs index f091eb7..30bfed7 100644 --- a/CSharpSourceCode/CharacterDevelopment/CareerSystem/Choices/NecromancerCareerChoices.cs +++ b/CSharpSourceCode/CharacterDevelopment/CareerSystem/Choices/NecromancerCareerChoices.cs @@ -236,8 +236,8 @@ protected override void InitializePassives() public override void InitialCareerSetup() { - var religions = ReligionObject.All.FindAll(x => x.Affinity == ReligionAffinity.Order); - + // Become hostile to Human pantheon religions (your former faith) + var religions = ReligionObject.All.FindAll(x => x.Pantheon == Pantheon.Human); foreach (var religion in religions) { Hero.MainHero.AddReligiousInfluence(religion, -100, true); diff --git a/CSharpSourceCode/CharacterDevelopment/CareerSystem/Choices/VampireCountCareerChoices.cs b/CSharpSourceCode/CharacterDevelopment/CareerSystem/Choices/VampireCountCareerChoices.cs index e390475..50a8fac 100644 --- a/CSharpSourceCode/CharacterDevelopment/CareerSystem/Choices/VampireCountCareerChoices.cs +++ b/CSharpSourceCode/CharacterDevelopment/CareerSystem/Choices/VampireCountCareerChoices.cs @@ -331,7 +331,8 @@ public override void InitialCareerSetup() } - var religions = ReligionObject.All.FindAll(x => x.Affinity == ReligionAffinity.Order); + // Become hostile to Human pantheon religions (your former faith) + var religions = ReligionObject.All.FindAll(x => x.Pantheon == Pantheon.Human); foreach (var religion in religions) { Hero.MainHero.AddReligiousInfluence(religion, -100, true); diff --git a/CSharpSourceCode/Extensions/KingdomExtension.cs b/CSharpSourceCode/Extensions/KingdomExtension.cs index a1e8939..9118506 100644 --- a/CSharpSourceCode/Extensions/KingdomExtension.cs +++ b/CSharpSourceCode/Extensions/KingdomExtension.cs @@ -1,6 +1,11 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using System.Runtime.CompilerServices; using TaleWorlds.CampaignSystem; +using TaleWorlds.CampaignSystem.Actions; +using TaleWorlds.CampaignSystem.CampaignBehaviors; +using TOR_Core.CampaignMechanics.Diplomacy; +using TOR_Core.Utilities; namespace TOR_Core.Extensions; @@ -30,25 +35,146 @@ public static void SetAllyTriggered(this IFaction obj, bool value) public static bool IsCoastalKingdom(this Kingdom kingdom) { + return kingdom.StringId == TORConstants.Factions.NORDLAND || + kingdom.StringId == TORConstants.Factions.OSTLAND || + kingdom.StringId == TORConstants.Factions.WASTELAND || + kingdom.StringId == TORConstants.Factions.COURONNE || + kingdom.StringId == TORConstants.Factions.ANGUILLE || + kingdom.StringId == TORConstants.Factions.LYONESSE || + kingdom.StringId == TORConstants.Factions.MOUSILLON || + kingdom.StringId == TORConstants.Factions.BORDELEAUX || + kingdom.StringId == TORConstants.Factions.BRIONNE; + } - //Nordland - //Marienburg - //Ostland - //Mousillon - //Lyonesse - //Bordeleaux - //Coronne - //Brionne - //Languille + public static bool IsCastleFaction(this Kingdom kingdom) + { + return kingdom.RulingClan.IsCastleFaction(); + } + + public static IEnumerable GetTradeAgreementKingdoms(this Kingdom kingdom) + { + var tradeAgreementBehavior = Campaign.Current?.GetCampaignBehavior(); + if (tradeAgreementBehavior == null) + return Enumerable.Empty(); - string[] coastalKingdoms = { "nordland", "ostland", "wasteland", "couronne", "anguille", "lyonesse", "mousillon", "bordeleaux", "brionne" }; + return Kingdom.All + .Where(k => k != kingdom && !k.IsEliminated) + .Where(k => tradeAgreementBehavior.HasTradeAgreement(kingdom, k)); + } + + public static int GetTradeAgreementCount(this Kingdom kingdom) + { + return kingdom.GetTradeAgreementKingdoms().Count(); + } + + public static bool HasTradeAgreementWith(this Kingdom kingdom, Kingdom otherKingdom) + { + var tradeAgreementBehavior = Campaign.Current?.GetCampaignBehavior(); + if (tradeAgreementBehavior == null) + return false; + return tradeAgreementBehavior.HasTradeAgreement(kingdom, otherKingdom); + } + + public static IEnumerable GetEnemyKingdoms(this Kingdom kingdom) + { + if (kingdom == null) + return Enumerable.Empty(); - return coastalKingdoms.Any(id => kingdom.StringId == id); + return Kingdom.All + .Where(k => k != kingdom && !k.IsEliminated && kingdom.IsAtWarWith(k)); + } + + public static float GetTotalEnemyStrength(this Kingdom kingdom) + { + return kingdom.GetEnemyKingdoms().Sum(k => k.CurrentTotalStrength); + } + + public static IEnumerable GetAlliedKingdoms(this IFaction faction) + { + Kingdom kingdom = faction as Kingdom; + return kingdom?.GetAlliedKingdoms(); } - public static bool IsCastleFaction(this Kingdom kingdom) + public static IEnumerable GetAlliedKingdoms(this Kingdom kingdom) { - return kingdom.RulingClan.IsCastleFaction(); + if (kingdom == null) + return Enumerable.Empty(); + + return Kingdom.All + .Where(k => k != kingdom && !k.IsEliminated && kingdom.IsAllyWith(k)); + } + + public static int GetAllianceCount(this Kingdom kingdom) + { + return kingdom.GetAlliedKingdoms().Count(); + } + + public static int GetWarCount(this Kingdom kingdom) + { + return kingdom.GetEnemyKingdoms().Count(); + } + + public static float GetAllianceTotalStrength(this Kingdom kingdom) + { + if (kingdom == null) return 0f; + + float totalStrength = kingdom.CurrentTotalStrength; + foreach (var ally in kingdom.GetAlliedKingdoms()) + { + totalStrength += ally.CurrentTotalStrength; + } + return totalStrength; + } + + public static float GetTotalEnemyAllianceStrength(this Kingdom kingdom) + { + if (kingdom == null) return 0f; + + float sum = 0f; + foreach (var enemy in kingdom.GetEnemyKingdoms()) + { + sum += enemy.GetAllianceTotalStrength(); + } + return sum; + } + + public static void SetAlliance(this Kingdom kingdom1, Kingdom kingdom2) + { + if (kingdom1 == null || kingdom2 == null) + return; + + var allianceBehavior = Campaign.Current?.GetCampaignBehavior(); + allianceBehavior?.StartAlliance(kingdom1, kingdom2); + } + + public static void SetAllianceClean(this Kingdom kingdom1, Kingdom kingdom2) + { + if (kingdom1 == null || kingdom2 == null) + return; + + var enemies1 = kingdom1.GetEnemyKingdoms().ToList(); + var enemies2 = kingdom2.GetEnemyKingdoms().ToList(); + + var allianceBehavior = Campaign.Current?.GetCampaignBehavior(); + allianceBehavior?.StartAlliance(kingdom1, kingdom2); + + JoinAllyWars(kingdom1, kingdom2, enemies2); + JoinAllyWars(kingdom2, kingdom1, enemies1); + } + + private static void JoinAllyWars(Kingdom kingdom, Kingdom ally, List allyEnemies) + { + var allianceWarBehavior = Campaign.Current?.GetCampaignBehavior(); + + foreach (var enemy in allyEnemies) + { + if (kingdom.IsAtWarWith(enemy)) continue; + if (enemy.Culture?.StringId == TORConstants.Cultures.CHAOS) continue; + if (enemy == kingdom) continue; + + allianceWarBehavior?.MarkAsAllianceWar(kingdom, enemy); + DeclareWarAction.ApplyByKingdomDecision(kingdom, enemy); + } } } \ No newline at end of file diff --git a/CSharpSourceCode/HarmonyPatches/LogEntryNotificationPatches.cs b/CSharpSourceCode/HarmonyPatches/LogEntryNotificationPatches.cs new file mode 100644 index 0000000..c3903b4 --- /dev/null +++ b/CSharpSourceCode/HarmonyPatches/LogEntryNotificationPatches.cs @@ -0,0 +1,170 @@ +using System.Reflection; +using HarmonyLib; +using TaleWorlds.CampaignSystem; +using TaleWorlds.CampaignSystem.LogEntries; +using TaleWorlds.CampaignSystem.Settlements; +using TOR_Core.Utilities; + +namespace TOR_Core.HarmonyPatches +{ + [HarmonyPatch(typeof(CampaignInformationManager), "NewLogEntryAdded")] + public static class LogEntryNotificationPatch + { + private static readonly bool DebugkingdomDecisions=false; + + public static bool Prefix(LogEntry log) + { + if (!(log is IChatNotification chatNotification) || !chatNotification.IsVisibleNotification) + return true; + + switch (log) + { + case TakePrisonerLogEntry prisonerLog: + return IsRelevantPrisonerLog(prisonerLog); + + case EndCaptivityLogEntry endCaptivityLog: + return IsRelevantEndCaptivityLog(endCaptivityLog); + + case CharacterKilledLogEntry killedLog: + return IsRelevantKilledLog(killedLog); + + case MakePeaceLogEntry peaceLog: + return IsRelevantPeaceLog(peaceLog); + + case DeclareWarLogEntry warLog: + return IsRelevantWarLog(warLog); + + case ChangeSettlementOwnerLogEntry settlementLog: + return IsRelevantSettlementOwnerLog(settlementLog); + + case BattleStartedLogEntry battleLog: + return IsRelevantBattleLog(battleLog); + + case MercenaryClanChangedKingdomLogEntry mercenaryLog: + return IsRelevantMercenaryLog(mercenaryLog); + + case ClanChangeKingdomLogEntry clanChangeLog: + return IsRelevantClanChangeLog(clanChangeLog); + + case KingdomDecisionConcludedLogEntry decisionLog: + return IsRelevantKingdomDecisionLog(decisionLog); + + default: + return true; + } + } + + private static bool IsRelevantPrisonerLog(TakePrisonerLogEntry log) + { + if (TORNotificationHelper.IsHeroRelevantToPlayer(log.Prisoner)) + return true; + if (TORNotificationHelper.IsHeroRelevantToPlayer(log.CapturerHero)) + return true; + if (TORNotificationHelper.IsFactionRelevantToPlayer(log.CapturerPartyMapFaction)) + return true; + return false; + } + + private static bool IsRelevantEndCaptivityLog(EndCaptivityLogEntry log) + { + if (TORNotificationHelper.IsHeroRelevantToPlayer(log.Prisoner)) + return true; + return false; + } + + private static bool IsRelevantKilledLog(CharacterKilledLogEntry log) + { + if (TORNotificationHelper.IsHeroRelevantToPlayer(log.Victim)) + return true; + if (log.Killer != null && TORNotificationHelper.IsHeroRelevantToPlayer(log.Killer)) + return true; + return false; + } + + private static bool IsRelevantPeaceLog(MakePeaceLogEntry log) + { + if (log.Faction1 is Kingdom k1 && TORNotificationHelper.IsKingdomRelevantToPlayer(k1)) + return true; + if (log.Faction2 is Kingdom k2 && TORNotificationHelper.IsKingdomRelevantToPlayer(k2)) + return true; + if (TORNotificationHelper.IsFactionRelevantToPlayer(log.Faction1)) + return true; + if (TORNotificationHelper.IsFactionRelevantToPlayer(log.Faction2)) + return true; + return false; + } + + private static bool IsRelevantWarLog(DeclareWarLogEntry log) + { + if (log.Faction1 is Kingdom k1 && TORNotificationHelper.IsKingdomRelevantToPlayer(k1)) + return true; + if (log.Faction2 is Kingdom k2 && TORNotificationHelper.IsKingdomRelevantToPlayer(k2)) + return true; + if (TORNotificationHelper.IsFactionRelevantToPlayer(log.Faction1)) + return true; + if (TORNotificationHelper.IsFactionRelevantToPlayer(log.Faction2)) + return true; + return false; + } + + private static bool IsRelevantSettlementOwnerLog(ChangeSettlementOwnerLogEntry log) + { + if (log.Settlement != null && log.Settlement.OwnerClan == Clan.PlayerClan) + return true; + if (log.Settlement?.OwnerClan?.Kingdom != null && TORNotificationHelper.IsKingdomRelevantToPlayer(log.Settlement.OwnerClan.Kingdom)) + return true; + return false; + } + + private static readonly FieldInfo AttackerFactionField = typeof(BattleStartedLogEntry).GetField("_attackerFaction", BindingFlags.NonPublic | BindingFlags.Instance); + private static readonly FieldInfo SettlementField = typeof(BattleStartedLogEntry).GetField("_settlement", BindingFlags.NonPublic | BindingFlags.Instance); + + private static bool IsRelevantBattleLog(BattleStartedLogEntry log) + { + var attackerFaction = AttackerFactionField?.GetValue(log) as IFaction; + var settlement = SettlementField?.GetValue(log) as Settlement; + + if (attackerFaction != null && TORNotificationHelper.IsFactionRelevantToPlayer(attackerFaction)) + return true; + + if (settlement?.OwnerClan?.Kingdom != null && TORNotificationHelper.IsKingdomRelevantToPlayer(settlement.OwnerClan.Kingdom)) + return true; + + if (settlement?.MapFaction != null && TORNotificationHelper.IsFactionRelevantToPlayer(settlement.MapFaction)) + return true; + + return false; + } + + private static bool IsRelevantMercenaryLog(MercenaryClanChangedKingdomLogEntry log) + { + if (log.Clan == Clan.PlayerClan) + return true; + if (log.OldKingdom != null && TORNotificationHelper.IsKingdomRelevantToPlayer(log.OldKingdom)) + return true; + if (log.NewKingdom != null && TORNotificationHelper.IsKingdomRelevantToPlayer(log.NewKingdom)) + return true; + return false; + } + + private static bool IsRelevantClanChangeLog(ClanChangeKingdomLogEntry log) + { + if (log.Clan == Clan.PlayerClan) + return true; + if (log.OldKingdom != null && TORNotificationHelper.IsKingdomRelevantToPlayer(log.OldKingdom)) + return true; + if (log.NewKingdom != null && TORNotificationHelper.IsKingdomRelevantToPlayer(log.NewKingdom)) + return true; + return false; + } + + private static bool IsRelevantKingdomDecisionLog(KingdomDecisionConcludedLogEntry log) + { + if (DebugkingdomDecisions) return true; + + if (log.Kingdom != null && TORNotificationHelper.IsKingdomRelevantToPlayer(log.Kingdom)) + return true; + return false; + } + } +} diff --git a/CSharpSourceCode/Models/DiplomacyHelpers.cs b/CSharpSourceCode/Models/DiplomacyHelpers.cs new file mode 100644 index 0000000..403f034 --- /dev/null +++ b/CSharpSourceCode/Models/DiplomacyHelpers.cs @@ -0,0 +1,244 @@ +using System; +using System.Linq; +using TaleWorlds.CampaignSystem; +using TaleWorlds.CampaignSystem.CharacterDevelopment; +using TOR_Core.CampaignMechanics.Religion; +using TOR_Core.Extensions; + +namespace TOR_Core.Models +{ + /// + /// Shared helper methods for diplomacy calculations (war, alliance, trade). + /// Centralizes distance calculations and personality trait modifiers. + /// + public static class DiplomacyHelpers + { + // Distance thresholds for different diplomatic actions + public const float MaxWarDistance = 300f; + public const float MaxAllianceDistance = 500f; + public const float MaxTradeDistance = 600f; + + // Personality trait modifier step (trait level -2 to +2 maps to 0.25 to 1.75) + private const float TraitModifierStep = 0.375f; + + /// + /// Gets the approximate distance between two kingdoms based on their mid settlements. + /// + public static float GetKingdomDistance(Kingdom kingdom1, Kingdom kingdom2) + { + if (kingdom1?.FactionMidSettlement == null || kingdom2?.FactionMidSettlement == null) + return float.MaxValue; + + return kingdom1.FactionMidSettlement.Position.Distance(kingdom2.FactionMidSettlement.Position); + } + + /// + /// Checks if two kingdoms are within war declaration distance. + /// + public static bool IsWithinWarDistance(Kingdom kingdom1, Kingdom kingdom2) + { + return GetKingdomDistance(kingdom1, kingdom2) <= MaxWarDistance; + } + + /// + /// Checks if two kingdoms are within alliance distance. + /// + public static bool IsWithinAllianceDistance(Kingdom kingdom1, Kingdom kingdom2) + { + return GetKingdomDistance(kingdom1, kingdom2) <= MaxAllianceDistance; + } + + /// + /// Checks if two kingdoms are within trade agreement distance. + /// + public static bool IsWithinTradeDistance(Kingdom kingdom1, Kingdom kingdom2) + { + return GetKingdomDistance(kingdom1, kingdom2) <= MaxTradeDistance; + } + + /// + /// Gets a trait-based modifier for diplomacy scoring. + /// Maps trait level (-2 to +2) to multiplier (0.25 to 1.75). + /// Higher trait level = higher modifier. + /// + public static float GetTraitModifier(Hero leader, TraitObject trait) + { + if (leader == null || trait == null) return 1f; + int traitLevel = leader.GetTraitLevel(trait); + return 1f + (traitLevel * TraitModifierStep); + } + + /// + /// Gets an inverse trait-based modifier for diplomacy scoring. + /// Maps trait level (-2 to +2) to multiplier (1.75 to 0.25). + /// Higher trait level = lower modifier. + /// + public static float GetInverseTraitModifier(Hero leader, TraitObject trait) + { + if (leader == null || trait == null) return 1f; + int traitLevel = leader.GetTraitLevel(trait); + return 1f - (traitLevel * TraitModifierStep); + } + + /// + /// Gets the dominant pantheon for a kingdom based on its leader's religion or culture. + /// + public static Pantheon GetKingdomPantheon(Kingdom kingdom) + { + if (kingdom == null) return Pantheon.Human; + + var leaderReligion = kingdom.Leader?.GetDominantReligion(); + if (leaderReligion != null) + return leaderReligion.Pantheon; + + return ReligionObjectHelper.GetPantheon(kingdom.Culture?.StringId); + } + + /// + /// Checks if two kingdoms share the same culture. + /// + public static bool AreSameCulture(Kingdom kingdom1, Kingdom kingdom2) + { + var culture1 = kingdom1?.Culture?.StringId; + var culture2 = kingdom2?.Culture?.StringId; + return !string.IsNullOrEmpty(culture1) && culture1 == culture2; + } + + /// + /// Gets the culture compatibility between two kingdoms. + /// Returns: -1 (hostile) to +1 (friendly), 0 = neutral + /// + public static float GetCultureCompatibility(Kingdom kingdom1, Kingdom kingdom2) + { + var culture1 = kingdom1?.Culture?.StringId; + var culture2 = kingdom2?.Culture?.StringId; + + if (string.IsNullOrEmpty(culture1) || string.IsNullOrEmpty(culture2)) + return 0f; + + return ReligionObjectHelper.CalculateCultureCompatibility(culture1, culture2); + } + + /// + /// Checks if two kingdoms share the same religion. + /// + public static bool AreSameReligion(Kingdom kingdom1, Kingdom kingdom2) + { + var religion1 = kingdom1?.Leader?.GetDominantReligion(); + var religion2 = kingdom2?.Leader?.GetDominantReligion(); + return religion1 != null && religion1 == religion2; + } + + /// + /// Checks if two kingdoms have hostile religions. + /// + public static bool AreReligionsHostile(Kingdom kingdom1, Kingdom kingdom2) + { + var religion1 = kingdom1?.Leader?.GetDominantReligion(); + var religion2 = kingdom2?.Leader?.GetDominantReligion(); + + if (religion1 == null || religion2 == null) + return false; + + return religion1.HostileReligions != null && religion1.HostileReligions.Contains(religion2); + } + + /// + /// Gets the religion compatibility between two kingdoms. + /// Returns: -1 (hostile) to +1 (friendly), 0 = neutral + /// + public static float GetReligionCompatibility(Kingdom kingdom1, Kingdom kingdom2) + { + var religion1 = kingdom1?.Leader?.GetDominantReligion(); + var religion2 = kingdom2?.Leader?.GetDominantReligion(); + + if (religion1 == null || religion2 == null) + return 0f; + + return ReligionObjectHelper.CalculateReligionCompatibility(religion1, religion2); + } + + /// + /// Gets the pantheon compatibility between two kingdoms. + /// Returns: -1 (hostile) to +1 (friendly), 0 = neutral + /// + public static float GetPantheonCompatibility(Kingdom kingdom1, Kingdom kingdom2) + { + var religion1 = kingdom1?.Leader?.GetDominantReligion(); + var religion2 = kingdom2?.Leader?.GetDominantReligion(); + + if (religion1 == null || religion2 == null) + return 0f; + + return ReligionObjectHelper.GetPantheonCompatibility(religion1.Pantheon, religion2.Pantheon); + } + + /// + /// Calculates the average relation between a clan's members and a kingdom's clan leaders. + /// + public static float CalculateClanToKingdomRelation(Clan clan, Kingdom kingdom) + { + if (clan == null || kingdom == null) + return 0f; + + var clanHeroes = clan.Heroes.Where(h => h.IsAlive && !h.IsChild).ToList(); + var kingdomLeaders = kingdom.Clans + .Where(c => c.Leader != null && c.Leader.IsAlive) + .Select(c => c.Leader) + .ToList(); + + if (!clanHeroes.Any() || !kingdomLeaders.Any()) + return 0f; + + float totalRelation = 0f; + int count = 0; + + foreach (var clanHero in clanHeroes) + { + foreach (var kingdomLeader in kingdomLeaders) + { + totalRelation += clanHero.GetRelation(kingdomLeader); + count++; + } + } + + return count > 0 ? totalRelation / count : 0f; + } + + /// + /// Calculates the average relation between two kingdoms based on all clan leaders. + /// + public static float CalculateKingdomToKingdomRelation(Kingdom kingdom1, Kingdom kingdom2) + { + if (kingdom1 == null || kingdom2 == null) + return 0f; + + var leaders1 = kingdom1.Clans + .Where(c => c.Leader != null && c.Leader.IsAlive) + .Select(c => c.Leader) + .ToList(); + + var leaders2 = kingdom2.Clans + .Where(c => c.Leader != null && c.Leader.IsAlive) + .Select(c => c.Leader) + .ToList(); + + if (!leaders1.Any() || !leaders2.Any()) + return 0f; + + float totalRelation = 0f; + int count = 0; + + foreach (var leader1 in leaders1) + { + foreach (var leader2 in leaders2) + { + totalRelation += leader1.GetRelation(leader2); + count++; + } + } + + return count > 0 ? totalRelation / count : 0f; + } + } +} diff --git a/CSharpSourceCode/Models/TORAllianceModel.cs b/CSharpSourceCode/Models/TORAllianceModel.cs new file mode 100644 index 0000000..5cfec8d --- /dev/null +++ b/CSharpSourceCode/Models/TORAllianceModel.cs @@ -0,0 +1,753 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using TaleWorlds.CampaignSystem; +using TaleWorlds.CampaignSystem.CharacterDevelopment; +using TaleWorlds.CampaignSystem.GameComponents; +using TaleWorlds.Core; +using TaleWorlds.Library; +using TaleWorlds.Localization; +using TOR_Core.CampaignMechanics.Religion; +using TOR_Core.Extensions; +using TOR_Core.Utilities; + +namespace TOR_Core.Models +{ + /// + /// TOR Alliance Model - Determines AI willingness to form alliances. + /// Alliances are defensive pacts that pull allies into wars. + /// + public class TORAllianceModel : DefaultAllianceModel + { + // Distance scoring + private const float CloseDistanceThreshold = 150f; + private const float DistancePenaltyMultiplier = 1.5f; + + // Reliability scoring (culture/religion) + private const float SameReligionBonus = 30f; + private const float SameCultureBonus = 20f; + private const float HostileReligionPenalty = -50f; + private const float ReligionCompatibilityWeight = 20f; + private const float CultureCompatibilityWeight = 15f; + + // Power scoring + private const float PowerRatioWeight = 20f; + private const float MinimumUsefulStrengthRatio = 0.3f; // Ally should have at least 30% of our strength + + // Strategic positioning + private const float ProximityToEnemyWeight = 25f; + private const float SharedBorderWithEnemyBonus = 15f; + + // Common enemies + private const float SharedActiveWarWeight = 40f; + private const float SharedHostileFactionWeight = 20f; + private const float SharedThreatWeight = 15f; + + // Entanglement risk + private const float EnemyCountPenaltyWeight = -10f; + private const float StrongEnemyPenaltyWeight = -15f; + private const float InheritedWarRiskWeight = -20f; + + // Alliance network + private const float CompatibleAllyBonus = 15f; + private const float IncompatibleAllyPenalty = -20f; + private const float HostileAllyPenalty = -40f; + + // Protective alliance + private const float ProtectThreatenedWeight = 25f; + private const float ProtectWeakerWeight = 15f; + + // Lore considerations + private const float EonirDiplomacyBonus = 15f; + private const float DawiAsraiRivalryPenalty = -60f; // War of the Beard grudge + + // Personality trait weights + private const float HonorTraitWeight = 15f; + + // Kingdom relations + private const float KingdomRelationWeight = 0.3f; // -100 to +100 relation = -30 to +30 score + + // Honor Alliance Decision weights + private const float HonorAllianceBaseHonorWeight = 30f; // Honorable lords keep their word + private const float HonorAllianceBaseValorWeight = 20f; // Brave lords want to fight + private const float HonorAllianceHostileReligionBonus = 50f; + private const float HonorAllianceAttackerReligionWeight = 20f; + private const float HonorAllianceAllyReligionWeight = 15f; + private const float HonorAllianceAttackerCultureWeight = 25f; + private const float HonorAllianceAllyCultureWeight = 15f; + private const float HonorAllianceRelationWeight = 0.5f; + private const float HonorAllianceHighRelationBonus = 20f; + private const float HonorAllianceVeryHighRelationBonus = 40f; + private const float HonorAllianceLowRelationPenalty = -15f; + private const float HonorAllianceStrengthHopelessPenalty = -30f; + private const float HonorAllianceStrengthConfidentBonus = 15f; + + private static TextObject _loreText => TORTextHelper.GetTextObject("tor_alliance_lore", "Faction disposition"); + private static TextObject _distanceText => TORTextHelper.GetTextObject("tor_alliance_distance", "Geographic distance"); + private static TextObject _reliabilityText => TORTextHelper.GetTextObject("tor_alliance_reliability", "Partner reliability"); + private static TextObject _powerText => TORTextHelper.GetTextObject("tor_alliance_power", "Military strength"); + private static TextObject _positioningText => TORTextHelper.GetTextObject("tor_alliance_position", "Strategic positioning"); + private static TextObject _commonEnemiesText => TORTextHelper.GetTextObject("tor_alliance_common_enemy", "Common enemies"); + private static TextObject _entanglementText => TORTextHelper.GetTextObject("tor_alliance_entangle", "Entanglement risk"); + private static TextObject _protectiveText => TORTextHelper.GetTextObject("tor_alliance_protect", "Protective instinct"); + private static TextObject _honorText => TORTextHelper.GetTextObject("tor_alliance_honor", "Alliance commitment"); + private static TextObject _allianceNetworkText => TORTextHelper.GetTextObject("tor_alliance_network", "Alliance network"); + private static TextObject _relationText => TORTextHelper.GetTextObject("tor_alliance_relation", "Personal relations"); + + private static TextObject _chaosCannotAllyText => TORTextHelper.GetTextObject("tor_alliance_chaos", "The forces of Chaos do not form alliances."); + private static TextObject _greenskinCannotAllyText => TORTextHelper.GetTextObject("tor_alliance_greenskin", "Greenskins do not understand alliances."); + private static TextObject _allianceLimitText => TORTextHelper.GetTextObject("tor_alliance_limit", "Alliance limit reached"); + + public override int MaxNumberOfAlliances => 2; + + public override ExplainedNumber GetScoreOfStartingAlliance( + Kingdom proposingKingdom, + Kingdom targetKingdom, + IFaction evaluatingFaction, + out TextObject explanationText, + bool includeDescription = false) + { + var score = base.GetScoreOfStartingAlliance( + proposingKingdom, targetKingdom, evaluatingFaction, + out explanationText, includeDescription); + + // Hard lore restrictions - return early with massive penalty + if (!CanFormAlliance(proposingKingdom, targetKingdom)) + { + score.Add(-10000f, _loreText); + return score; + } + + // Alliance limit check - can't ally if either kingdom is at max + if (proposingKingdom.GetAllianceCount() >= MaxNumberOfAlliances || + targetKingdom.GetAllianceCount() >= MaxNumberOfAlliances) + { + score.Add(-10000f, _allianceLimitText); + return score; + } + + // Distance check - too far to be useful allies + if (!DiplomacyHelpers.IsWithinAllianceDistance(proposingKingdom, targetKingdom)) + { + score.Add(-500f, _distanceText); + return score; + } + + // Get the evaluating leader for personality traits + Hero evaluatingLeader = GetEvaluatingLeader(evaluatingFaction); + + // Get trait modifiers + float honorModifier = DiplomacyHelpers.GetTraitModifier(evaluatingLeader, DefaultTraits.Honor); + float calculatingModifier = DiplomacyHelpers.GetTraitModifier(evaluatingLeader, DefaultTraits.Calculating); + float mercyModifier = DiplomacyHelpers.GetTraitModifier(evaluatingLeader, DefaultTraits.Mercy); + float generosityModifier = DiplomacyHelpers.GetTraitModifier(evaluatingLeader, DefaultTraits.Generosity); + float valorModifier = DiplomacyHelpers.GetTraitModifier(evaluatingLeader, DefaultTraits.Valor); + float valorInverseModifier = DiplomacyHelpers.GetInverseTraitModifier(evaluatingLeader, DefaultTraits.Valor); + float generosityInverseModifier = DiplomacyHelpers.GetInverseTraitModifier(evaluatingLeader, DefaultTraits.Generosity); + + // Check compatibility for common enemies calculation + bool isCompatible = IsCompatiblePartner(proposingKingdom, targetKingdom); + + // Calculate individual scores + float loreScore = CalculateLoreConsiderations(proposingKingdom, targetKingdom); + float distanceScore = CalculateDistanceScore(proposingKingdom, targetKingdom); + float reliabilityScore = CalculateReliabilityScore(proposingKingdom, targetKingdom) * honorModifier; + float powerScore = CalculatePowerScore(proposingKingdom, targetKingdom) * calculatingModifier * generosityInverseModifier; + float positioningScore = CalculateStrategicPositioningScore(proposingKingdom, targetKingdom) * calculatingModifier; + float commonEnemiesScore = CalculateCommonEnemiesScore(proposingKingdom, targetKingdom, isCompatible, calculatingModifier); + float entanglementScore = CalculateEntanglementRiskScore(proposingKingdom, targetKingdom) * calculatingModifier * valorInverseModifier; + float allianceNetworkScore = CalculateAllianceNetworkScore(proposingKingdom, targetKingdom) * calculatingModifier; + + // Kingdom relations - average of all clan leaders' relations + float kingdomRelation = DiplomacyHelpers.CalculateKingdomToKingdomRelation(proposingKingdom, targetKingdom); + float relationScore = kingdomRelation * KingdomRelationWeight * mercyModifier; + + // Protective alliances require both mercy AND generosity - cruel or greedy lords don't consider them + float protectiveScore = 0f; + if (mercyModifier > 1f && generosityModifier > 1f) + { + // Only merciful AND generous lords consider protective alliances + // Scale by how merciful/generous they are above baseline + float mercyFactor = mercyModifier - 1f; // 0 to 0.75 + float generosityFactor = generosityModifier - 1f; // 0 to 0.75 + protectiveScore = CalculateProtectiveAllianceScore(proposingKingdom, targetKingdom) * (1f + mercyFactor + generosityFactor); + } + + // Honor bonus - honorable lords value alliance commitments + float honorScore = (honorModifier - 1f) * HonorTraitWeight; + + // Add scores to explained number + if (loreScore != 0) score.Add(loreScore, _loreText); + if (distanceScore != 0) score.Add(distanceScore, _distanceText); + if (reliabilityScore != 0) score.Add(reliabilityScore, _reliabilityText); + if (powerScore != 0) score.Add(powerScore, _powerText); + if (positioningScore != 0) score.Add(positioningScore, _positioningText); + if (commonEnemiesScore != 0) score.Add(commonEnemiesScore, _commonEnemiesText); + if (entanglementScore != 0) score.Add(entanglementScore, _entanglementText); + if (allianceNetworkScore != 0) score.Add(allianceNetworkScore, _allianceNetworkText); + if (relationScore != 0) score.Add(relationScore, _relationText); + if (protectiveScore != 0) score.Add(protectiveScore, _protectiveText); + if (honorScore != 0) score.Add(honorScore, _honorText); + + return score; + } + + /// + /// Gets the leader of the evaluating faction. + /// + private Hero GetEvaluatingLeader(IFaction evaluatingFaction) + { + if (evaluatingFaction is Clan clan) + return clan.Leader; + if (evaluatingFaction is Kingdom kingdom) + return kingdom.Leader; + return null; + } + + /// + /// Checks if two kingdoms are compatible (same religion or culture). + /// Used to determine if common enemy score applies fully or only for calculating lords. + /// + private bool IsCompatiblePartner(Kingdom kingdom1, Kingdom kingdom2) + { + if (DiplomacyHelpers.AreSameReligion(kingdom1, kingdom2)) + return true; + if (DiplomacyHelpers.AreSameCulture(kingdom1, kingdom2)) + return true; + + // Check pantheon compatibility + var pantheon1 = DiplomacyHelpers.GetKingdomPantheon(kingdom1); + var pantheon2 = DiplomacyHelpers.GetKingdomPantheon(kingdom2); + + // Same pantheon = compatible + if (pantheon1 == pantheon2) + return true; + + // Human-Dwarven-Elven are generally compatible (Order vs Chaos) + if (IsOrderPantheon(pantheon1) && IsOrderPantheon(pantheon2)) + return true; + + return false; + } + + private bool IsOrderPantheon(Pantheon pantheon) + { + return pantheon == Pantheon.Human || + pantheon == Pantheon.Dwarven || + pantheon == Pantheon.Elven; + } + + /// + /// Lore-based alliance considerations. + /// Eonir are natural diplomats. + /// Dwarfs and Wood Elves have ancient grudges (War of the Beard). + /// + private float CalculateLoreConsiderations(Kingdom proposingKingdom, Kingdom targetKingdom) + { + float score = 0f; + + // Eonir are skilled diplomats + if (proposingKingdom.Culture?.StringId == TORConstants.Cultures.EONIR) + score += EonirDiplomacyBonus; + + // War of the Beard - Dwarfs and Wood Elves distrust each other + var proposingCulture = proposingKingdom.Culture?.StringId; + var targetCulture = targetKingdom.Culture?.StringId; + if ((proposingCulture == TORConstants.Cultures.DAWI && targetCulture == TORConstants.Cultures.ASRAI) || + (proposingCulture == TORConstants.Cultures.ASRAI && targetCulture == TORConstants.Cultures.DAWI)) + { + score += DawiAsraiRivalryPenalty; + } + + return score; + } + + /// + /// Distance score - closer allies are more useful. + /// Returns: 0 (close) to -90 (far but within range) + /// + private float CalculateDistanceScore(Kingdom proposingKingdom, Kingdom targetKingdom) + { + float distance = DiplomacyHelpers.GetKingdomDistance(proposingKingdom, targetKingdom); + + if (distance <= CloseDistanceThreshold) + return 0f; + + // Gradual penalty for distance + return (CloseDistanceThreshold - distance) * DistancePenaltyMultiplier; + } + + /// + /// Reliability score based on religion and culture compatibility. + /// Trustworthy partners make better allies. + /// Returns: -50 (hostile) to +50 (very reliable) + /// + private float CalculateReliabilityScore(Kingdom proposingKingdom, Kingdom targetKingdom) + { + // Same religion = very reliable + if (DiplomacyHelpers.AreSameReligion(proposingKingdom, targetKingdom)) + return SameReligionBonus; + + // Hostile religions = unreliable (will backstab) + if (DiplomacyHelpers.AreReligionsHostile(proposingKingdom, targetKingdom)) + return HostileReligionPenalty; + + float score = 0f; + + // Same culture = reliable + if (DiplomacyHelpers.AreSameCulture(proposingKingdom, targetKingdom)) + score += SameCultureBonus; + + // Otherwise use compatibility scores + float religionCompat = DiplomacyHelpers.GetReligionCompatibility(proposingKingdom, targetKingdom); + float cultureCompat = DiplomacyHelpers.GetCultureCompatibility(proposingKingdom, targetKingdom); + + score += religionCompat * ReligionCompatibilityWeight; + score += cultureCompat * CultureCompatibilityWeight; + + return score; + } + + /// + /// Power score - evaluate if ally has enough strength to be useful. + /// Uses alliance strength (includes their current allies). + /// Returns: -20 (too weak) to +30 (powerful ally) + /// + private float CalculatePowerScore(Kingdom proposingKingdom, Kingdom targetKingdom) + { + // Use alliance strength - their allies would become our allies too + float ourStrength = Math.Max(proposingKingdom.GetAllianceTotalStrength(), 1f); + float theirStrength = targetKingdom.GetAllianceTotalStrength(); + + float strengthRatio = theirStrength / ourStrength; + + // Too weak to be useful + if (strengthRatio < MinimumUsefulStrengthRatio) + return -20f; + + // Scale: 0.3 ratio = 0, 1.0 ratio = ~14, 2.0 ratio = ~28 + float score = (strengthRatio - MinimumUsefulStrengthRatio) * PowerRatioWeight; + + return MBMath.ClampFloat(score, -20f, 30f); + } + + /// + /// Strategic positioning - can this ally help against our current enemies? + /// Returns: 0 (no positioning value) to +40 (excellent position) + /// + private float CalculateStrategicPositioningScore(Kingdom proposingKingdom, Kingdom targetKingdom) + { + float score = 0f; + + // Check if target kingdom is close to any of our enemies + foreach (var enemyKingdom in proposingKingdom.GetEnemyKingdoms()) + { + float distanceToEnemy = DiplomacyHelpers.GetKingdomDistance(targetKingdom, enemyKingdom); + + // Close to our enemy = can help us fight + if (distanceToEnemy <= DiplomacyHelpers.MaxWarDistance) + { + score += ProximityToEnemyWeight; + + // Shares border with enemy = even better (buffer state) + if (distanceToEnemy <= 150f) + score += SharedBorderWithEnemyBonus; + } + } + + return score; + } + + /// + /// Common enemies score - shared threats create natural alliances. + /// For incompatible partners, only calculating lords see value. + /// Returns: 0 to +80 (many shared enemies) + /// + private float CalculateCommonEnemiesScore(Kingdom proposingKingdom, Kingdom targetKingdom, bool isCompatible, float calculatingModifier) + { + float score = 0f; + + // Shared active wars - both fighting the same enemy + foreach (var enemyKingdom in proposingKingdom.GetEnemyKingdoms()) + { + if (targetKingdom.IsAtWarWith(enemyKingdom)) + { + score += SharedActiveWarWeight; + } + } + + // Shared hostile factions (religions/cultures that hate both of us) + var ourPantheon = DiplomacyHelpers.GetKingdomPantheon(proposingKingdom); + var theirPantheon = DiplomacyHelpers.GetKingdomPantheon(targetKingdom); + + // Both threatened by Chaos + if (ourPantheon != Pantheon.Chaos && theirPantheon != Pantheon.Chaos) + { + bool chaosExists = Kingdom.All.Any(k => !k.IsEliminated && + DiplomacyHelpers.GetKingdomPantheon(k) == Pantheon.Chaos); + if (chaosExists) + score += SharedThreatWeight; + } + + // Both threatened by Greenskins + if (ourPantheon != Pantheon.Greenskin && theirPantheon != Pantheon.Greenskin) + { + bool greenskinExists = Kingdom.All.Any(k => !k.IsEliminated && + DiplomacyHelpers.GetKingdomPantheon(k) == Pantheon.Greenskin); + if (greenskinExists) + score += SharedThreatWeight; + } + + // Shared hostile religions + if (DiplomacyHelpers.AreReligionsHostile(proposingKingdom, targetKingdom) == false) + { + // Check if we share enemies based on religion + var ourReligion = proposingKingdom.Leader?.GetDominantReligion(); + var theirReligion = targetKingdom.Leader?.GetDominantReligion(); + + if (ourReligion?.HostileReligions != null && theirReligion?.HostileReligions != null) + { + foreach (var hostile in ourReligion.HostileReligions) + { + if (theirReligion.HostileReligions.Contains(hostile)) + score += SharedHostileFactionWeight; + } + } + } + + // Apply compatibility modifier + if (isCompatible) + { + return score; + } + else + { + // Incompatible partners - only calculating lords see value + // calculatingModifier ranges from 0.25 (-2 trait) to 1.75 (+2 trait) + // We want: -2 trait = 0, 0 trait = 0, +2 trait = full score + float pragmatismFactor = Math.Max(0f, calculatingModifier - 1f); // 0 to 0.75 + return score * pragmatismFactor * 1.33f; // Scale so +2 calculating gets full score + } + } + + /// + /// Entanglement risk - will this alliance drag us into unwanted wars? + /// Returns: 0 (low risk) to -60 (high risk) + /// + private float CalculateEntanglementRiskScore(Kingdom proposingKingdom, Kingdom targetKingdom) + { + float score = 0f; + + // How many wars are they fighting? + int theirWarCount = targetKingdom.GetWarCount(); + score += theirWarCount * EnemyCountPenaltyWeight; + + // Are their enemies strong? Would we inherit dangerous wars? + float ourStrength = proposingKingdom.CurrentTotalStrength; + + foreach (var enemyKingdom in targetKingdom.GetEnemyKingdoms()) + { + // Skip if we're already at war with them (not additional risk) + if (proposingKingdom.IsAtWarWith(enemyKingdom)) + continue; + + float enemyStrength = enemyKingdom.CurrentTotalStrength; + + // Strong enemy we'd inherit = risk + if (enemyStrength > ourStrength * 0.5f) + score += StrongEnemyPenaltyWeight; + + // Any inherited war is a risk + score += InheritedWarRiskWeight; + } + + return MBMath.ClampFloat(score, -60f, 0f); + } + + /// + /// Alliance network score - evaluates the compatibility of their current allies with us. + /// Compatible allies = good (joining a friendly network) + /// Hostile allies = bad (their allies might cause problems for us) + /// Returns: -60 (hostile network) to +45 (very compatible network) + /// + private float CalculateAllianceNetworkScore(Kingdom proposingKingdom, Kingdom targetKingdom) + { + float score = 0f; + + // Check each of their current allies + foreach (var theirAlly in targetKingdom.GetAlliedKingdoms()) + { + // Skip if it's us (shouldn't happen but be safe) + if (theirAlly == proposingKingdom) + continue; + + // Check if their ally is hostile to us + if (DiplomacyHelpers.AreReligionsHostile(proposingKingdom, theirAlly)) + { + score += HostileAllyPenalty; + continue; + } + + // Check if their ally is compatible with us + if (IsCompatiblePartner(proposingKingdom, theirAlly)) + { + score += CompatibleAllyBonus; + } + else + { + // Incompatible but not hostile - mild concern + score += IncompatibleAllyPenalty; + } + } + + return MBMath.ClampFloat(score, -60f, 45f); + } + + /// + /// Protective alliance score - merciful lords want to protect threatened kingdoms. + /// Returns: 0 (not threatened) to +40 (severely threatened) + /// + private float CalculateProtectiveAllianceScore(Kingdom proposingKingdom, Kingdom targetKingdom) + { + float score = 0f; + + // Is target kingdom under threat? + int theirWarCount = targetKingdom.GetWarCount(); + if (theirWarCount > 0) + { + score += theirWarCount * ProtectThreatenedWeight; + } + + // Is target kingdom weaker than their enemies? + float theirStrength = targetKingdom.CurrentTotalStrength; + float enemyStrength = targetKingdom.GetTotalEnemyStrength(); + + if (enemyStrength > theirStrength) + { + float vulnerabilityRatio = Math.Min(enemyStrength / Math.Max(theirStrength, 1f), 3f); + score += vulnerabilityRatio * ProtectWeakerWeight; + } + + return MBMath.ClampFloat(score, 0f, 40f); + } + + public bool CanFormAlliance(Kingdom kingdom1, Kingdom kingdom2) + { + var pantheon1 = DiplomacyHelpers.GetKingdomPantheon(kingdom1); + var pantheon2 = DiplomacyHelpers.GetKingdomPantheon(kingdom2); + + // Chaos cannot ally + if (pantheon1 == Pantheon.Chaos || pantheon2 == Pantheon.Chaos) + return false; + + // Greenskins cannot ally + if (pantheon1 == Pantheon.Greenskin || pantheon2 == Pantheon.Greenskin) + return false; + + return true; + } + + /// + /// Calculates how much a clan supports joining a war to honor an alliance. + /// Uses personality traits to modify various factors. + /// Positive = support joining, Negative = support breaking alliance. + /// + public float CalculateHonorAllianceSupport(Clan clan, Kingdom ourKingdom, Kingdom attackedAlly, Kingdom attacker) + { + if (clan?.Leader == null) + return 0f; + + var leader = clan.Leader; + float support = 0f; + + // Get trait modifiers + float honorModifier = DiplomacyHelpers.GetTraitModifier(leader, DefaultTraits.Honor); + float valorModifier = DiplomacyHelpers.GetTraitModifier(leader, DefaultTraits.Valor); + float valorInverseModifier = DiplomacyHelpers.GetInverseTraitModifier(leader, DefaultTraits.Valor); + float mercyModifier = DiplomacyHelpers.GetTraitModifier(leader, DefaultTraits.Mercy); + float calculatingModifier = DiplomacyHelpers.GetTraitModifier(leader, DefaultTraits.Calculating); + float calculatingInverseModifier = DiplomacyHelpers.GetInverseTraitModifier(leader, DefaultTraits.Calculating); + + // === BASE TRAIT BONUSES === + // Honorable lords keep their word + support += (honorModifier - 1f) * HonorAllianceBaseHonorWeight / 0.375f; + // Brave lords want to fight + support += (valorModifier - 1f) * HonorAllianceBaseValorWeight / 0.375f; + + // === RELIGION FACTORS (modified by Calculating inverse + Mercy) === + var clanReligion = leader.GetDominantReligion(); + var attackerReligion = attacker.Leader?.GetDominantReligion(); + var allyReligion = attackedAlly.Leader?.GetDominantReligion(); + + float religionScore = 0f; + + // Strong bonus for fighting religious enemies + if (clanReligion != null && attackerReligion != null) + { + if (clanReligion.HostileReligions?.Contains(attackerReligion) == true) + { + religionScore += HonorAllianceHostileReligionBonus; + } + else + { + // Similarity with attacker reduces support for war + float attackerSimilarity = ReligionObjectHelper.CalculateReligionCompatibility(clanReligion, attackerReligion); + religionScore -= attackerSimilarity * HonorAllianceAttackerReligionWeight; + } + } + + // Bonus for defending co-religionists + if (clanReligion != null && allyReligion != null) + { + float allySimilarity = ReligionObjectHelper.CalculateReligionCompatibility(clanReligion, allyReligion); + religionScore += allySimilarity * HonorAllianceAllyReligionWeight; + } + + // Apply trait modifiers: Pragmatists ignore religion, Merciful defend faith + support += religionScore * calculatingInverseModifier * mercyModifier; + + // === RELATION WITH ALLY (modified by Mercy + Calculating inverse) === + float allyRelation = DiplomacyHelpers.CalculateClanToKingdomRelation(clan, attackedAlly); + float allyRelationScore = allyRelation * HonorAllianceRelationWeight; + + // Very high relations make breaking almost unthinkable + if (allyRelation > 80) + allyRelationScore += HonorAllianceVeryHighRelationBonus; + else if (allyRelation > 50) + allyRelationScore += HonorAllianceHighRelationBonus; + + // Very low relations make breaking more acceptable + if (allyRelation < -20) + allyRelationScore += HonorAllianceLowRelationPenalty; + + support += allyRelationScore * mercyModifier * calculatingInverseModifier; + + // === RELATION WITH ATTACKER (modified by Calculating inverse only) === + float attackerRelation = DiplomacyHelpers.CalculateClanToKingdomRelation(clan, attacker); + float attackerRelationScore = -attackerRelation * 0.4f; // Good relations = less willing to fight + support += attackerRelationScore * calculatingInverseModifier; + + // === STRENGTH CONSIDERATION (modified by Calculating + Valor inverse) === + float allianceStrength = ourKingdom.CurrentTotalStrength + attackedAlly.CurrentTotalStrength; + float enemyStrength = attacker.CurrentTotalStrength; + + float strengthScore = 0f; + if (enemyStrength > allianceStrength * 3f) + { + strengthScore = HonorAllianceStrengthHopelessPenalty; // Hesitant to join hopeless war + } + else if (allianceStrength > enemyStrength * 2f) + { + strengthScore = HonorAllianceStrengthConfidentBonus; // Confident in victory + } + + // Calculating lords weigh odds carefully, Brave lords ignore odds + support += strengthScore * calculatingModifier * valorInverseModifier; + + // === CULTURE COMPATIBILITY (modified by Honor + Calculating inverse) === + // Hostile cultures with attacker = more support for war + float attackerCultureCompat = ReligionObjectHelper.CalculateCultureCompatibility( + ourKingdom.Culture?.StringId, attacker.Culture?.StringId); + float attackerCultureScore = -attackerCultureCompat * HonorAllianceAttackerCultureWeight; + + // Friendly cultures with ally = more support for honoring alliance + float allyCultureCompat = ReligionObjectHelper.CalculateCultureCompatibility( + ourKingdom.Culture?.StringId, attackedAlly.Culture?.StringId); + float allyCultureScore = allyCultureCompat * HonorAllianceAllyCultureWeight; + + support += (attackerCultureScore + allyCultureScore) * honorModifier * calculatingInverseModifier; + + return support; + } + + /// + /// Checks if a kingdom can consider forming alliances at all. + /// Returns false for Chaos/Greenskins or if at max alliances. + /// + public bool CanKingdomConsiderAlliance(Kingdom kingdom) + { + if (kingdom == null) + return false; + + var pantheon = DiplomacyHelpers.GetKingdomPantheon(kingdom); + if (pantheon == Pantheon.Chaos || pantheon == Pantheon.Greenskin) + return false; + + if (kingdom.AlliedKingdoms.Count >= MaxNumberOfAlliances) + return false; + + return true; + } + + /// + /// Gets potential alliance partners for a kingdom. + /// Undead factions prioritize other Undead, others use distance-based selection. + /// Returns top scored candidates that pass all filters. + /// + public List GetPotentialAlliancePartners(Kingdom kingdom, int maxCandidates = 3) + { + if (!CanKingdomConsiderAlliance(kingdom)) + return new List(); + + var myPantheon = DiplomacyHelpers.GetKingdomPantheon(kingdom); + var isUndead = myPantheon == Pantheon.Undead; + + // Get candidate kingdoms based on faction type + List candidateKingdoms = GetCandidateKingdoms(kingdom, isUndead); + + // Filter by alliance rules + var validPartners = candidateKingdoms + .Where(k => CanFormAlliance(kingdom, k)) + .Where(k => !kingdom.IsAtWarWith(k)) + .Where(k => !kingdom.IsAllyWith(k)) + .Where(k => k.AlliedKingdoms.Count < MaxNumberOfAlliances) + .ToList(); + + if (!validPartners.Any()) + return new List(); + + // Score and return top candidates + var scoredPartners = validPartners + .Select(k => new + { + Kingdom = k, + Score = GetScoreOfStartingAlliance(kingdom, k, kingdom.RulingClan, out _).ResultNumber + }) + .Where(x => x.Score > 50) + .OrderByDescending(x => x.Score) + .TakeRandom(maxCandidates) + .Select(x => x.Kingdom) + .ToList(); + + return scoredPartners; + } + + private List GetCandidateKingdoms(Kingdom kingdom, bool isUndead) + { + if (isUndead) + { + // Undead prioritize other Undead factions regardless of distance + var undeadKingdoms = Kingdom.All + .Where(k => k != kingdom && !k.IsEliminated) + .Where(k => DiplomacyHelpers.GetKingdomPantheon(k) == Pantheon.Undead) + .ToList(); + + if (undeadKingdoms.Any()) + return undeadKingdoms; + + // No other undead - use all kingdoms sorted by distance + return Kingdom.All + .Where(k => k != kingdom && !k.IsEliminated) + .OrderBy(k => DiplomacyHelpers.GetKingdomDistance(kingdom, k)) + .ToList(); + } + + // Normal factions - take 5 closest + return Kingdom.All + .Where(k => k != kingdom && !k.IsEliminated) + .OrderBy(k => DiplomacyHelpers.GetKingdomDistance(kingdom, k)) + .Take(5) + .ToList(); + } + } +} diff --git a/CSharpSourceCode/Models/TORDiplomacyModel.cs b/CSharpSourceCode/Models/TORDiplomacyModel.cs new file mode 100644 index 0000000..3e08134 --- /dev/null +++ b/CSharpSourceCode/Models/TORDiplomacyModel.cs @@ -0,0 +1,804 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using TaleWorlds.CampaignSystem; +using TaleWorlds.CampaignSystem.GameComponents; +using TaleWorlds.Core; +using TaleWorlds.LinQuick; +using TaleWorlds.Localization; +using TaleWorlds.CampaignSystem.CharacterDevelopment; +using TOR_Core.CampaignMechanics.Diplomacy; +using TOR_Core.CampaignMechanics.Religion; +using TOR_Core.CharacterDevelopment; +using TOR_Core.Extensions; +using TOR_Core.Utilities; + +namespace TOR_Core.Models +{ + public class TORDiplomacyModel : DefaultDiplomacyModel + { + // War declaration weights (scaled by 100 for DenarsToInfluence compatibility) + private const float WarCultureCompatibilityWeight = 5000f; + + // Territorial Integrity weights (hierarchical claims) + private const float DirectClaimWeight = 5000f; // My own settlement + private const float CulturalClaimWeight = 2500f; // My culture's settlement held by foreigners + private const float PantheonClaimWeight = 1000f; // My pantheon's settlement held by other pantheons + + // Lorewise Rivalry weights + private const float KarakReclamationWeight = 15000f; // Dwarfs vs Greenskins holding Karaks + private const float AntiChaosWeight = 12000f; // Good factions vs Chaos + private const float NordlandLaurelornRivalryWeight = 5000f; // Nordland vs Laurelorn (territorial dispute) + private const float DawiAsraiRivalryWeight = 8000f; // Dwarfs vs Wood Elves (War of the Beard grudge) + + // Territorial distance factor settings + private const float TerritorialDistanceScaling = 100f; // Distance at which factor is ~0.5 + private const float TerritorialMinDistanceFactor = 0.2f; // Minimum factor for very distant claims + + // Alliance candidate weights + private const float AllianceReligionCompatibilityWeight = 100f; + private const float AllianceCultureCompatibilityWeight = 75f; + private const float AllianceStrengthWeight = 50f; + private const float AllianceDistancePenaltyFactor = 0.01f; + private const float AllianceMinimumScoreThreshold = 50f; + + // Note: Trait modifiers and distance thresholds are now in DiplomacyHelpers + + public override int GetInfluenceCostOfProposingPeace(Clan proposingClan) => 150; + public override int GetInfluenceCostOfProposingWar(Clan proposingClan) => 150; + + public override float GetRelationIncreaseFactor(Hero hero1, Hero hero2, float relationChange) + { + var baseValue = base.GetRelationIncreaseFactor(hero1, hero2, relationChange); + var values = new ExplainedNumber(baseValue); + + var playerHero = hero1.IsHumanPlayerCharacter || hero2.IsHumanPlayerCharacter ? (hero1.IsHumanPlayerCharacter ? hero1 : hero2) : null; + if (playerHero == null) return baseValue; + + var conversationHero = !hero1.IsHumanPlayerCharacter || !hero2.IsHumanPlayerCharacter ? (!hero1.IsHumanPlayerCharacter ? hero1 : hero2) : null; + if (playerHero.HasAnyCareer()) + { + var choices = playerHero.GetAllCareerChoices(); + + if (choices.Contains("CourtleyPassive1")) + { + if (baseValue > 0) + { + var choice = TORCareerChoices.GetChoice("CourtleyPassive1"); + if (choice != null) + { + var value = choice.Passive.InterpretAsPercentage ? choice.Passive.EffectMagnitude / 100 : choice.Passive.EffectMagnitude; + values.AddFactor(value); + } + } + } + + if (choices.Contains("JustCausePassive4")) + { + if (baseValue > 0) + { + if (conversationHero != null && conversationHero.Culture.StringId == TORConstants.Cultures.BRETONNIA) + { + var choice = TORCareerChoices.GetChoice("JustCausePassive4"); + if (choice != null) + { + var value = choice.Passive.InterpretAsPercentage ? choice.Passive.EffectMagnitude / 100 : choice.Passive.EffectMagnitude; + values.AddFactor(value); + } + } + } + } + } + return values.ResultNumber; + } + + public override float GetScoreOfDeclaringWar(IFaction factionDeclaresWar, IFaction factionDeclaredWar, Clan evaluatingClan, out TextObject reason, bool includeReason = false) + { + reason = new TextObject("It is time to declare war!"); + + if (factionDeclaresWar is Kingdom declaringKingdom && factionDeclaredWar is Kingdom targetKingdom) + { + // Use offensive war count (excludes alliance/defensive wars) + int offensiveWars = GetOffensiveWarCount(declaringKingdom); + + // Maximum war limit - cannot declare more wars + if (offensiveWars >= TORConfig.NumMaxKingdomWars) + return -100000; + + // War count is acceptable - combine base game score with TOR factors + + // Add TOR custom scoring factors + float customScore = CalculateWarTargetScore(declaringKingdom, targetKingdom, evaluatingClan); + + // Small random variance to prevent identical scores + customScore += MBRandom.RandomInt(-25, 25); + + return customScore; + } + + return base.GetScoreOfDeclaringWar(factionDeclaresWar, factionDeclaredWar, evaluatingClan, out reason, includeReason); + } + + public override float GetScoreOfDeclaringPeace(IFaction factionDeclaresPeace, IFaction factionDeclaredPeace) + { + // Chaos really shouldn't be allowed to make peace + if (factionDeclaresPeace.Culture.StringId == TORConstants.Cultures.CHAOS || factionDeclaredPeace.Culture.StringId == TORConstants.Cultures.CHAOS) + { + return float.MinValue; + } + + if (factionDeclaresPeace is Kingdom kingdom && factionDeclaredPeace is Kingdom enemyKingdom) + { + int offensiveWars = GetOffensiveWarCount(kingdom); + int totalWars = kingdom.GetWarCount(); + + // If under minimum wars, don't seek peace + if (totalWars <= TORConfig.NumMinKingdomWars) return -100000; + + // If over maximum offensive wars, strongly favor peace + if (offensiveWars > TORConfig.NumMaxKingdomWars) return 100000; + + // If we have alliance wars pushing us over the limit, prioritize peace with NON-alliance enemies + if (totalWars > TORConfig.NumMaxKingdomWars) + { + var allianceWarBehavior = Campaign.Current?.GetCampaignBehavior(); + if (allianceWarBehavior != null) + { + bool isAllianceWar = allianceWarBehavior.IsAllianceWar(kingdom, enemyKingdom); + if (!isAllianceWar) + { + // Strongly favor peace with non-alliance enemies when stretched thin + return 50000; + } + // Less likely to abandon alliance wars + return -50000; + } + } + } + + return base.GetScoreOfDeclaringPeace(factionDeclaresPeace, factionDeclaredPeace); + } + + /// + /// Calculates TOR custom war scoring factors for a specific target kingdom. + /// Used both for target selection and for voting on war decisions. + /// + private float CalculateWarTargetScore(Kingdom declaringKingdom, Kingdom targetKingdom, Clan evaluatingClan) + { + // DISTANCE CHECK FIRST - prevent proxy wars against distant kingdoms + if (!DiplomacyHelpers.IsWithinWarDistance(declaringKingdom, targetKingdom)) + return -200000f; // Too far - don't even consider this war + + var leader = evaluatingClan?.Leader; + + // Get trait modifiers for the evaluating clan's leader + float honorModifier = DiplomacyHelpers.GetTraitModifier(leader, DefaultTraits.Honor); + float generosityInverseModifier = DiplomacyHelpers.GetInverseTraitModifier(leader, DefaultTraits.Generosity); + float calculatingModifier = DiplomacyHelpers.GetTraitModifier(leader, DefaultTraits.Calculating); + float calculatingInverseModifier = DiplomacyHelpers.GetInverseTraitModifier(leader, DefaultTraits.Calculating); + float mercyModifier = DiplomacyHelpers.GetTraitModifier(leader, DefaultTraits.Mercy); + float valorModifier = DiplomacyHelpers.GetTraitModifier(leader, DefaultTraits.Valor); + + // Traitorous behavior penalties (scaled by 100, modified by Honor) + // Dishonorable lords (-2 Honor) barely care about breaking agreements + float tradeAgreementPenalty = declaringKingdom.HasTradeAgreementWith(targetKingdom) ? -3000f * honorModifier : 0f; + float alliancePenalty = declaringKingdom.IsAllyWith(targetKingdom) ? -7000f * honorModifier : 0f; + + // Individual scoring factors + // Riches: Greedy lords (low Generosity) care more, Calculating lords amplify awareness + float richesScore = CalculateRichesScore(declaringKingdom, targetKingdom) * generosityInverseModifier * calculatingModifier; + // Relations: Cruel lords (-2 Mercy) ignore personal relationships + float relationScore = CalculateRelationScore(evaluatingClan, targetKingdom) * mercyModifier; + float distanceScore = CalculateDistanceScore(declaringKingdom, targetKingdom); + // Religion: Pragmatists (high Calculating) and cruel lords (low Mercy) ignore religion + float religionScore = CalculateReligionScore(declaringKingdom, targetKingdom) * calculatingInverseModifier * mercyModifier; + // Culture: Honorable lords respect cultural bonds, Pragmatists ignore cultural sentiment + float cultureScore = CalculateCultureScore(declaringKingdom, targetKingdom) * honorModifier * calculatingInverseModifier; + // Territorial: Greedy lords (low Generosity) protect direct claims, Merciful lords liberate kin + float territorialScore = CalculateTerritorialIntegrityScore(declaringKingdom, targetKingdom, leader); + // Rivalry: Brave lords (+2 Valor) are more motivated by glory/grudges + float rivalryScore = CalculateLorewiseRivalryScore(declaringKingdom, targetKingdom) * valorModifier; + // Tactical: Cruel lords strike harder when strong, Brave/Merciful lords ignore unfavorable odds, Calculating amplifies + float baseTacticalScore = CalculateTacticalScore(declaringKingdom, targetKingdom); + float mercyInverseModifier = DiplomacyHelpers.GetInverseTraitModifier(leader, DefaultTraits.Mercy); + float adjustedTactical = baseTacticalScore >= 0 + ? baseTacticalScore * valorModifier * mercyInverseModifier // Positive: brave + cruel hit harder + : baseTacticalScore / Math.Max(valorModifier * mercyModifier, 0.1f); // Negative: brave + merciful ignore bad odds + float tacticalScore = adjustedTactical * calculatingModifier; + + float totalScore = tradeAgreementPenalty + + alliancePenalty + + richesScore + + relationScore + + distanceScore + + religionScore + + cultureScore + + territorialScore + + rivalryScore + + tacticalScore; + + return totalScore; + } + + /// + /// Calculates lorewise rivalry bonuses based on Warhammer lore. + /// + private float CalculateLorewiseRivalryScore(Kingdom declaringKingdom, Kingdom targetKingdom) + { + float score = 0f; + + // 1. KARAK RECLAMATION - Dwarfs vs Greenskins holding Karaks + if (declaringKingdom.Culture?.StringId == TORConstants.Cultures.DAWI) + { + var targetPantheon = targetKingdom.Leader.GetDominantReligion().Pantheon; + if (targetPantheon == Pantheon.Greenskin) + { + // Count how many Karaks (dwarf settlements) the greenskins hold + int karakCount = 0; + foreach (var settlement in targetKingdom.Settlements) + { + if (settlement.Town == null) continue; + + + if(!settlement.IsDwarfKarak())continue; + + karakCount++; + } + score += karakCount * KarakReclamationWeight; + } + } + + // 2. ANTI-CHAOS - Good factions vs Chaos + var targetCulture = targetKingdom.Culture?.StringId; + if (targetCulture == TORConstants.Cultures.CHAOS) + { + var myPantheon = targetKingdom.Leader.GetDominantReligion().Pantheon; + // Good pantheons: Empire, Bretonnia, Dwarfs, Elves + if (myPantheon == Pantheon.Human || + myPantheon == Pantheon.Dwarven || + myPantheon == Pantheon.Elven) + { + // Count Chaos-held settlements (any settlement they hold is an affront) + int chaosSettlements = targetKingdom.Settlements.Count(s => s.Town != null); + score += chaosSettlements * AntiChaosWeight; + } + } + + // 3. WAR OF THE BEARD - Dwarfs vs Wood Elves (ancient grudge) + var myCulture = declaringKingdom.Culture?.StringId; + if ((myCulture == TORConstants.Cultures.DAWI && targetCulture == TORConstants.Cultures.ASRAI) || + (myCulture == TORConstants.Cultures.ASRAI && targetCulture == TORConstants.Cultures.DAWI)) + { + score += DawiAsraiRivalryWeight; + } + + switch (declaringKingdom.StringId) + { + // NORDLAND vs LAURELORN - Territorial forest dispute + case TORConstants.Factions.NORDLAND when + targetKingdom.StringId == TORConstants.Factions.LAURELORN: + case TORConstants.Factions.LAURELORN when + targetKingdom.StringId == TORConstants.Factions.NORDLAND: + score += NordlandLaurelornRivalryWeight; + break; + + // Wissenland and Montfort rivalry + case TORConstants.Factions.WISSENLAND when + targetKingdom.StringId == TORConstants.Factions.MONTFORT: + case TORConstants.Factions.WISSENLAND when + targetKingdom.StringId == TORConstants.Factions.MONTFORT: + score += NordlandLaurelornRivalryWeight; + break; + } + + return score; + } + + /// + /// Calculates territorial integrity score based on claims. + /// - Direct claims: Settlements that rightfully belong to the declaring kingdom (modified by Generosity inverse) + /// - Cultural claims: Settlements of same culture held by foreign culture (modified by Mercy) + /// - Pantheon claims: Settlements of same pantheon held by different pantheon (modified by Mercy) + /// Distance factor applied: nearby claims worth more than distant ones. + /// + private float CalculateTerritorialIntegrityScore(Kingdom declaringKingdom, Kingdom targetKingdom, Hero leader = null) + { + // Trait modifiers for territorial claims + float generosityInverseMod = DiplomacyHelpers.GetInverseTraitModifier(leader, DefaultTraits.Generosity); + float mercyMod = DiplomacyHelpers.GetTraitModifier(leader, DefaultTraits.Mercy); + float directClaimScore = 0f; + float culturalClaimScore = 0f; + float pantheonClaimScore = 0f; + + var myPantheon = declaringKingdom.Leader?.GetDominantReligion()?.Pantheon ?? Pantheon.Human; + var myMidSettlement = declaringKingdom.FactionMidSettlement; + + foreach (var settlement in targetKingdom.Settlements) + { + // Skip villages - only towns and castles + if (settlement.Town == null) + continue; + + string rightfulOwner = TORConstants.SettlementPrefixToFaction.GetRightfulOwner(settlement.StringId); + if (string.IsNullOrEmpty(rightfulOwner)) + continue; + + // Calculate distance factor - closer settlements get higher weight + float distanceFactor = CalculateSettlementDistanceFactor(myMidSettlement, settlement); + + // Direct Claim - this settlement belongs to us + if (rightfulOwner == declaringKingdom.StringId) + { + directClaimScore += distanceFactor; + continue; + } + + // Cultural Claim - settlement belongs to our culture but held by foreign culture + string rightfulCulture = TORConstants.SettlementPrefixToFaction.GetFactionCulture(rightfulOwner); + if (rightfulCulture != null && + rightfulCulture == declaringKingdom.Culture?.StringId && + targetKingdom.Culture?.StringId != rightfulCulture) + { + culturalClaimScore += distanceFactor; + continue; + } + + // Pantheon Claim - settlement belongs to our pantheon but held by different pantheon + var rightfulPantheon = ReligionObjectHelper.GetPantheon(rightfulCulture); + var holderPantheon = targetKingdom.Leader?.GetDominantReligion()?.Pantheon ?? Pantheon.Human; + if (rightfulPantheon == myPantheon && holderPantheon != rightfulPantheon) + { + pantheonClaimScore += distanceFactor; + } + } + + // Apply trait modifiers: Greedy lords care about direct claims, Merciful lords care about liberating kin + return directClaimScore * DirectClaimWeight * generosityInverseMod + + culturalClaimScore * CulturalClaimWeight * mercyMod + + pantheonClaimScore * PantheonClaimWeight * mercyMod; + } + + // Resource value tiers for missing resource bonus + private static readonly Dictionary ResourceValues = new() + { + // Strategic (high value) + { "iron_mine", 1500f }, + { "silver_mine", 1500f }, + // Valuable + { "salt_mine", 800f }, + { "europe_horse_ranch", 600f }, + { "steppe_horse_ranch", 600f }, + { "desert_horse_ranch", 600f }, + { "battanian_horse_ranch", 600f }, + { "sturgian_horse_ranch", 600f }, + { "vlandian_horse_ranch", 600f }, + // Useful materials + { "lumberjack", 400f }, + { "clay_mine", 300f }, + { "flax_plant", 300f }, + // Luxury + { "silk_plant", 500f }, + { "trapper", 400f }, + { "vineyard", 300f }, + // Food (common but still valuable) + { "wheat_farm", 200f }, + { "fisherman", 200f }, + { "cattle_farm", 200f }, + { "sheep_farm", 200f }, + { "swine_farm", 200f }, + { "date_farm", 150f }, + { "olive_trees", 150f }, + }; + + /// + /// Calculates score based on target kingdom's overall wealth. + /// Considers: village hearth, town prosperity, and missing resources. + /// + private float CalculateRichesScore(Kingdom declaringKingdom, Kingdom targetKingdom) + { + if (targetKingdom.Settlements == null || !targetKingdom.Settlements.Any()) + return 0f; + + float totalHearth = 0f; + float totalProsperity = 0f; + HashSet ourResources = new(); + HashSet theirResources = new(); + + // Gather our kingdom's resources + foreach (var settlement in declaringKingdom.Settlements) + { + if (settlement.IsVillage && settlement.Village?.VillageType != null) + ourResources.Add(settlement.Village.VillageType.StringId); + } + + // Gather target kingdom's wealth and resources + foreach (var settlement in targetKingdom.Settlements) + { + if (settlement.IsVillage && settlement.Village != null) + { + totalHearth += settlement.Village.Hearth; + if (settlement.Village.VillageType != null) + theirResources.Add(settlement.Village.VillageType.StringId); + } + else if (settlement.IsTown && settlement.Town != null) + { + totalProsperity += settlement.Town.Prosperity; + } + } + + // Calculate missing resource bonus (resources they have that we don't) + float missingResourceScore = 0f; + foreach (var resource in theirResources) + { + if (!ourResources.Contains(resource)) + { + missingResourceScore += ResourceValues.GetValueOrDefault(resource, 100f); + } + } + + // Weight components (scaled for war scoring) + // Hearth: ~200-800 per village, total could be 2000-8000 for a kingdom + float hearthScore = totalHearth * 0.5f; // 1000-4000 range + // Prosperity: ~3000-6000 per town, total could be 10000-30000 + float prosperityScore = totalProsperity * 0.1f; // 1000-3000 range + // Missing resources: 0-5000+ depending on what they have + + float totalScore = hearthScore + prosperityScore + missingResourceScore; + + // Competition factor: reduce if others are already at war with target + int competitorCount = targetKingdom.GetWarCount(); + float competitionFactor = Math.Max(0.2f, 1f - competitorCount * 0.25f); + + return totalScore * competitionFactor; + } + + /// + /// Calculates score based on evaluating clan's relations with target kingdom's lords. + /// Returns: -3000 (friendly relations) to +3000 (hostile relations) (scaled by 100) + /// + private float CalculateRelationScore(Clan evaluatingClan, Kingdom targetKingdom) + { + if (evaluatingClan?.Leader == null || targetKingdom == null) + return 0f; + + var clanLeader = evaluatingClan.Leader; + float totalRelation = 0f; + int lordCount = 0; + + foreach (var targetClan in targetKingdom.Clans) + { + if (targetClan.Leader != null && targetClan.Leader.IsAlive) + { + totalRelation += clanLeader.GetRelation(targetClan.Leader); + lordCount++; + } + } + + if (lordCount == 0) + return 1000f; // No lords = neutral + + float averageRelation = totalRelation / lordCount; + + // Map: +80 -> -3000, +-25 -> +1000, -80 -> +3000 (scaled by 100) + // Higher relation = less likely to declare war + if (averageRelation >= 80) + return -3000f; + if (averageRelation >= 25) + return -1500f; + if (averageRelation >= -25) + return 1000f; + if (averageRelation >= -80) + return 2000f; + return 3000f; + } + + /// + /// Calculates score based on distance between kingdoms. + /// Close targets get bonus, distant targets get penalty. + /// Neutral point at ~500 distance. (scaled by 100) + /// + private float CalculateDistanceScore(Kingdom declaringKingdom, Kingdom targetKingdom) + { + float distance = DiplomacyHelpers.GetKingdomDistance(declaringKingdom, targetKingdom); + + if (distance >= 300) + return -5000f; // No valid distance = strong penalty + + // Neutral distance where score is 0 + const float neutralDistance = 500f; + // Scale factor for how much distance affects score (scaled by 100) + const float distanceScaleFactor = 5f; + + // Below neutral: positive, above neutral: negative + // At 0 distance: +2500, at 500: 0, at 1000: -2500, at 1500: -5000 + float distanceScore = (neutralDistance - distance) * distanceScaleFactor; + + // Clamp to reasonable range + return Math.Max(-5000f, Math.Min(2500f, distanceScore)); + } + + /// + /// Calculates score based on religious compatibility. + /// Hostile religions are more attractive targets. + /// + private float CalculateReligionScore(Kingdom declaringKingdom, Kingdom targetKingdom) + { + float religionCompatibility = DiplomacyHelpers.GetReligionCompatibility(declaringKingdom, targetKingdom); + // Negative compatibility = more likely to declare war (scaled by 100) + return -religionCompatibility * TORConfig.DeclareWarScoreReligiousEffectMultiplier * 100f; + } + + /// + /// Calculates score based on cultural compatibility. + /// Hostile cultures are more attractive targets. + /// + private float CalculateCultureScore(Kingdom declaringKingdom, Kingdom targetKingdom) + { + float cultureCompat = DiplomacyHelpers.GetCultureCompatibility(declaringKingdom, targetKingdom); + // Negative compatibility = more likely to declare war + return -cultureCompat * WarCultureCompatibilityWeight; + } + + /// + /// Calculates tactical score based on war situations of both kingdoms. (scaled by 100) + /// - Strength comparison: weaker targets are more attractive + /// - Target already fighting wars = opportunity (they're stretched thin) + /// - We already fighting wars = penalty (we're stretched thin) + /// + private float CalculateTacticalScore(Kingdom declaringKingdom, Kingdom targetKingdom) + { + float score = 0f; + + // Strength comparison - weaker kingdoms are more attractive targets + // Uses alliance strength to account for defensive pacts + float ourStrength = declaringKingdom.GetAllianceTotalStrength(); + float theirStrength = Math.Max(targetKingdom.GetAllianceTotalStrength(), 1f); + float strengthRatio = ourStrength / theirStrength; + // ratio > 1 = we're stronger (bonus), ratio < 1 = they're stronger (penalty) + // Scaled by 100 for DenarsToInfluence compatibility + score += strengthRatio * TORConfig.DeclareWarScoreFactionStrengthMultiplier * 100f; + + // Target's current enemy count - opportunity to strike while they're distracted + // Bonus for each enemy, but plateau after 2 (scaled by 100) + // 0 enemies: 0, 1 enemy: +1500, 2+ enemies: +3000 (cap) + int targetEnemyCount = targetKingdom.GetWarCount(); + score += Math.Min(targetEnemyCount * 1500f, 3000f); + + // Our current war count - penalty for overextension (scaled by 100) + // Each offensive war we're fighting reduces appetite for new wars + int ourOffensiveWars = GetOffensiveWarCount(declaringKingdom); + // 0 wars: +2000 bonus (eager), 1 war: 0, 2 wars: -2000, 3+ wars: -4000 (cap) + score += (1 - ourOffensiveWars) * 2000f; + score = Math.Max(score, -4000f); // Floor at -4000 + + return score; + } + + /// + /// Gets the number of offensive wars (excluding alliance/defensive wars). + /// + private int GetOffensiveWarCount(Kingdom kingdom) + { + var allianceWarBehavior = Campaign.Current?.GetCampaignBehavior(); + if (allianceWarBehavior != null) + { + return allianceWarBehavior.GetOffensiveWarCount(kingdom); + } + // Fallback to total wars if behavior not available + return kingdom.GetWarCount(); + } + + public override float GetScoreOfMercenaryToJoinKingdom(Clan mercenaryClan, Kingdom kingdom) + { + var score = base.GetScoreOfMercenaryToJoinKingdom(mercenaryClan, kingdom); + + if (kingdom == null || mercenaryClan == null) return score; + + if (kingdom.Culture.StringId == TORConstants.Cultures.BRETONNIA) + { + if (mercenaryClan.Culture.StringId == TORConstants.Cultures.BRETONNIA) + { + score = +1000; + } + else + { + score = -10000; + } + } + + if (kingdom.Culture.StringId != TORConstants.Cultures.BRETONNIA && mercenaryClan.Culture.StringId == TORConstants.Cultures.BRETONNIA) + { + score = -10000; + } + + if (mercenaryClan.StringId == "tor_dog_clan_hero_curse" && (kingdom.Culture.StringId == TORConstants.Cultures.SYLVANIA || kingdom.Culture.StringId == TORConstants.Cultures.MOUSILLON || kingdom.Culture.StringId == TORConstants.Cultures.BRETONNIA)) + { + score = -10000; + } + + if (mercenaryClan.Culture.StringId == TORConstants.Cultures.DRUCHII) + { + score = -10000; + } + + return score; + } + + /// + /// Calculates the best war target candidate for a kingdom. + /// + /// BASE GAME (DefaultDiplomacyModel) considerations: + /// - War declaration permission checks (not at war, not recently made peace, etc.) + /// - Clan influences and kingdom decision mechanics + /// + /// TOR ADDITIONS - War Target Scoring Factors (higher score = more likely target): + /// 1. DISTANCE: Closer kingdoms score higher (configurable multiplier) + /// 2. STRENGTH: Weaker kingdoms score higher (our strength / their strength × multiplier) + /// 3. RELIGION: Hostile religions score higher (negative compatibility × multiplier) + /// 4. CULTURE: Hostile cultures score higher (negative compatibility × 50) + /// 5. TERRITORIAL INTEGRITY (Hierarchical claims): + /// - Direct Claims (+200 per settlement): Our faction's rightful settlements + /// - Cultural Claims (+80 per settlement): Our culture's settlements held by foreigners + /// - Pantheon Claims (+30 per settlement): Our pantheon's settlements held by other faiths + /// + public Kingdom GetWarDeclarationTargetCandidate(Kingdom consideringKingdom) + { + if (consideringKingdom == null) return null; + + var permissionModel = Campaign.Current?.Models?.KingdomDecisionPermissionModel; + if (permissionModel == null) return null; + + var kingdomCandidates = Kingdom.All.WhereQ(x => + !x.IsEliminated && + x != consideringKingdom && + permissionModel.IsWarDecisionAllowedBetweenKingdoms(consideringKingdom, x, out _) && + !consideringKingdom.IsAtWarWith(x) && + (x.GetStanceWith(consideringKingdom)?.PeaceDeclarationDate == null || + x.GetStanceWith(consideringKingdom)?.PeaceDeclarationDate.ElapsedDaysUntilNow > TORConfig.MinPeaceDays)).ToListQ(); + + if (kingdomCandidates.Count > 0) + { + // Calculate scores for all candidates using shared scoring method + Dictionary candidateScores = []; + + foreach (var targetKingdom in kingdomCandidates) + { + candidateScores[targetKingdom] = CalculateWarTargetScore(consideringKingdom, targetKingdom, consideringKingdom.RulingClan); + } + + var candidate = candidateScores.OrderByDescending(x => x.Value).Take(3).GetRandomElementInefficiently().Key; + return candidate; + } + return null; + } + + /// + /// Calculates the best peace target candidate for a kingdom. + /// + public Kingdom GetPeaceDeclarationTargetCandidate(Kingdom consideringKingdom, bool isEmergency = false) + { + if (consideringKingdom == null) return null; + + var permissionModel = Campaign.Current?.Models?.KingdomDecisionPermissionModel; + if (permissionModel == null) return null; + + var kingdomCandidates = Kingdom.All.WhereQ(x => + !x.IsEliminated && + x != consideringKingdom && + permissionModel.IsPeaceDecisionAllowedBetweenKingdoms(consideringKingdom, x, out _) && + consideringKingdom.IsAtWarWith(x) && + (x.GetStanceWith(consideringKingdom)?.WarStartDate.ElapsedDaysUntilNow > TORConfig.MinWarDays || + isEmergency)).ToListQ(); + + if (kingdomCandidates.Count > 0) + { + var kingdomListByStrength = kingdomCandidates.SelectQ(x => + new Tuple(x, x.GetAllianceTotalStrength())).ToListQ(); + + Dictionary candidateScores = []; + + foreach (var tuple in kingdomListByStrength) + { + // Prefer making peace with stronger enemies + candidateScores[tuple.Item1] = tuple.Item1.GetAllianceTotalStrength() / consideringKingdom.GetAllianceTotalStrength(); + } + + var maxvalue = candidateScores.Values.Max(); + if (maxvalue > 1) + { + var candidate = candidateScores.MaxBy(x => x.Value).Key; + return candidate; + } + } + return null; + } + + /// + /// Calculates the best alliance target candidate for a kingdom. + /// + public Kingdom GetAllianceDeclarationTargetCandidate(Kingdom consideringKingdom) + { + if (consideringKingdom == null) return null; + + var permissionModel = Campaign.Current?.Models?.KingdomDecisionPermissionModel; + if (permissionModel == null) return null; + + var kingdomCandidates = Kingdom.All.WhereQ(x => + !x.IsEliminated && + x != consideringKingdom && + !consideringKingdom.IsAtWarWith(x) && + !consideringKingdom.IsAllyWith(x) && + permissionModel.IsStartAllianceDecisionAllowedBetweenKingdoms(consideringKingdom, x, out _)).ToListQ(); + + if (kingdomCandidates.Count > 0) + { + Dictionary candidateScores = []; + + foreach (var candidate in kingdomCandidates) + { + float score = 0; + + // Religious similarity bonus + var religionScore = ReligionObjectHelper.CalculateReligionCompatibility( + candidate.Leader.GetDominantReligion(), + consideringKingdom.Leader.GetDominantReligion()); + score += religionScore * AllianceReligionCompatibilityWeight; + + // Cultural compatibility bonus + float cultureCompat = ReligionObjectHelper.CalculateCultureCompatibility( + consideringKingdom.Culture?.StringId, candidate.Culture?.StringId); + score += cultureCompat * AllianceCultureCompatibilityWeight; + + // Strength consideration - prefer allying with stronger kingdoms when threatened + var totalEnemyStrength = consideringKingdom.GetTotalEnemyAllianceStrength(); + if (totalEnemyStrength > consideringKingdom.CurrentTotalStrength) + { + score += candidate.CurrentTotalStrength / consideringKingdom.CurrentTotalStrength * AllianceStrengthWeight; + } + + // Distance consideration - prefer nearby allies + float distance = DiplomacyHelpers.GetKingdomDistance(consideringKingdom, candidate); + score -= distance * AllianceDistancePenaltyFactor; + + candidateScores[candidate] = score; + } + + if (candidateScores.Any()) + { + var bestCandidate = candidateScores.MaxBy(x => x.Value); + if (bestCandidate.Value > AllianceMinimumScoreThreshold) + { + return bestCandidate.Key; + } + } + } + return null; + } + + // Note: Trade agreements were removed in 1.3. Alliances are now handled by IAllianceCampaignBehavior. + // Note: GetKingdomDistance moved to DiplomacyHelpers + + /// + /// Calculates a distance factor for territorial claims. + /// Returns 1.0 for nearby settlements, decreasing to TerritorialMinDistanceFactor for distant ones. + /// Uses inverse formula: factor = 1 / (1 + distance / scaling) + /// + private float CalculateSettlementDistanceFactor(TaleWorlds.CampaignSystem.Settlements.Settlement fromSettlement, TaleWorlds.CampaignSystem.Settlements.Settlement toSettlement) + { + if (fromSettlement == null || toSettlement == null) + return 0; + + float distance = fromSettlement.Position.Distance(toSettlement.Position); + + // Inverse decay formula: closer = higher factor + // At distance 0: factor = 1.0 + // At distance = TerritorialDistanceScaling (500): factor ≈ 0.5 + // At very large distances: approaches TerritorialMinDistanceFactor + float rawFactor = 1f / (1f + distance / TerritorialDistanceScaling); + + // Scale to range [TerritorialMinDistanceFactor, 1.0] + return TerritorialMinDistanceFactor + rawFactor * (1f - TerritorialMinDistanceFactor); + } + + } +} \ No newline at end of file diff --git a/CSharpSourceCode/Models/TORKingdomDecisionPermissionModel.cs b/CSharpSourceCode/Models/TORKingdomDecisionPermissionModel.cs index 05e3d02..010009b 100644 --- a/CSharpSourceCode/Models/TORKingdomDecisionPermissionModel.cs +++ b/CSharpSourceCode/Models/TORKingdomDecisionPermissionModel.cs @@ -38,6 +38,14 @@ public override bool IsPeaceDecisionAllowedBetweenKingdoms( public override bool IsStartAllianceDecisionAllowedBetweenKingdoms(Kingdom kingdom1, Kingdom kingdom2, out TextObject reason) { + // Chaos cannot form alliances + if (kingdom1.Culture.StringId == TORConstants.Cultures.CHAOS || kingdom2.Culture.StringId == TORConstants.Cultures.CHAOS) + { + reason = TORTextHelper.GetTextObject("TOR_Alliance_Chaos_Forbidden", "The forces of Chaos cannot form alliances."); + return false; + } + + // Check for hostile religions if ((bool)(kingdom1?.Leader?.GetDominantReligion()?.HostileReligions?.Contains(kingdom2?.Leader?.GetDominantReligion()))) { reason = TORTextHelper.GetTextObject("TOR_Alliance_Religion_Conflict", "The dominant religions of the two kingdoms are hostile towards each other."); diff --git a/CSharpSourceCode/Models/TORTradeAgreementModel.cs b/CSharpSourceCode/Models/TORTradeAgreementModel.cs new file mode 100644 index 0000000..d418f74 --- /dev/null +++ b/CSharpSourceCode/Models/TORTradeAgreementModel.cs @@ -0,0 +1,466 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using TaleWorlds.CampaignSystem; +using TaleWorlds.CampaignSystem.CharacterDevelopment; +using TaleWorlds.CampaignSystem.GameComponents; +using TaleWorlds.Core; +using TaleWorlds.Library; +using TaleWorlds.Localization; +using TOR_Core.CampaignMechanics.Religion; +using TOR_Core.Extensions; +using TOR_Core.Utilities; + +namespace TOR_Core.Models +{ + /// + /// TOR Trade Agreement Model - Determines AI willingness to form trade agreements. + /// Trade agreements provide a diplomatic penalty when declaring war on trade partners. + /// + public class TORTradeAgreementModel : DefaultTradeAgreementModel + { + // Culture scoring + private const float SameCultureBonus = 15f; + private const float CultureCompatibilityWeight = 15f; + + // Religion scoring + private const float SameReligionBonus = 25f; + private const float HostileReligionPenalty = -30f; + private const float PantheonCompatibilityWeight = 10f; + private const float ReligionCompatibilityWeight = 15f; + + // Personality trait weights + private const float CalculatingTraitWeight = 10f; + private const float GenerosityTraitWeight = 8f; + private const float HonorTraitWeight = 6f; + + // Economic benefit weights + private const float ComplementaryResourceWeight = 3f; // Per unique resource they have that we lack + private const float FoodScarcityBonusWeight = 10f; // Bonus if we lack food and they have it + private const float ProsperityDifferenceWeight = 0.002f; // Slight bonus for trading with prosperous kingdoms + + // Faction-specific bonuses + private const float EonirTradeBonus = 10f; + + private const float WastelandBonus = 20f; + + private const float MontfortBonus = 20f; + + // Kingdom relations + private const float KingdomRelationWeight = 0.2f; // -100 to +100 relation = -20 to +20 score + + // Resource categories for trade value + private static readonly HashSet FoodResources = new() + { + "grain_farm", "cattle_farm", "sheep_farm", "swine_farm", + "fisherman", "date_farm", "olive_trees" + }; + + private static readonly HashSet LuxuryResources = new() + { + "silver_mine", "vineyard", "silk", "silkworm_farm", "fur_trader" + }; + + private static readonly HashSet MilitaryResources = new() + { + "iron_mine", "europe_horse_ranch", "steppe_horse_ranch", + "desert_horse_ranch", "lumberjack" + }; + + // Note: Distance thresholds are in DiplomacyHelpers (MaxTradeDistance = 600) + + public override int GetMaximumTradeAgreementCount(Kingdom kingdom) => 3; + + /// + /// Calculates TOR custom trade agreement scoring factors. + /// Used for AI decision making on trade agreements. + /// + public override float GetScoreOfStartingTradeAgreement( + Kingdom kingdom, + Kingdom targetKingdom, + Clan clan, + out TextObject explanation, + bool includeExplanation = false) + { + float baseScore = base.GetScoreOfStartingTradeAgreement( + kingdom, targetKingdom, clan, out explanation, includeExplanation); + + if (baseScore <= 0) + return 0f; + + // DISTANCE CHECK FIRST - prevent trade with distant kingdoms + if (!DiplomacyHelpers.IsWithinTradeDistance(kingdom, targetKingdom)) + return 0f; // Too far - don't even consider this trade agreement + + var leader = clan?.Leader; + + // Get trait modifiers for the evaluating clan's leader + float calculatingModifier = DiplomacyHelpers.GetTraitModifier(leader, DefaultTraits.Calculating); + float generosityModifier = DiplomacyHelpers.GetTraitModifier(leader, DefaultTraits.Generosity); + float honorModifier = DiplomacyHelpers.GetTraitModifier(leader, DefaultTraits.Honor); + float mercyModifier = DiplomacyHelpers.GetTraitModifier(leader, DefaultTraits.Mercy); + + // Individual scoring factors + float distanceScore = CalculateDistanceScore(kingdom, targetKingdom); + float cultureScore = CalculateCultureScore(kingdom, targetKingdom); + float religionScore = CalculateReligionScore(kingdom, targetKingdom); + float loreConsiderations = CalculateLoreConsiderations(kingdom, targetKingdom); + // Economic: What trade benefits can we gain? (affected by Calculating trait) + float economicScore = CalculateEconomicBenefitScore(kingdom, targetKingdom) * calculatingModifier; + // War alternative: Calculating lords consider if war would be more profitable + float warAlternativePenalty = CalculateWarAlternativePenalty(kingdom, targetKingdom, calculatingModifier); + + // Personality scoring + float calculatingScore = CalculateCalculatingScore(calculatingModifier); + float generosityScore = CalculateGenerosityScore(generosityModifier); + float honorScore = CalculateHonorScore(kingdom, targetKingdom, honorModifier); + + // Kingdom relations - average of all clan leaders' relations + float kingdomRelation = DiplomacyHelpers.CalculateKingdomToKingdomRelation(kingdom, targetKingdom); + float relationScore = kingdomRelation * KingdomRelationWeight * mercyModifier; + + float totalScore = baseScore + + distanceScore + + cultureScore + + religionScore + + loreConsiderations + + economicScore + + warAlternativePenalty + + calculatingScore + + generosityScore + + honorScore + + relationScore; + + return MBMath.ClampFloat(totalScore, 0f, 100f); + } + + /// + /// Calculates distance score for trade agreements. + /// Closer kingdoms are better trade partners (shorter routes). + /// Returns: 0 (close) to -60 (far but within range) + /// + private float CalculateDistanceScore(Kingdom kingdom, Kingdom targetKingdom) + { + float distance = DiplomacyHelpers.GetKingdomDistance(kingdom, targetKingdom); + + // No penalty for close kingdoms (below 300) + if (distance <= 300f) + return 0f; + + // Gradual penalty: -0.2 per unit beyond 300 + return (300f - distance) * 0.2f; + } + + /// + /// Calculates culture compatibility score for trade. + /// Same culture or compatible cultures trade more willingly. + /// Returns: -15 to +15 + /// + private float CalculateCultureScore(Kingdom kingdom, Kingdom targetKingdom) + { + if (DiplomacyHelpers.AreSameCulture(kingdom, targetKingdom)) + return SameCultureBonus; + + float compatibility = DiplomacyHelpers.GetCultureCompatibility(kingdom, targetKingdom); + return compatibility * CultureCompatibilityWeight; + } + + /// + /// Calculates religion compatibility score for trade. + /// Same religion or compatible pantheons increase trust. + /// Hostile religions severely penalize trade willingness. + /// Returns: -30 to +25 + /// + private float CalculateReligionScore(Kingdom kingdom, Kingdom targetKingdom) + { + // Same religion - strong bonus + if (DiplomacyHelpers.AreSameReligion(kingdom, targetKingdom)) + return SameReligionBonus; + + // Hostile religions - major penalty + if (DiplomacyHelpers.AreReligionsHostile(kingdom, targetKingdom)) + return HostileReligionPenalty; + + // Otherwise use compatibility + float pantheonCompat = DiplomacyHelpers.GetPantheonCompatibility(kingdom, targetKingdom); + float religionCompat = DiplomacyHelpers.GetReligionCompatibility(kingdom, targetKingdom); + + return pantheonCompat * PantheonCompatibilityWeight + religionCompat * ReligionCompatibilityWeight; + } + + /// + /// Calculates faction-specific trade bonuses. + /// Some factions are naturally more inclined to trade. + /// + private float CalculateLoreConsiderations(Kingdom kingdom, Kingdom targetKingdom) + { + // Eonir are natural merchants + if (kingdom.Culture?.StringId == TORConstants.Cultures.EONIR) + return EonirTradeBonus; + + // Marienburg is the biggest harbor in world - there are goods that nobody can aquire + if (targetKingdom.StringId == TORConstants.Factions.WASTELAND) + return WastelandBonus; + + // Montfort like to trade with humans + if (kingdom.StringId == TORConstants.Factions.MONTFORT && DiplomacyHelpers.GetKingdomPantheon(targetKingdom) == Pantheon.Human) + return MontfortBonus; + + return 0f; + } + + /// + /// Calculates economic benefit of trading with target kingdom. + /// Considers: complementary resources, food scarcity, prosperity. + /// Returns: 0 to ~30 (higher = more beneficial trade partner) + /// + private float CalculateEconomicBenefitScore(Kingdom kingdom, Kingdom targetKingdom) + { + if (kingdom.Settlements == null || targetKingdom.Settlements == null) + return 0f; + + // Gather our resources + HashSet ourResources = new(); + bool weHaveFood = false; + float ourProsperity = 0f; + + foreach (var settlement in kingdom.Settlements) + { + if (settlement.IsVillage && settlement.Village?.VillageType != null) + { + string resourceId = settlement.Village.VillageType.StringId; + ourResources.Add(resourceId); + if (FoodResources.Contains(resourceId)) + weHaveFood = true; + } + else if (settlement.IsTown && settlement.Town != null) + { + ourProsperity += settlement.Town.Prosperity; + } + } + + // Gather their resources + HashSet theirResources = new(); + bool theyHaveFood = false; + float theirProsperity = 0f; + + foreach (var settlement in targetKingdom.Settlements) + { + if (settlement.IsVillage && settlement.Village?.VillageType != null) + { + string resourceId = settlement.Village.VillageType.StringId; + theirResources.Add(resourceId); + if (FoodResources.Contains(resourceId)) + theyHaveFood = true; + } + else if (settlement.IsTown && settlement.Town != null) + { + theirProsperity += settlement.Town.Prosperity; + } + } + + float score = 0f; + + // Complementary resources - resources they have that we lack + int complementaryCount = 0; + foreach (var resource in theirResources) + { + if (!ourResources.Contains(resource)) + { + complementaryCount++; + // Bonus for luxury and military resources + if (LuxuryResources.Contains(resource) || MilitaryResources.Contains(resource)) + complementaryCount++; // Double count valuable resources + } + } + score += complementaryCount * ComplementaryResourceWeight; + + // Food scarcity bonus - if we lack food and they have it + if (!weHaveFood && theyHaveFood) + score += FoodScarcityBonusWeight; + + // Prosperity difference - slight bonus for trading with prosperous kingdoms + if (theirProsperity > ourProsperity) + { + float prosperityDiff = theirProsperity - ourProsperity; + score += prosperityDiff * ProsperityDifferenceWeight; + } + + return score; + } + + /// + /// Calculating lords consider if war would be more profitable than trade. + /// Uses actual war scoring to determine if conquest is preferable. + /// Returns: 0 (war not attractive) to -30 (war much better option) + /// + private float CalculateWarAlternativePenalty(Kingdom kingdom, Kingdom targetKingdom, float calculatingModifier) + { + // Only calculating lords think this way + if (calculatingModifier <= 1f) + return 0f; + + // Already at war - trade not relevant + if (kingdom.IsAtWarWith(targetKingdom)) + return 0f; + + // Get the actual war score using the diplomacy model + var diplomacyModel = Campaign.Current?.Models?.DiplomacyModel as TORDiplomacyModel; + if (diplomacyModel == null) + return 0f; + + float warScore = diplomacyModel.GetScoreOfDeclaringWar(kingdom, targetKingdom, kingdom.RulingClan, out _); + + // If war score is negative or low, trade is the better option + if (warScore <= 0f) + return 0f; + + // War looks attractive - penalty scales with how good war looks + // Normalize war score (typically ranges from 0 to ~50000 for very attractive wars) + float normalizedWarScore = Math.Min(warScore / 10000f, 3f); // Cap at 3x multiplier + + // Scale by how calculating the lord is + float penalty = -normalizedWarScore * 10f * (calculatingModifier - 1f); + + return Math.Max(penalty, -30f); // Cap at -30 + } + + /// + /// Calculating lords value trade for strategic economic benefit. + /// Returns: -7.5 to +7.5 + /// + private float CalculateCalculatingScore(float calculatingModifier) + { + return (calculatingModifier - 1f) * CalculatingTraitWeight; + } + + /// + /// Generous lords are more open to mutual partnerships. + /// Returns: -6 to +6 + /// + private float CalculateGenerosityScore(float generosityModifier) + { + return (generosityModifier - 1f) * GenerosityTraitWeight; + } + + /// + /// Honorable lords prefer trading with same background (culture/religion). + /// Only applies if kingdoms share culture or religion. + /// Returns: 0, or -4.5 to +4.5 + /// + private float CalculateHonorScore(Kingdom kingdom, Kingdom targetKingdom, float honorModifier) + { + bool sameBackground = DiplomacyHelpers.AreSameCulture(kingdom, targetKingdom) || + DiplomacyHelpers.AreSameReligion(kingdom, targetKingdom); + + if (!sameBackground) + return 0f; + + return (honorModifier - 1f) * HonorTraitWeight; + } + + private static TextObject _chaosCannotTradeText => TORTextHelper.GetTextObject("tor_trade_chaos", "The forces of Chaos do not engage in trade."); + private static TextObject _greenskinCannotTradeText => TORTextHelper.GetTextObject("tor_trade_greenskin", "Greenskins do not understand the concept of trade."); + + /// + /// Checks if two kingdoms can form a trade agreement. + /// Lore restrictions: Chaos and Greenskins cannot trade. + /// + public override bool CanMakeTradeAgreement( + Kingdom kingdom, + Kingdom other, + bool checkOtherSideTradeSupport, + out TextObject reason, + bool includeReason = false) + { + reason = includeReason ? TextObject.GetEmpty() : null; + + // Check lore restrictions for both kingdoms + var ourPantheon = DiplomacyHelpers.GetKingdomPantheon(kingdom); + var theirPantheon = DiplomacyHelpers.GetKingdomPantheon(other); + + if (ourPantheon == Pantheon.Chaos || theirPantheon == Pantheon.Chaos) + { + reason = _chaosCannotTradeText; + return false; + } + + if (ourPantheon == Pantheon.Greenskin || theirPantheon == Pantheon.Greenskin) + { + reason = _greenskinCannotTradeText; + return false; + } + + return base.CanMakeTradeAgreement(kingdom, other, checkOtherSideTradeSupport, out reason, includeReason); + } + + /// + /// Gets potential trade partners for a kingdom. + /// Undead factions prioritize other Undead, others use distance-based selection. + /// Returns top scored candidates that pass all filters. + /// + public List GetPotentialTradePartners(Kingdom kingdom, int maxCandidates = 3) + { + if (kingdom == null) + return new List(); + + var myPantheon = DiplomacyHelpers.GetKingdomPantheon(kingdom); + var isUndead = myPantheon == Pantheon.Undead; + + // Get candidate kingdoms based on faction type + List candidateKingdoms = GetCandidateKingdoms(kingdom, isUndead); + + // Filter by trade rules + var validPartners = candidateKingdoms + .Where(k => CanMakeTradeAgreement(kingdom, k, true, out _)) + .ToList(); + + if (!validPartners.Any()) + return new List(); + + // Score and return top candidates + var scoredPartners = validPartners + .Select(k => new + { + Kingdom = k, + Score = GetScoreOfStartingTradeAgreement(kingdom, k, kingdom.RulingClan, out _) + }) + .Where(x => x.Score > 0) + .OrderByDescending(x => x.Score) + .Take(maxCandidates) + .Select(x => x.Kingdom) + .ToList(); + + return scoredPartners; + } + + private List GetCandidateKingdoms(Kingdom kingdom, bool isUndead) + { + if (isUndead) + { + // Undead prioritize other Undead factions regardless of distance + var undeadKingdoms = Kingdom.All + .Where(k => k != kingdom && !k.IsEliminated) + .Where(k => DiplomacyHelpers.GetKingdomPantheon(k) == Pantheon.Undead) + .ToList(); + + if (undeadKingdoms.Any()) + return undeadKingdoms; + + // No other undead - use 10 closest kingdoms + return Kingdom.All + .Where(k => k != kingdom && !k.IsEliminated) + .OrderBy(k => DiplomacyHelpers.GetKingdomDistance(kingdom, k)) + .Take(10) + .ToList(); + } + + // Normal factions - take 5 closest + return Kingdom.All + .Where(k => k != kingdom && !k.IsEliminated) + .OrderBy(k => DiplomacyHelpers.GetKingdomDistance(kingdom, k)) + .Take(5) + .ToList(); + } + } +} diff --git a/CSharpSourceCode/SubModule.cs b/CSharpSourceCode/SubModule.cs index 79f6b65..6398239 100644 --- a/CSharpSourceCode/SubModule.cs +++ b/CSharpSourceCode/SubModule.cs @@ -172,6 +172,8 @@ protected override void InitializeGameStarter(Game game, IGameStarter starterObj starter.AddBehavior(new TORFactionDiscontinuationCampaignBehavior()); starter.AddBehavior(new ServeAsAHirelingCampaignBehavior()); starter.AddBehavior(new TORStartupBehavior()); + starter.AddBehavior(new TORKingdomDecisionsCampaignBehavior()); + starter.AddBehavior(new TORAllianceWarBehavior()); starter.AddBehavior(new TORArtisanDistrictCampaignBehavior()); starter.AddBehavior(new PriestBehavior()); starter.AddBehavior(new SkillTrainerBehavior()); @@ -240,6 +242,9 @@ protected override void OnGameStart(Game game, IGameStarter gameStarterObject) gameStarterObject.AddModel(new TORRaidModel()); gameStarterObject.AddModel(new TORBattleBannerBearersModel()); gameStarterObject.AddModel(new TORKingdomDecisionPermissionModel()); + gameStarterObject.AddModel(new TORDiplomacyModel()); + gameStarterObject.AddModel(new TORAllianceModel()); + gameStarterObject.AddModel(new TORTradeAgreementModel()); gameStarterObject.AddModel(new TORSettlementLoyaltyModel()); gameStarterObject.AddModel(new TORBattleRewardModel()); gameStarterObject.AddModel(new TORTroopSupplierModel()); diff --git a/CSharpSourceCode/TOR_Core.csproj b/CSharpSourceCode/TOR_Core.csproj index e633a13..009b14d 100644 --- a/CSharpSourceCode/TOR_Core.csproj +++ b/CSharpSourceCode/TOR_Core.csproj @@ -583,6 +583,9 @@ + + + @@ -710,6 +713,7 @@ + @@ -787,6 +791,10 @@ + + + + @@ -850,6 +858,7 @@ + diff --git a/CSharpSourceCode/Utilities/TORConsoleCommands.cs b/CSharpSourceCode/Utilities/TORConsoleCommands.cs index 765fc20..a5ca026 100644 --- a/CSharpSourceCode/Utilities/TORConsoleCommands.cs +++ b/CSharpSourceCode/Utilities/TORConsoleCommands.cs @@ -5,6 +5,7 @@ using System.Linq; using TaleWorlds.CampaignSystem; using TaleWorlds.CampaignSystem.Actions; +using TaleWorlds.CampaignSystem.CampaignBehaviors; using TaleWorlds.CampaignSystem.Party; using TaleWorlds.Core; using TaleWorlds.Library; @@ -139,39 +140,132 @@ public static string ShowCompanionPosition(List arguments) return result; } - [CommandLineFunctionality.CommandLineArgumentFunction("declare_peace", "tor")] - public static string DeclarePeace(List strings) + [CommandLineFunctionality.CommandLineArgumentFunction("set_alliance", "tor")] + public static string SetAlliance(List strings) { if (!CampaignCheats.CheckCheatUsage(ref CampaignCheats.ErrorType)) return CampaignCheats.ErrorType; - string str1 = "campaign.declare_peace [Faction1] | [Faction2]"; + + string usage = "tor.set_alliance [Kingdom1] | [Kingdom2]\nCreates an alliance between two kingdoms. Use kingdom StringIds (e.g., 'empire', 'bretonnia')."; + + if (CampaignCheats.CheckParameters(strings, 0) || CampaignCheats.CheckParameters(strings, 1) || CampaignCheats.CheckHelp(strings)) + return usage; + + List separatedNames = CampaignCheats.GetSeparatedNames(strings, true); + if (separatedNames.Count != 2) + return usage; + + string kingdom_str1 = separatedNames[0].ToLower().Replace(" ", ""); + string kingdom_str2 = separatedNames[1].ToLower().Replace(" ", ""); + + Kingdom faction1 = null; + Kingdom faction2 = null; + + foreach (var kingdom in Campaign.Current.Kingdoms) + { + if (kingdom_str1 == kingdom.StringId.ToLower()) + { + faction1 = kingdom; + } + if (kingdom_str2 == kingdom.StringId.ToLower()) + { + faction2 = kingdom; + } + } + + if (faction1 == null) + return "Kingdom not found: " + kingdom_str1 + "\n" + usage; + + if (faction2 == null) + return "Kingdom not found: " + kingdom_str2 + "\n" + usage; + + if (faction1 == faction2) + return "Cannot create alliance with self.\n" + usage; + + if (faction1.IsAtWarWith(faction2)) + return "Cannot create alliance - kingdoms are at war. Use tor.declare_peace first.\n"; + + if (faction1.IsAllyWith(faction2)) + return faction1.Name + " and " + faction2.Name + " are already allies.\n"; + + // Use the clean alliance method that removes native Call to War decisions + // This is preferred for TOR since we use our own HonorAllianceDecision system + faction1.SetAlliance(faction2); + + return "Alliance created between " + faction1.Name + " and " + faction2.Name + ".\n"; + } + + [CommandLineFunctionality.CommandLineArgumentFunction("break_alliance", "tor")] + public static string BreakAlliance(List strings) + { + if (!CampaignCheats.CheckCheatUsage(ref CampaignCheats.ErrorType)) + return CampaignCheats.ErrorType; + + string usage = "tor.break_alliance [Kingdom1] | [Kingdom2]\nBreaks an alliance between two kingdoms."; + if (CampaignCheats.CheckParameters(strings, 0) || CampaignCheats.CheckParameters(strings, 1) || CampaignCheats.CheckHelp(strings)) - return str1; + return usage; + List separatedNames = CampaignCheats.GetSeparatedNames(strings, true); if (separatedNames.Count != 2) - return str1; + return usage; + string kingdom_str1 = separatedNames[0].ToLower().Replace(" ", ""); string kingdom_str2 = separatedNames[1].ToLower().Replace(" ", ""); + Kingdom faction1 = null; Kingdom faction2 = null; + foreach (var kingdom in Campaign.Current.Kingdoms) { - if (kingdom_str1 == kingdom.StringId) + if (kingdom_str1 == kingdom.StringId.ToLower()) { faction1 = kingdom; } - if (kingdom_str2 == kingdom.StringId) + if (kingdom_str2 == kingdom.StringId.ToLower()) { faction2 = kingdom; } } - if (faction1 != null && faction2 != null) + if (faction1 == null) + return "Kingdom not found: " + kingdom_str1 + "\n" + usage; + + if (faction2 == null) + return "Kingdom not found: " + kingdom_str2 + "\n" + usage; + + if (!faction1.IsAllyWith(faction2)) + return faction1.Name + " and " + faction2.Name + " are not allies.\n"; + + // Use the IAllianceCampaignBehavior to break the alliance + var allianceBehavior = Campaign.Current.GetCampaignBehavior(); + if (allianceBehavior != null) { - MakePeaceAction.Apply(faction1, faction2); - return "Peace declared between " + (object)faction1.Name + " and " + (object)faction2.Name; + allianceBehavior.EndAlliance(faction1, faction2); + return "Alliance broken between " + faction1.Name + " and " + faction2.Name + ".\n"; } - return faction1 == null ? "Faction is not found: " + kingdom_str1 + "\n" + str1 : "Faction is not found: " + kingdom_str2; + + return "Error: Could not find alliance behavior.\n"; + } + + [CommandLineFunctionality.CommandLineArgumentFunction("list_kingdoms", "tor")] + public static string ListKingdoms(List strings) + { + if (!CampaignCheats.CheckCheatUsage(ref CampaignCheats.ErrorType)) + return CampaignCheats.ErrorType; + + if (CampaignCheats.CheckHelp(strings)) + return "Lists all kingdoms with their StringIds for use with alliance/war commands."; + + string result = "Available Kingdoms:\n"; + foreach (var kingdom in Campaign.Current.Kingdoms.OrderBy(k => k.StringId)) + { + string allies = kingdom.AlliedKingdoms.Count > 0 + ? " [Allies: " + string.Join(", ", kingdom.AlliedKingdoms.Select(a => a.StringId)) + "]" + : ""; + result += $" {kingdom.StringId} - {kingdom.Name}{allies}\n"; + } + return result; } [CommandLineFunctionality.CommandLineArgumentFunction("add_enchantment_blueprint", "tor")] diff --git a/CSharpSourceCode/Utilities/TORConstants.cs b/CSharpSourceCode/Utilities/TORConstants.cs index 813eb2d..f72c957 100644 --- a/CSharpSourceCode/Utilities/TORConstants.cs +++ b/CSharpSourceCode/Utilities/TORConstants.cs @@ -1,4 +1,6 @@ using System.Collections.Generic; +using TaleWorlds.CampaignSystem; +using TaleWorlds.CampaignSystem.Settlements; namespace TOR_Core.Utilities { @@ -52,5 +54,308 @@ public readonly struct Cultures ]; } + public readonly struct Factions + { + // Empire Provinces + public const string REIKLAND = "reikland"; + public const string MIDDENLAND = "middenland"; + public const string OSTLAND = "ostland"; + public const string OSTERMARK = "ostermark"; + public const string STIRLAND = "stirland"; + public const string HOCHLAND = "hochland"; + public const string AVERLAND = "averland"; + public const string WISSENLAND = "wissenland"; + public const string TALABECLAND = "talabecland"; + public const string NORDLAND = "nordland"; + public const string MOOT = "moot"; + + // Bretonnia Duchies + public const string COURONNE = "couronne"; + public const string AQUITAINE = "aquitaine"; + public const string ARTOIS = "artois"; + public const string BORDELEAUX = "bordeleaux"; + public const string GISOREUX = "gisoreux"; + public const string MONTFORT = "montfort"; + public const string PARRAVON = "parravon"; + public const string QUENELLES = "quenelles"; + public const string CARCASSONNE = "carcassonne"; + public const string BASTONNE = "bastonne"; + public const string BRIONNE = "brionne"; + public const string ANGUILLE = "anguille"; + public const string LYONESSE = "lyonesse"; + + // Vampire Counts + public const string SYLVANIA = "sylvania"; + public const string MOUSILLON = "mousillon"; + public const string NECRACHS = "necrachs"; + public const string BLOODDRAGONS = "blooddragons"; + + // Dwarf Holds + public const string KARAK_KADRIN = "karak_kadrin"; + public const string KARAK_NORN = "karak_norn"; + public const string KARAK_HIRN = "karak_hirn"; + public const string KARAK_IZOR = "karak_izor"; + public const string KARAK_AZGARAZ = "karak_azgaraz"; + public const string KARAK_KAFERKAMMAZ = "karak_kaferkammaz"; + public const string KARAK_ZIFLIN = "karak_ziflin"; + public const string KARAK_ZHUFBAR = "karak_zhufbar"; + public const string KARAK_GANTUK = "karak_gantuk"; + public const string KARAK_EKSFILAZ = "karak_eksfilaz"; + public const string KARAK_ANGAZHAR = "karak_angazhar"; + + // Elf Kingdoms + public const string ATHEL_LOREN = "athel_loren"; + public const string LAURELORN = "laurelorn"; + + // Greenskin Tribes + public const string BAD_AXES = "bad_axes"; + public const string BLACK_PIT = "black_pit"; + public const string BLACK_SUNZ = "black_sunz"; + public const string BLOODY_SPEARZ = "bloody_spearz"; + public const string BRASSKEEP = "brasskeep"; + public const string CROOKED_EYE = "crooked_eye"; + public const string DEFF_GRINDAZ = "deff_grindaz"; + public const string IRON_TRIBE = "iron_tribe"; + public const string MASSIF_CHOPPAS = "massif_choppas"; + public const string NECK_SNAPPERS = "neck_snappers"; + public const string RED_EYE = "red_eye"; + public const string SKULL_SMASHERZ = "skull_smasherz"; + + // Other + public const string REAVAZ = "reavaz"; + public const string WASTELAND = "wasteland"; + + public static readonly List AllEmpire = + [ + REIKLAND, MIDDENLAND, OSTLAND, OSTERMARK, STIRLAND, HOCHLAND, + AVERLAND, WISSENLAND, TALABECLAND, NORDLAND, MOOT + ]; + + public static readonly List AllBretonnia = + [ + COURONNE, AQUITAINE, ARTOIS, BORDELEAUX, GISOREUX, MONTFORT, + PARRAVON, QUENELLES, CARCASSONNE, BASTONNE, BRIONNE, ANGUILLE, LYONESSE + ]; + + public static readonly List AllVampire = + [ + SYLVANIA, MOUSILLON, NECRACHS, BLOODDRAGONS + ]; + + public static readonly List AllDwarfs = + [ + KARAK_KADRIN, KARAK_NORN, KARAK_HIRN, KARAK_IZOR, KARAK_AZGARAZ, + KARAK_KAFERKAMMAZ, KARAK_ZIFLIN, KARAK_ZHUFBAR, KARAK_GANTUK, + KARAK_EKSFILAZ, KARAK_ANGAZHAR + ]; + + public static readonly List AllElves = + [ + ATHEL_LOREN, LAURELORN + ]; + + public static readonly List AllGreenskins = + [ + BAD_AXES, BLACK_PIT, BLACK_SUNZ, BLOODY_SPEARZ, BRASSKEEP, + CROOKED_EYE, DEFF_GRINDAZ, IRON_TRIBE, MASSIF_CHOPPAS, + NECK_SNAPPERS, RED_EYE, SKULL_SMASHERZ + ]; + + public static readonly List All = + [ + // Empire + REIKLAND, MIDDENLAND, OSTLAND, OSTERMARK, STIRLAND, HOCHLAND, + AVERLAND, WISSENLAND, TALABECLAND, NORDLAND, MOOT, WASTELAND, + // Bretonnia + COURONNE, AQUITAINE, ARTOIS, BORDELEAUX, GISOREUX, MONTFORT, + PARRAVON, QUENELLES, CARCASSONNE, BASTONNE, BRIONNE, ANGUILLE, LYONESSE, + // Vampires + SYLVANIA, MOUSILLON, NECRACHS, BLOODDRAGONS, + // Dwarfs + KARAK_KADRIN, KARAK_NORN, KARAK_HIRN, KARAK_IZOR, KARAK_AZGARAZ, + KARAK_KAFERKAMMAZ, KARAK_ZIFLIN, KARAK_ZHUFBAR, KARAK_GANTUK, + KARAK_EKSFILAZ, KARAK_ANGAZHAR, + // Elves + ATHEL_LOREN, LAURELORN, + // Greenskins + BAD_AXES, BLACK_PIT, BLACK_SUNZ, BLOODY_SPEARZ, BRASSKEEP, + CROOKED_EYE, DEFF_GRINDAZ, IRON_TRIBE, MASSIF_CHOPPAS, + NECK_SNAPPERS, RED_EYE, SKULL_SMASHERZ, REAVAZ + + ]; + } + + /// + /// Maps settlement prefix codes (e.g., "RL", "ST", "AV") to their rightful faction StringIds. + /// Used for territorial integrity calculations in war scoring. + /// + public static class SettlementPrefixToFaction + { + private static readonly Dictionary _prefixMap = new() + { + // Empire Provinces + { "RL", Factions.REIKLAND }, + { "ML", Factions.MIDDENLAND }, + { "OL", Factions.OSTLAND }, + { "OM", Factions.OSTERMARK }, + { "ST", Factions.STIRLAND }, + { "HL", Factions.HOCHLAND }, + { "AV", Factions.AVERLAND }, + { "WI", Factions.WISSENLAND }, + { "TB", Factions.TALABECLAND }, + { "NL", Factions.NORDLAND }, + { "MT", Factions.MOOT }, + + // Bretonnia Duchies + { "CO", Factions.COURONNE }, + { "AQ", Factions.AQUITAINE }, + { "AS", Factions.ARTOIS }, + { "BL", Factions.BORDELEAUX }, + { "GX", Factions.GISOREUX }, + { "MO", Factions.MONTFORT }, + { "PA", Factions.PARRAVON }, + { "QU", Factions.QUENELLES }, + { "CC", Factions.CARCASSONNE }, + { "BA", Factions.BASTONNE }, + { "BE", Factions.BRIONNE }, + { "LA", Factions.ANGUILLE }, + { "LY", Factions.LYONESSE }, + + // Vampire Counts + { "SY", Factions.SYLVANIA }, + { "MS", Factions.MOUSILLON }, + // Note: Necrachs and Blooddragons share "BK" prefix with Brasskeep + // BK1 = Blooddragons, BK2 = Brasskeep - cannot map cleanly + + // Dwarf Holds + { "KK", Factions.KARAK_KADRIN }, + { "NO", Factions.KARAK_NORN }, + { "KH", Factions.KARAK_HIRN }, + { "KI", Factions.KARAK_IZOR }, + { "AZ", Factions.KARAK_AZGARAZ }, + { "KF", Factions.KARAK_KAFERKAMMAZ }, + { "ZI", Factions.KARAK_ZIFLIN }, + { "ZH", Factions.KARAK_ZHUFBAR }, + { "KG", Factions.KARAK_GANTUK }, + { "EZ", Factions.KARAK_EKSFILAZ }, + { "AN", Factions.KARAK_ANGAZHAR }, + + // Elf Kingdoms + { "AL", Factions.ATHEL_LOREN }, + { "LL", Factions.LAURELORN }, + + // Greenskin Tribes + { "BX", Factions.BAD_AXES }, + { "BP", Factions.BLACK_PIT }, + { "BZ", Factions.BLACK_SUNZ }, + { "BS", Factions.BLOODY_SPEARZ }, + { "BK", Factions.BRASSKEEP }, + { "CE", Factions.CROOKED_EYE }, + { "DG", Factions.DEFF_GRINDAZ }, + { "IT", Factions.IRON_TRIBE }, + { "MC", Factions.MASSIF_CHOPPAS }, + { "NS", Factions.NECK_SNAPPERS }, + { "RE", Factions.RED_EYE }, + { "SM", Factions.SKULL_SMASHERZ }, + + // Other Greenskin + { "RZ", Factions.REAVAZ }, + + // Other + { "WA", Factions.WASTELAND }, + }; + + /// + /// Gets the faction StringId that a settlement with the given prefix belongs to. + /// + /// The settlement ID (e.g., "town_RL1", "castle_ST2") + /// The faction StringId that historically owns this settlement, or null if not found + public static string GetRightfulOwner(string settlementId) + { + if (string.IsNullOrEmpty(settlementId)) + return null; + + // Extract the prefix (two letters after "town_" or "castle_") + // Format: town_XX# or castle_XX# + string prefix = null; + + if (settlementId.StartsWith("town_") && settlementId.Length >= 7) + { + prefix = settlementId.Substring(5, 2).ToUpper(); + } + else if (settlementId.StartsWith("castle_") && settlementId.Length >= 9) + { + prefix = settlementId.Substring(7, 2).ToUpper(); + } + + if (prefix != null && _prefixMap.TryGetValue(prefix, out string factionId)) + { + return factionId; + } + + return null; + } + + /// + /// Checks if a settlement belongs originally to a specific faction based on its prefix. + /// + public static bool SettlementBelongsOriginallyToFaction(Settlement settlement, Kingdom faction) + { + var id = ""; + var kingdom=""; + if (settlement != null) + { + id = settlement.StringId; + } + var rightfulOwner = GetRightfulOwner(id); + return rightfulOwner != null && rightfulOwner == faction.StringId; + } + + /// + /// Gets the culture StringId for a faction StringId. + /// + public static string GetFactionCulture(string factionId) + { + if (string.IsNullOrEmpty(factionId)) + return null; + + // Empire provinces + if (Factions.AllEmpire.Contains(factionId)) + return Cultures.EMPIRE; + + // Bretonnia duchies + if (Factions.AllBretonnia.Contains(factionId)) + return Cultures.BRETONNIA; + + // Vampire Counts - mixed cultures + if (factionId == Factions.SYLVANIA) + return Cultures.SYLVANIA; + if (factionId == Factions.MOUSILLON) + return Cultures.MOUSILLON; + if (factionId == Factions.NECRACHS || factionId == Factions.BLOODDRAGONS) + return Cultures.SYLVANIA; // Generic vampire culture + + // Dwarf Holds + if (Factions.AllDwarfs.Contains(factionId)) + return Cultures.DAWI; + + // Elf Kingdoms + if (factionId == Factions.ATHEL_LOREN) + return Cultures.ASRAI; + if (factionId == Factions.LAURELORN) + return Cultures.EONIR; + + // Greenskin Tribes + if (Factions.AllGreenskins.Contains(factionId) || factionId == Factions.REAVAZ) + return Cultures.GREENSKIN; + + // Wasteland + if (factionId == Factions.WASTELAND) + return Cultures.EMPIRE; + + return null; + } + } + } } \ No newline at end of file diff --git a/CSharpSourceCode/Utilities/TORNotificationHelper.cs b/CSharpSourceCode/Utilities/TORNotificationHelper.cs new file mode 100644 index 0000000..a9002fa --- /dev/null +++ b/CSharpSourceCode/Utilities/TORNotificationHelper.cs @@ -0,0 +1,83 @@ +using System.Linq; +using TaleWorlds.CampaignSystem; +using TaleWorlds.CampaignSystem.Party; +using TOR_Core.Extensions; +using TOR_Core.Models; + +namespace TOR_Core.Utilities +{ + public static class TORNotificationHelper + { + private const float MaxRelevantDistance = 75f; + + public static bool IsPartyRelevantToPlayer(PartyBase partyBase) + { + if (partyBase != null && PartyBase.MainParty != null) + { + var distance = partyBase.Position.Distance(PartyBase.MainParty.Position); + if (distance <= MaxRelevantDistance) + return true; + } + return false; + } + + public static bool IsHeroRelevantToPlayer(Hero hero) + { + if (hero == null) return false; + if (hero == Hero.MainHero) return true; + if (hero.Clan == Clan.PlayerClan) return true; + + if (hero.Clan?.Kingdom != null) + { + if (hero.Clan.Kingdom == Clan.PlayerClan?.Kingdom) return true; + if (IsKingdomRelevantToPlayer(hero.Clan.Kingdom)) return true; + } + + if (hero.PartyBelongedTo != null) + { + if (IsPartyRelevantToPlayer(hero.PartyBelongedTo.Party)) + return true; + } + return false; + } + + public static bool IsKingdomRelevantToPlayer(Kingdom kingdom) + { + if (kingdom == null) return false; + + var playerKingdom = Clan.PlayerClan?.Kingdom; + if (playerKingdom == null) return false; + if (kingdom == playerKingdom) return true; + + var distance = DiplomacyHelpers.GetKingdomDistance(kingdom, playerKingdom); + if (distance <= MaxRelevantDistance) + return true; + + if (playerKingdom.IsAtWarWith(kingdom)) return true; + if (playerKingdom.IsAllyWith(kingdom)) return true; + if (playerKingdom.HasTradeAgreementWith(kingdom)) return true; + + return false; + } + + public static bool AreKingdomsRelevantToPlayer(params Kingdom[] kingdoms) + { + return kingdoms.Any(IsKingdomRelevantToPlayer); + } + + public static bool IsFactionRelevantToPlayer(IFaction faction) + { + if (faction == null) return false; + if (faction == Clan.PlayerClan) return true; + if (faction == Clan.PlayerClan?.Kingdom) return true; + + if (faction is Kingdom kingdom) + return IsKingdomRelevantToPlayer(kingdom); + + if (faction is Clan clan) + return clan.Kingdom != null && IsKingdomRelevantToPlayer(clan.Kingdom); + + return false; + } + } +} \ No newline at end of file diff --git a/ModuleData/tor_custom_xmls/tor_config.xml b/ModuleData/tor_custom_xmls/tor_config.xml index cfe2169..bf5ab41 100644 --- a/ModuleData/tor_custom_xmls/tor_config.xml +++ b/ModuleData/tor_custom_xmls/tor_config.xml @@ -8,16 +8,16 @@ NumberOfMaximumLooterPartiesEarly="100" NumberOfMaximumLooterParties="200" NumberOfMaximumLooterPartiesLate="300" - NumberOfMaximumBanditPartiesAroundEachHideout="8" + NumberOfMaximumBanditPartiesAroundEachHideout="4" NumberOfMaximumBanditPartiesInEachHideout="2" - NumberOfInitialHideoutsAtEachBanditFaction="10" - NumberOfMaximumHideoutsAtEachBanditFaction="30" + NumberOfInitialHideoutsAtEachBanditFaction="5" + NumberOfMaximumHideoutsAtEachBanditFaction="20" MaximumNumberOfCareerPerkPoints="30" DeclareWarScoreDistanceMultiplier="50" DeclareWarScoreFactionStrengthMultiplier="0.5" DeclareWarScoreReligiousEffectMultiplier="5" NumMinKingdomWars="0" - NumMaxKingdomWars="3" + NumMaxKingdomWars="5" MinPeaceDays="20" MinWarDays="40" AIGoldAdjustmentAmount="20000" diff --git a/ModuleData/tor_religions.xml b/ModuleData/tor_religions.xml index 8348360..78d9258 100644 --- a/ModuleData/tor_religions.xml +++ b/ModuleData/tor_religions.xml @@ -1,7 +1,7 @@ - + @@ -29,7 +29,7 @@ - + @@ -60,7 +60,7 @@ - + @@ -88,7 +88,7 @@ - + @@ -112,7 +112,7 @@ - + @@ -138,7 +138,7 @@ - + @@ -164,7 +164,7 @@ - + @@ -189,7 +189,7 @@ - + @@ -218,7 +218,7 @@ - + @@ -242,7 +242,7 @@ - + @@ -264,7 +264,7 @@ - + @@ -288,7 +288,7 @@ - + @@ -310,7 +310,7 @@ - + @@ -335,7 +335,7 @@ - + @@ -351,7 +351,7 @@ - + @@ -370,7 +370,7 @@ - + @@ -387,7 +387,7 @@ - + @@ -408,7 +408,7 @@ - + @@ -424,7 +424,7 @@ - + @@ -441,7 +441,7 @@ - + @@ -458,7 +458,7 @@ - + @@ -476,7 +476,7 @@ - + @@ -506,7 +506,7 @@ - + diff --git a/ModuleData/tor_strings.xml b/ModuleData/tor_strings.xml index f7e0cf2..aab88c2 100644 --- a/ModuleData/tor_strings.xml +++ b/ModuleData/tor_strings.xml @@ -5051,4 +5051,22 @@ If a Rune ability is available to cast, the next Rune cast in 5 seconds shall be + + + + + + + + + + + + + + + + + + diff --git a/bin/Win64_Shipping_Client/TOR_Core.dll b/bin/Win64_Shipping_Client/TOR_Core.dll index 5cd89d1..60cd8a7 100644 Binary files a/bin/Win64_Shipping_Client/TOR_Core.dll and b/bin/Win64_Shipping_Client/TOR_Core.dll differ diff --git a/bin/Win64_Shipping_wEditor/TOR_Core.dll b/bin/Win64_Shipping_wEditor/TOR_Core.dll index 5cd89d1..60cd8a7 100644 Binary files a/bin/Win64_Shipping_wEditor/TOR_Core.dll and b/bin/Win64_Shipping_wEditor/TOR_Core.dll differ