diff --git a/.gitignore b/.gitignore index 59513826..61e43d76 100644 --- a/.gitignore +++ b/.gitignore @@ -350,3 +350,5 @@ EmAssetPackages/ Tools/TranslationTool/translation_issues_report_* .claude tmpclaude-* +CLAUDE.md +INDEX.md diff --git a/CSharpSourceCode/CampaignMechanics/Crafting/EnchanterTownBehavior.cs b/CSharpSourceCode/CampaignMechanics/Crafting/EnchanterTownBehavior.cs index 83471ac1..96f146a9 100644 --- a/CSharpSourceCode/CampaignMechanics/Crafting/EnchanterTownBehavior.cs +++ b/CSharpSourceCode/CampaignMechanics/Crafting/EnchanterTownBehavior.cs @@ -773,7 +773,7 @@ bool EnchanterCondition(string culture) void OpenEnchantmentShop(List prefixList, string culture) { - EnchantmentHelper.OpenEnchantmentRecipeShop(prefixList, culture, false); + EnchantmentShopHelper.OpenEnchantmentRecipeShop(prefixList, culture, false); } } } diff --git a/CSharpSourceCode/CampaignMechanics/Crafting/EnchantmentHelper.cs b/CSharpSourceCode/CampaignMechanics/Crafting/EnchantmentHelper.cs index fded237e..05632964 100644 --- a/CSharpSourceCode/CampaignMechanics/Crafting/EnchantmentHelper.cs +++ b/CSharpSourceCode/CampaignMechanics/Crafting/EnchantmentHelper.cs @@ -1,22 +1,22 @@ using HarmonyLib; -using NLog; -using System; using System.Collections.Generic; using System.Linq; using TaleWorlds.CampaignSystem; using TaleWorlds.Core; -using TaleWorlds.Core.ImageIdentifiers; using TaleWorlds.LinQuick; using TaleWorlds.Localization; using TaleWorlds.ObjectSystem; -using TOR_Core.AbilitySystem.Spells; using TOR_Core.CharacterDevelopment; using TOR_Core.Extensions; -using TOR_Core.Items; using TOR_Core.Utilities; namespace TOR_Core.CampaignMechanics.Crafting; +/// +/// Enchantment blueprint data and item creation: what blueprints exist, who in the party +/// is eligible to learn one, and building the actual enchanted . +/// For the town-service shop UI built on top of this data, see . +/// public static class EnchantmentHelper { public static ItemObject CreateEnchantedItem(ItemObject original, List traits = null, string newName = null, bool playerCrafted = false, ItemModifier itemModifier = null) @@ -53,7 +53,7 @@ private static ItemObject CreateItemCopy(ItemObject copyFrom, string newId, stri return newItem; } - private static List GetBlueprintItems(List prefixList) + internal static List GetBlueprintItems(List prefixList) { return MBObjectManager.Instance.GetObjectTypeList() .Where(item => @@ -65,7 +65,7 @@ private static List GetBlueprintItems(List prefixList) .ToList(); } - private static bool TryGetBlueprintData(ItemObject item, out string blueprintId, out SkillObject requiredSkill, out int requiredSkillValue, out string restriction) + internal static bool TryGetBlueprintData(ItemObject item, out string blueprintId, out SkillObject requiredSkill, out int requiredSkillValue, out string restriction) { blueprintId = null; requiredSkill = null; @@ -104,19 +104,13 @@ private static bool TryGetBlueprintData(ItemObject item, out string blueprintId, return true; } - private static bool IsBlueprintCurrentlyApplicableToParty(string blueprintId) - { - if (Hero.MainHero.PartyBelongedTo.GetMemberHeroes().Any(hero => hero.HasKnownEnchantmentBlueprint(blueprintId))) - { - return true; - } + internal static bool IsBlueprintKnownByParty(string blueprintId) => Hero.MainHero.PartyBelongedTo.GetMemberHeroes().Any(hero => hero.HasKnownEnchantmentBlueprint(blueprintId)); - return Hero.MainHero.PartyBelongedTo.ItemRoster.Any(rosterElement => + internal static bool IsBlueprintInInventory(string blueprintId) => Hero.MainHero.PartyBelongedTo.ItemRoster.Any(rosterElement => TryGetBlueprintData(rosterElement.EquipmentElement.Item, out var inventoryBlueprintId, out _, out _, out _) && inventoryBlueprintId == blueprintId); - } - private static List GetEligibleHeroesForBlueprint(string blueprintId, SkillObject requiredSkill, int requiredSkillValue, string restriction, bool requireRequiredSkill) + internal static List GetEligibleHeroesForBlueprint(string blueprintId, SkillObject requiredSkill, int requiredSkillValue, string restriction, bool requireRequiredSkill) { var eligibleHeroes = new List(); @@ -158,7 +152,7 @@ public static bool HasAnyLearnableEnchantmentRecipe(List prefixList) continue; } - if (IsBlueprintCurrentlyApplicableToParty(blueprintId)) + if (IsBlueprintKnownByParty(blueprintId) || IsBlueprintInInventory(blueprintId)) { continue; } @@ -171,216 +165,4 @@ public static bool HasAnyLearnableEnchantmentRecipe(List prefixList) return false; } - - public static void OpenEnchantmentRecipeShop(List prefixList, string culture, bool blessings = false) - { - var blueprints = GetBlueprintItems(prefixList); - - var list = new List(); - foreach (var item in blueprints) - { - if (!TryGetBlueprintData(item, out var blueprintId, out var requiredSkill, out var requiredSkillValue, out var restriction)) - { - continue; - } - - if (IsBlueprintCurrentlyApplicableToParty(blueprintId)) - { - continue; - } - - if (GetEligibleHeroesForBlueprint(blueprintId, requiredSkill, requiredSkillValue, restriction, false).Any()) - { - list.Add(item); - } - } - - var selectableItems = new List(); - foreach (var item in list) - { - - var trait = item.GetTraits().FirstOrDefault(); - if (trait == null) - { - TORCommon.Log($"Enchantment blueprint {item.StringId} has no traits. Skipping this item.", LogLevel.Error); - continue; - } - - if (trait.OnInventoryUseScript == null) - { - TORCommon.Log($"Enchantment blueprint {item.StringId} has no inventory use script. Skipping this item.", LogLevel.Error); - continue; - } - - var arguments = trait.OnInventoryUseScript.InventoryScriptArguments; - if (arguments == null || arguments.Count < 3) - { - var argCount = arguments?.Count ?? 0; - TORCommon.Log($"Enchantment blueprint {item.StringId} has insufficient arguments (expected at least 3, got {argCount})", LogLevel.Error); - continue; - } - - var included = false; - - var hintText = new TextObject("{TRAIT_EFFECT}\n\n{REQUIREMENT_TEXT}\n\n{COMPLETE_COST}"); - - if (!TryGetBlueprintData(item, out var id, out var skill, out var skillValue, out var restriction)) - { - continue; - } - - var eligableHeroes = GetEligibleHeroesForBlueprint(id, skill, skillValue, restriction, false); - if (!eligableHeroes.Any()) - { - continue; - } - - var learnableHeroes = eligableHeroes.Where(hero => hero.GetSkillValue(skill) >= skillValue).ToList(); - var enabled = learnableHeroes.Any(); - - var requirementPrefix = ""; - - if (!string.IsNullOrEmpty(restriction)) - { - var lore = LoreObject.GetAll().FirstOrDefault(x => x.StringId == restriction); - if (lore != null) - { - requirementPrefix = "This enchantment is bound to the Lore of " + lore.Name + ". "; - } - else - { - requirementPrefix = "This enchantment requires " + restriction + ". "; - } - } - - if (!enabled) - { - if (eligableHeroes.Count == 1) - { - var hero = eligableHeroes[0]; - if (hero == Hero.MainHero) - { - hintText.SetTextVariable("REQUIREMENT_TEXT", requirementPrefix + "You don't have enough " + skill.Name + ". Requires " + skillValue + "."); - } - else - { - hintText.SetTextVariable("REQUIREMENT_TEXT", requirementPrefix + hero.Name + " doesn't have enough " + skill.Name + ". Requires " + skillValue + "."); - } - } - else - { - hintText.SetTextVariable("REQUIREMENT_TEXT", requirementPrefix + "None of your eligible characters have enough " + skill.Name + ". Requires " + skillValue + "."); - } - } - else - { - hintText.SetTextVariable("REQUIREMENT_TEXT", ""); - } - - var crCost = 0; - var goldCost = 0; - var cr = Hero.MainHero.GetCultureSpecificCustomResource(); - var factor = cr.GetCustomResourceGeneralizedFactor(); - crCost = (int)factor * skillValue; - - goldCost = (int)item.Value; - - if (enabled) - { - if (!hintText.GetVariableValue("REQUIREMENT_TEXT", out var requirementText) || - requirementText != null && requirementText.ToString().IsEmpty()) - { - if (crCost >= Hero.MainHero.GetCultureSpecificCustomResourceValue()) - { - enabled = false; - - hintText.SetTextVariable("REQUIREMENT_TEXT", "Not enough {CUSTOMRESOURCE}"); - } - - if (goldCost >= Hero.MainHero.Gold) - { - enabled = false; - - hintText.SetTextVariable("REQUIREMENT_TEXT", "Not enough {GOLD_ICON}."); - } - } - - } - - - var underlyingTrait = ItemTrait.All.FirstOrDefault(x => x.ItemTraitStringId == id); - - if (underlyingTrait != null) - { - string typeRestriction = GameTexts.FindText("tor_enchantmentshop_restriction", underlyingTrait.ValidItemType.ToString()).ToString(); - GameTexts.SetVariable("VALIDTYPE_RESTRICTION", typeRestriction); - } - - - - if (enabled) - { - - hintText = new TextObject(trait.ItemTraitDescription + "\n {GOLD_VALUE}{GOLD_ICON} , {CR_VALUE}{CUSTOMRESOURCE},\n {VALIDTYPE_RESTRICTION}"); - } - hintText.SetTextVariable("TRAIT_EFFECT", trait.ItemTraitDescription); - hintText.SetTextVariable("COMPLETE_COST", "{GOLD_VALUE}{GOLD_ICON} , {CR_VALUE}{CUSTOMRESOURCE}"); - GameTexts.SetVariable("CR_VALUE", crCost); - GameTexts.SetVariable("CUSTOMRESOURCE", Hero.MainHero.GetCultureSpecificCustomResource().GetCustomResourceIconAsText()); - GameTexts.SetVariable("GOLD_VALUE", item.Value); - - - selectableItems.Add(new InquiryElement(new Tuple, ItemObject>(eligableHeroes, item), item.Name.ToString(), new ItemImageIdentifier(item), enabled, hintText.ToString())); - } - - var shopvariation = ""; - if (blessings) - { - shopvariation = "blessings"; - } - else - { - shopvariation = culture; - } - - var title = GameTexts.FindText("tor_enchantmentshop_title", shopvariation).ToString(); - var description = GameTexts.FindText("tor_enchantmentshop_description", shopvariation).ToString(); - - var inquirydata = new MultiSelectionInquiryData(title, description, selectableItems, true, 1, 1, "Accept", "Cancel", - AddEnchantment, null, "", true); - MBInformationManager.ShowMultiSelectionInquiry(inquirydata, true); - } - - - private static void AddEnchantment(List inquiryElements) - { - var element = (Tuple, ItemObject>)inquiryElements.FirstOrDefault()?.Identifier; - if (element == null) return; - - var heroes = element.Item1; - var item = element.Item2; - var trait = item.GetTraits().FirstOrDefault(); - - var arguments = trait.OnInventoryUseScript.InventoryScriptArguments; - var skillValue = 0; - - int.TryParse(arguments[2], out skillValue); - var candidateHero = heroes.Count == 1 ? heroes[0] : heroes.FirstOrDefault(x => x == Hero.MainHero); - - if (candidateHero != null) - { - candidateHero.AddEnchantmentBlueprint(arguments[0], true); // convenience, only one character can learn it, so we instantly apply the trait - } - else - { - Hero.MainHero.PartyBelongedTo.ItemRoster.Add(new ItemRosterElement(item, 1)); // we dont know, so we just add it to the inventory - var itemAddedText = TORTextHelper.GetTextObject("tor_item_added_to_inventory_text", "{ITEM_NAME} was added to the inventory"); - itemAddedText.SetTextVariable("ITEM_NAME", item.Name); - MBInformationManager.AddQuickInformation(itemAddedText, 0); - } - - var crCost = skillValue * Hero.MainHero.GetCultureSpecificCustomResource().GetCustomResourceGeneralizedFactor(); - Hero.MainHero.AddCultureSpecificCustomResource(-crCost); - Hero.MainHero.ChangeHeroGold(-item.Value); - } -} \ No newline at end of file +} diff --git a/CSharpSourceCode/CampaignMechanics/Crafting/EnchantmentShopHelper.cs b/CSharpSourceCode/CampaignMechanics/Crafting/EnchantmentShopHelper.cs new file mode 100644 index 00000000..1566d546 --- /dev/null +++ b/CSharpSourceCode/CampaignMechanics/Crafting/EnchantmentShopHelper.cs @@ -0,0 +1,244 @@ +using NLog; +using System; +using System.Collections.Generic; +using System.Linq; +using TaleWorlds.CampaignSystem; +using TaleWorlds.Core; +using TaleWorlds.Core.ImageIdentifiers; +using TaleWorlds.LinQuick; +using TaleWorlds.Localization; +using TOR_Core.AbilitySystem.Spells; +using TOR_Core.Extensions; +using TOR_Core.Items; +using TOR_Core.Utilities; + +namespace TOR_Core.CampaignMechanics.Crafting; + +/// +/// The town-service enchantment shop: builds the inquiry listing eligible blueprints (via +/// ) and applies the chosen one on purchase. No blueprint +/// eligibility/data logic lives here - see for that. +/// +public static class EnchantmentShopHelper +{ + public static void OpenEnchantmentRecipeShop(List prefixList, string culture, bool blessings = false) + { + var purchasableBlueprints = GetPurchasableBlueprints(prefixList); + var selectableItems = BuildInquiryElements(purchasableBlueprints); + + var shopVariation = GetShopVariation(culture, blessings); + var title = GameTexts.FindText("tor_enchantmentshop_title", shopVariation).ToString(); + var description = GameTexts.FindText("tor_enchantmentshop_description", shopVariation).ToString(); + + var inquirydata = new MultiSelectionInquiryData(title, description, selectableItems, true, 1, 1, "Accept", "Cancel", + AddEnchantment, null, "", true); + MBInformationManager.ShowMultiSelectionInquiry(inquirydata, true); + } + + private readonly record struct PurchasableBlueprint(ItemObject Item, string BlueprintId, SkillObject RequiredSkill, int RequiredSkillValue, string Restriction, List EligibleHeroes); + + private static List GetPurchasableBlueprints(List prefixList) + { + var blueprints = EnchantmentHelper.GetBlueprintItems(prefixList); + + var list = new List(); + foreach (var item in blueprints) + { + if (!EnchantmentHelper.TryGetBlueprintData(item, out var blueprintId, out var requiredSkill, out var requiredSkillValue, out var restriction)) + { + continue; + } + + if (EnchantmentHelper.IsBlueprintKnownByParty(blueprintId) || EnchantmentHelper.IsBlueprintInInventory(blueprintId)) + { + continue; + } + + var eligibleHeroes = EnchantmentHelper.GetEligibleHeroesForBlueprint(blueprintId, requiredSkill, requiredSkillValue, restriction, false); + if (eligibleHeroes.Any()) + { + list.Add(new PurchasableBlueprint(item, blueprintId, requiredSkill, requiredSkillValue, restriction, eligibleHeroes)); + } + } + + return list; + } + + private static List BuildInquiryElements(List blueprints) => + blueprints.WhereQ(b => IsUsableTrait(b.Item)).SelectQ(CreateInquiryElement).ToListQ(); + + private static InquiryElement CreateInquiryElement(PurchasableBlueprint blueprint) + { + var trait = blueprint.Item.GetTraits().FirstOrDefault(); + + var enabled = blueprint.EligibleHeroes.Any(hero => hero.GetSkillValue(blueprint.RequiredSkill) >= blueprint.RequiredSkillValue); + + var hintText = new TextObject("{TRAIT_EFFECT}\n\n{REQUIREMENT_TEXT}\n\n{COMPLETE_COST}"); + hintText.SetTextVariable("REQUIREMENT_TEXT", enabled ? "" : BuildRequirementText(blueprint.EligibleHeroes, blueprint.RequiredSkill, blueprint.RequiredSkillValue, blueprint.Restriction)); + + var crCost = CalculateCustomResourceCost(blueprint.RequiredSkillValue); + var goldCost = blueprint.Item.Value; + enabled = ApplyAffordabilityCheck(hintText, enabled, crCost, goldCost); + + SetValidItemTypeRestrictionVariable(blueprint.BlueprintId); + + if (enabled) + { + hintText = new TextObject(trait.ItemTraitDescription + "\n {GOLD_VALUE}{GOLD_ICON} , {CR_VALUE}{CUSTOMRESOURCE},\n {VALIDTYPE_RESTRICTION}"); + } + + hintText.SetTextVariable("TRAIT_EFFECT", trait.ItemTraitDescription); + hintText.SetTextVariable("COMPLETE_COST", "{GOLD_VALUE}{GOLD_ICON} , {CR_VALUE}{CUSTOMRESOURCE}"); + GameTexts.SetVariable("CR_VALUE", crCost); + GameTexts.SetVariable("CUSTOMRESOURCE", Hero.MainHero.GetCultureSpecificCustomResource().GetCustomResourceIconAsText()); + GameTexts.SetVariable("GOLD_VALUE", blueprint.Item.Value); + + return new InquiryElement(new Tuple, ItemObject>(blueprint.EligibleHeroes, blueprint.Item), blueprint.Item.Name.ToString(), new ItemImageIdentifier(blueprint.Item), enabled, hintText.ToString()); + } + + private static bool IsUsableTrait(ItemObject item) + { + var trait = item.GetTraits().FirstOrDefault(); + if (trait == null) + { + TORCommon.Log($"Enchantment blueprint {item.StringId} has no traits. Skipping this item.", LogLevel.Error); + return false; + } + + if (trait.OnInventoryUseScript == null) + { + TORCommon.Log($"Enchantment blueprint {item.StringId} has no inventory use script. Skipping this item.", LogLevel.Error); + return false; + } + + var arguments = trait.OnInventoryUseScript.InventoryScriptArguments; + if (arguments == null || arguments.Count < 3) + { + var argCount = arguments?.Count ?? 0; + TORCommon.Log($"Enchantment blueprint {item.StringId} has insufficient arguments (expected at least 3, got {argCount})", LogLevel.Error); + return false; + } + + return true; + } + + private static string BuildRequirementText(List eligableHeroes, SkillObject skill, int skillValue, string restriction) + { + var requirementPrefix = GetRestrictionPrefix(restriction); + + if (eligableHeroes.Count == 1) + { + var hero = eligableHeroes[0]; + return hero == Hero.MainHero + ? requirementPrefix + "You don't have enough " + skill.Name + ". Requires " + skillValue + "." + : requirementPrefix + hero.Name + " doesn't have enough " + skill.Name + ". Requires " + skillValue + "."; + } + + return requirementPrefix + "None of your eligible characters have enough " + skill.Name + ". Requires " + skillValue + "."; + } + + private static string GetRestrictionPrefix(string restriction) + { + if (string.IsNullOrEmpty(restriction)) + { + return ""; + } + + var lore = LoreObject.GetAll().FirstOrDefault(x => x.StringId == restriction); + return lore != null + ? "This enchantment is bound to the Lore of " + lore.Name + ". " + : "This enchantment requires " + restriction + ". "; + } + + private static int CalculateCustomResourceCost(int skillValue) + { + var factor = Hero.MainHero.GetCultureSpecificCustomResource().GetCustomResourceGeneralizedFactor(); + return (int)factor * skillValue; + } + + private static bool ApplyAffordabilityCheck(TextObject hintText, bool enabled, int crCost, int goldCost) + { + if (!enabled) + { + return false; + } + + if (!hintText.GetVariableValue("REQUIREMENT_TEXT", out var requirementText) || + requirementText != null && requirementText.ToString().IsEmpty()) + { + var missing = new List(); + + if (crCost >= Hero.MainHero.GetCultureSpecificCustomResourceValue()) + { + missing.Add("{CUSTOMRESOURCE}"); + } + + if (goldCost >= Hero.MainHero.Gold) + { + missing.Add("{GOLD_ICON}"); + } + + if (missing.Any()) + { + enabled = false; + hintText.SetTextVariable("REQUIREMENT_TEXT", "Not enough " + string.Join(" and ", missing) + "."); + } + } + + return enabled; + } + + private static void SetValidItemTypeRestrictionVariable(string blueprintId) + { + var underlyingTrait = ItemTrait.All.FirstOrDefault(x => x.ItemTraitStringId == blueprintId); + if (underlyingTrait != null) + { + var typeRestriction = GameTexts.FindText("tor_enchantmentshop_restriction", underlyingTrait.ValidItemType.ToString()).ToString(); + GameTexts.SetVariable("VALIDTYPE_RESTRICTION", typeRestriction); + } + } + + private static string GetShopVariation(string culture, bool blessings) => blessings ? "blessings" : culture; + + private static void AddEnchantment(List inquiryElements) + { + var element = (Tuple, ItemObject>)inquiryElements.FirstOrDefault()?.Identifier; + if (element == null) return; + + var heroes = element.Item1; + var item = element.Item2; + var trait = item.GetTraits().FirstOrDefault(); + var arguments = trait.OnInventoryUseScript.InventoryScriptArguments; + + int.TryParse(arguments[2], out var skillValue); + + GrantBlueprintOrAddToInventory(heroes, item, arguments[0]); + ChargeForPurchase(skillValue, item); + } + + private static void GrantBlueprintOrAddToInventory(List heroes, ItemObject item, string blueprintId) + { + var candidateHero = SelectRecipientHero(heroes); + + if (candidateHero != null) + { + candidateHero.AddEnchantmentBlueprint(blueprintId, true); + } + else + { + Hero.MainHero.PartyBelongedTo.ItemRoster.Add(new ItemRosterElement(item, 1)); + var itemAddedText = TORTextHelper.GetTextObject("tor_item_added_to_inventory_text", "{ITEM_NAME} was added to the inventory"); + itemAddedText.SetTextVariable("ITEM_NAME", item.Name); + MBInformationManager.AddQuickInformation(itemAddedText, 0); + } + } + + private static Hero SelectRecipientHero(List heroes) => heroes.Count == 1 ? heroes[0] : heroes.FirstOrDefault(x => x == Hero.MainHero); + + private static void ChargeForPurchase(int skillValue, ItemObject item) + { + var crCost = skillValue * Hero.MainHero.GetCultureSpecificCustomResource().GetCustomResourceGeneralizedFactor(); + Hero.MainHero.AddCultureSpecificCustomResource(-crCost); + Hero.MainHero.ChangeHeroGold(-item.Value); + } +} diff --git a/CSharpSourceCode/CampaignMechanics/Crafting/PriestBehavior.cs b/CSharpSourceCode/CampaignMechanics/Crafting/PriestBehavior.cs index 3b813758..fda001f3 100644 --- a/CSharpSourceCode/CampaignMechanics/Crafting/PriestBehavior.cs +++ b/CSharpSourceCode/CampaignMechanics/Crafting/PriestBehavior.cs @@ -274,7 +274,7 @@ void OpenBlessingRecipesShop(string prefix) { var partner = CharacterObject.OneToOneConversationCharacter; - EnchantmentHelper.OpenEnchantmentRecipeShop([prefix], partner.Culture.StringId, true); + EnchantmentShopHelper.OpenEnchantmentRecipeShop([prefix], partner.Culture.StringId, true); } void BlessParty(string cultId) diff --git a/CSharpSourceCode/TOR_Core.csproj b/CSharpSourceCode/TOR_Core.csproj index 8860968d..950f8581 100644 --- a/CSharpSourceCode/TOR_Core.csproj +++ b/CSharpSourceCode/TOR_Core.csproj @@ -516,6 +516,7 @@ + diff --git a/CSharpSourceCode/TOR_Core.sln b/CSharpSourceCode/TOR_Core.sln index b9b1a52e..79fc7bd6 100644 --- a/CSharpSourceCode/TOR_Core.sln +++ b/CSharpSourceCode/TOR_Core.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.2.32505.173 +# Visual Studio Version 18 +VisualStudioVersion = 18.9.12120.119 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TOR_Core", "TOR_Core.csproj", "{80942161-02FB-4024-A1C2-22EF33DC8D1A}" EndProject @@ -13,8 +13,8 @@ Global Release|x64 = Release|x64 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {80942161-02FB-4024-A1C2-22EF33DC8D1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {80942161-02FB-4024-A1C2-22EF33DC8D1A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80942161-02FB-4024-A1C2-22EF33DC8D1A}.Debug|Any CPU.ActiveCfg = Debug|x64 + {80942161-02FB-4024-A1C2-22EF33DC8D1A}.Debug|Any CPU.Build.0 = Debug|x64 {80942161-02FB-4024-A1C2-22EF33DC8D1A}.Debug|x64.ActiveCfg = Debug|x64 {80942161-02FB-4024-A1C2-22EF33DC8D1A}.Debug|x64.Build.0 = Debug|x64 {80942161-02FB-4024-A1C2-22EF33DC8D1A}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md new file mode 100644 index 00000000..0c43ec04 --- /dev/null +++ b/docs/architecture-overview.md @@ -0,0 +1,103 @@ +# TOR_Core — Current Architecture Overview + +This document describes the codebase as it stands today (`CSharpSourceCode/`), as input to a +proposed vertical-slicing refactor (see [`vertical-slicing-proposal.md`](./vertical-slicing-proposal.md)). + +## The shape of the mod + +TOR_Core is a single Bannerlord sub-module assembly. Content is organized into ~15 top-level +folders, most named after a technical layer or a broad theme (`CampaignMechanics`, +`BattleMechanics`, `AbilitySystem`, `Models`, ...). Within `CampaignMechanics` and +`BattleMechanics`, there's a second level of folders that map much more closely to actual +features (`Religion/`, `Chaos/`, `Firearms/`, `Crafting/`, ...) — so the raw material for a +feature-oriented ("vertical slice") structure already mostly exists. What's missing is +*isolation*: nothing about a feature's folder makes it self-contained or independently +registrable. + +## The registration bottleneck: `SubModule.cs` + +Every feature wires itself up by being manually `new`'d in one enormous class, +[`SubModule.cs`](../CSharpSourceCode/SubModule.cs): + +| Method | What it does | Approx. count | +|---|---|---| +| `OnSubModuleLoad` | Harmony patching, config/template loading (abilities, status effects, triggered effects, items, banners, careers, voices, ink stories) | ~15 manager `Load`/`Initialize` calls | +| `InitializeGameStarter` | `starter.AddBehavior(new XyzCampaignBehavior())` | ~60 campaign behaviors | +| `OnGameStart` | `gameStarterObject.AddModel(new TORXyzModel())` | ~90 game models | +| `OnMissionBehaviorInitialize` | `mission.AddMissionBehavior(new XyzMissionLogic())` | ~15 mission behaviors | +| `BeginGameStart` | `game.ObjectManager.RegisterType(...)` for custom settlement/career/religion object types | ~13 types | + +Every one of these lines is a hard dependency from the "hub" onto a "spoke" feature's concrete +type — `using TOR_Core.CampaignMechanics.Religion;` etc., 65 `using` statements deep. Adding, +removing, or reasoning about one feature in isolation means reading (and safely editing) this +2,600+ line god-object regardless of which feature you actually touch. + +## Current wiring + +```mermaid +flowchart TB + SM["SubModule.cs
(god object — ~60 AddBehavior, ~90 AddModel,
~15 AddMissionBehavior calls, all hand-written)"] + + subgraph INFRA["Infrastructure-shaped folders"] + direction LR + UTIL[Utilities] + EXT[Extensions] + SAVE[SaveGameSystem] + GM[GameManagers] + HP[HarmonyPatches] + AUDIO[Audio] + INK[Ink] + MISS[Missions] + end + + subgraph COMBAT["Battle-simulation core"] + direction LR + ABIL[AbilitySystem] + SE["StatusEffect /
TriggeredEffect /
DamageSystem"] + AI["BattleMechanics/AI"] + end + + subgraph FEATURES["~26 CampaignMechanics feature folders"] + direction LR + CAREERS[Careers] + RELIGION[Religion] + CHAOS[Chaos] + CRES[CustomResources] + CRAFT[Crafting] + DIPLO[Diplomacy] + SETTLE[TORCustomSettlement] + MORE1["... 19 more"] + end + + subgraph MISSIONFX["~9 BattleMechanics content add-ons"] + direction LR + FIRE[Firearms] + DISM[Dismemberment] + ARTY[Artillery] + BANN[Banners] + ARENA[CustomArenaModes] + MORE2["... 4 more"] + end + + INFRA -.depended on by everything.-> COMBAT + INFRA -.depended on by everything.-> FEATURES + INFRA -.depended on by everything.-> MISSIONFX + COMBAT -.depended on by.-> FEATURES + COMBAT -.depended on by.-> MISSIONFX + + FEATURES -- "new'd individually, one line each" --> SM + MISSIONFX -- "new'd individually, one line each" --> SM + COMBAT -- "new'd individually" --> SM + INFRA -- "Initialize()/LoadXML() called individually" --> SM +``` + +## Observations + +- **Folders already suggest features; registration does not respect that.** `CampaignMechanics/Religion/` is a coherent, mostly-self-contained unit of code — but you can't tell that from `SubModule.cs`, where its one `AddBehavior` call sits between unrelated features. +- **Cross-cutting infrastructure is not marked as such.** `Utilities/`, `Extensions/`, `SaveGameSystem/`, `GameManagers/`, `Audio/`, `Ink/`, and most of `HarmonyPatches/` have no feature identity at all — they're pure plumbing every feature depends on, but they sit at the same folder depth as `Chaos/` or `Firearms/`, obscuring the dependency direction. +- **A few systems are structurally "framework" but organizationally trapped inside `BattleMechanics/`**: `AbilitySystem`'s parent concepts (`StatusEffect`, `TriggeredEffect`, `DamageSystem`, `AI`) are the shared combat runtime every spell/prayer/item/career-ability is built on, not a feature themselves. +- **`Models/` is a 90-entry flat registry with no feature grouping at all** — `TORFaithModel` (Religion), `TORCustomResourceModel` (CustomResources), and `TORPartySizeModel` (nothing in particular) all live in the same folder and get added in the same block in `SubModule.OnGameStart`. +- **A handful of files are already colocated correctly** despite the lack of formal structure — e.g. `TORAllianceWarBehavior` carries its own `SaveableTypeDefiner` nested class rather than adding to the central `TORSaveableTypeDefiner`. This is the pattern a vertical-slice structure should generalize. + +See [`vertical-slicing-proposal.md`](./vertical-slicing-proposal.md) for the proposed target +shape, a folder-by-folder Framework/Module classification, and a phased migration plan. diff --git a/docs/vertical-slicing-proposal.md b/docs/vertical-slicing-proposal.md new file mode 100644 index 00000000..328dd566 --- /dev/null +++ b/docs/vertical-slicing-proposal.md @@ -0,0 +1,251 @@ +# Vertical Slicing Proposal + +Companion to [`architecture-overview.md`](./architecture-overview.md). Proposes splitting +`CSharpSourceCode/` into a **`Framework/`** layer (engine-shaped, no feature toggles, everything +depends on it) and a **`Modules/`** layer (self-contained vertical slices — own behaviors, own +models, own quests, own save data, own UI — each responsible for registering itself). + +This is a proposal for discussion, not a plan already agreed — see [Open questions](#open-questions-before-executing) +at the end. + +## Goal + +Two tests for "is this a Module": + +1. **Could a maintainer delete this folder and, modulo a couple of registration lines, have the + rest of the mod still compile and play?** If yes → Module. If half the codebase reaches into + it → Framework. +2. **Does it represent one coherent piece of Warhammer content/mechanic** (Religion, Firearms, + Crafting, the Chaos faction) **or a reusable mechanism** (an effect-resolution pipeline, a + save-id registry, a view-model injection trick)? Content → Module. Mechanism → Framework. + +## Target shape + +```mermaid +flowchart TB + subgraph REG["Registration"] + SM2["SubModule.cs
(thin: Harmony init, then discovers & drives ITORModules)"] + REGISTRY["TORModuleRegistry
(reflection scan for [TORModule]-attributed types,
same pattern already used by ViewModelExtensionManager)"] + end + + subgraph FW["Framework/ — engine-shaped, no feature toggles"] + direction LR + FUTIL[Utilities] + FEXT[Extensions] + FSAVE[SaveGameSystem] + FGM[GameManagers] + FHP[Patches] + FAUDIO[Audio] + FINK[Narrative /Ink] + FMISS[Missions] + FABIL[AbilitySystem] + FBATTLE["Battle/
StatusEffect, TriggeredEffect,
DamageSystem, AI"] + FITEMS["Items
(trait/metadata engine)"] + FCHARDEV["CharacterDevelopment
(skills/attributes/traits scaffold)"] + FMODELS["Models
(generic formula overrides)"] + FQUESTS[Quests infra] + FEVENTS[CustomEvents] + FDIALOGUE[Dialogue] + FUI["UI
(notifications, VM-extension, main menu)"] + end + + subgraph MODS["Modules/ — self-contained vertical slices"] + direction LR + M1["Careers/"] + M2["Religion/"] + M3["CustomResources/"] + M4["Crafting/"] + M5["Diplomacy/"] + M6["TORCustomSettlement/"] + M7["Firearms/"] + M8["Chaos/, RaiseDead/,
RegimentsOfRenown/, ...
(~20 more, one per mechanic)"] + end + + SM2 --> REGISTRY + REGISTRY -- "calls each module's
RegisterCampaignBehaviors/RegisterModels/
RegisterMissionBehaviors" --> MODS + MODS -- "depends on (read-only)" --> FW + FW -.no dependency on Modules.-> MODS +``` + +Key invariant: **arrows only point from `Modules/` into `Framework/`, never back.** If a +`Framework/` class needs to call into a specific module, that's a signal the class isn't +actually framework — either promote the mechanism up (generalize it) or push the call down (let +the module opt in via an event/hook rather than the framework knowing the module by name). + +## Proposed registration contract + +Replace the hand-written `AddBehavior`/`AddModel`/`AddMissionBehavior` blocks in `SubModule.cs` +with a small interface each module implements once, discovered the same way +`Extensions/UI`'s `ViewModelExtensionManager` already discovers `[ViewModelExtension]` types — +so this isn't a new idiom for the codebase, just the existing one applied to module registration: + +```csharp +public interface ITORModule +{ + void RegisterCampaignBehaviors(CampaignGameStarter starter) { } + void RegisterMissionBehaviors(Mission mission) { } + void RegisterModels(IGameStarter starter) { } + void RegisterGameObjectTypes(Game game) { } // ObjectManager.RegisterType + void OnSubModuleLoad() { } // template/XML loading, if any +} + +[AttributeUsage(AttributeTargets.Class)] +public class TORModuleAttribute : Attribute { } +``` + +```mermaid +classDiagram + class ITORModule { + <> + +RegisterCampaignBehaviors(CampaignGameStarter) + +RegisterMissionBehaviors(Mission) + +RegisterModels(IGameStarter) + +RegisterGameObjectTypes(Game) + +OnSubModuleLoad() + } + class TORModuleAttribute { + <> + } + class CareersModule + class ReligionModule + class CraftingModule + ITORModule <|.. CareersModule + ITORModule <|.. ReligionModule + ITORModule <|.. CraftingModule + TORModuleAttribute ..> CareersModule + TORModuleAttribute ..> ReligionModule + TORModuleAttribute ..> CraftingModule +``` + +`SubModule.cs` shrinks to: Harmony setup, `Framework/` initialization calls (still explicit — +framework pieces have real load-order constraints worth keeping visible), then one +`TORModuleRegistry.DiscoverAndRegister(...)` call per lifecycle hook. Adding a new feature +becomes "add a folder under `Modules/` with a class implementing `ITORModule`" instead of "edit +`SubModule.cs` in five places and hope you didn't collide with someone else's line." + +Save-id registration stays centrally *tracked* (id collisions are catastrophic and must be +reviewable in one place) but each module keeps contributing its own `SaveableTypeDefiner` +colocated with its types — generalizing the pattern `Diplomacy/TORAllianceWarBehavior` and +`Diplomacy/HonorAllianceDecision` already use, rather than growing the central +`TORSaveableTypeDefiner` forever. + +## Classification + +Legend: **FW** = moves to `Framework/`, **MOD** = moves to `Modules//`, **SPLIT** = the +folder's contents are genuinely mixed and need to be divided. + +### Already-framework-shaped top-level folders + +| Folder | Verdict | Notes | +|---|---|---| +| `Utilities/` | FW | Uncontroversial — pure cross-cutting helpers already. | +| `SaveGameSystem/` | FW | Keep as the id-ledger; push individual type definitions out to modules over time (see above). | +| `GameManagers/` | FW | Bootstrap, key bindings, shader compilation tracking. | +| `Audio/` | FW | Standalone subsystem, no feature identity. | +| `Ink/` | FW | The Ink *engine* bridge is generic (spawn item/settlement/event/quest/mission/audio); authored `.ink` files are already data, not code. | +| `Missions/` (root) | FW | `TorMissionManager`, `TORMissionAgentHandler`, `MissionExperienceBehavior` are generic mission-launch plumbing used by many modules. | +| `Extensions/` (root ext. methods, `DebugMethods`) | FW | Generic extension methods on vanilla types. | +| `Extensions/ExtendedInfoSystem/` | FW | The side-table *mechanism*; consumed by nearly every module. | +| `Extensions/UI/` (VM-extension framework, `TORInitialScreen`, `MainMenu/`) | FW mechanism, **SPLIT** concrete extensions | `IViewModelExtension`/`ViewModelExtensionManager` stay FW; concrete `CraftingVMExtension`/`RefinementVMExtension` move to `Modules/Crafting/`, etc. — anything named after a specific screen that only one module cares about. | +| `HarmonyPatches/` | mostly FW, **SPLIT** the rest | Engine-wide patches (`AgentPatches`, `MissionPatches`, `ObjectManagerPatches`, `ViewModelPatches`, `GameTextPatches`, `LoadingScreenPatches`, `MainMenuCrashPatch`, ...) stay FW. Patches that only touch one module's own types (`CraftingPatches`, `CustomResourcePatches`, `TournamentPatches`, `ArtilleryPatches`, `ArenaPracticePatch`, `CustomBattlePatches`) move to live beside that module. | + +### `AbilitySystem/` and combat runtime — Framework + +| Folder | Verdict | Notes | +|---|---|---| +| `AbilitySystem/` (core: `Ability`, `AbilityTemplate`, `AbilityFactory`, `AbilityComponent`, `AbilityManagerMissionLogic`, HUD) | FW | The magic/prayer/career-ability *engine* — every module that grants an ability is a client of this, not a peer. | +| `AbilitySystem/CrossHairs/`, `SpellCasting/`, `Spells/` (+`SpellBook/`), `Spells/Prayers/` | FW | Same engine; Lore/Spell/Prayer data model and its UI are generic magic-system plumbing, not one module's content. | +| `AbilitySystem/Scripts/` | **SPLIT** | Base `AbilityScript` machinery → FW. Per-Career `CareerAbilityScript` subclasses → `Modules/Careers/Abilities/`. | +| `BattleMechanics/StatusEffect/`, `TriggeredEffect/`, `DamageSystem/` | FW | The shared effect-resolution pipeline every spell/item/prayer fires through. | +| `BattleMechanics/AI/` (all of it: `CastingAI/`, `TeamAI/`, `ArtilleryAI/`, `CivilianMissionAI/`, `CommonAIFunctions/`) | FW | Battle-simulation infrastructure, not a toggleable feature — every battle uses it regardless of which content modules are involved. | +| `BattleMechanics/` root (`TORBattleAgentLogic`, `AddAgentComponentsMissionLogic`, `CustomCrosshairMissionBehavior`, `CinematicCameraMissionView`) | FW | Generic mission plumbing. | +| `BattleMechanics/` root — `CareerPerkMissionBehavior` | MOD → `Careers/` | Career-specific despite living at `BattleMechanics/` root today. | +| `BattleMechanics/` root — `TORMonsterSiegeLogic`, `SiegeEarlyVictoryMissionLogic` | MOD → `TORCustomSettlement/` | Monster-siege support exists for Troll Cave content. | + +### `BattleMechanics/*` content add-ons — Modules + +| Folder | Verdict | Notes | +|---|---|---| +| `Firearms/` | MOD | Self-contained black-powder mechanics. | +| `Dismemberment/` | MOD | Self-contained gore-on-kill logic. | +| `Artillery/` | MOD | Field siege weapons; depends on FW `AI/ArtilleryAI`. | +| `Banners/` | MOD | Custom faction banner content. | +| `CustomArenaModes/` | MOD → `Tournaments/` | Pair with `Missions/ArcheryContestMissionController` + `JoustFightMissionController`. | +| `SniperScope/` | MOD → `Firearms/` | Long-range-weapon scope, used by Firearms content. | +| `Voice/` | FW | Generic battle-shout/voice-over system every agent uses, not one feature. | +| `Morale/` (`UndeadMoraleAgentComponent`) | FW | A core race rule (undead ignore morale), not a toggle. | +| `SFX/` | FW | Generic scene-prop scripting toolkit (spin, face-target, light dampening), reused by whichever module drops a prop in a scene. | + +### `CampaignMechanics/` — the bulk of the feature surface + +| Folder | Verdict | Notes | +|---|---|---| +| `Assimilation/`, `BountyMaster/`, `Chaos/`, `CharacterCreation/`, `Companions/`, `Crafting/`, `Diplomacy/`, `MasterEngineer/`, `PostBattleLoot/`, `RaidingParties/`, `RaiseDead/`, `RegimentsOfRenown/`, `Religion/`, `ServeAsAHireling/`, `SpellTrainers/`, `TORCustomSettlement/` (+`Component/`, `CustomSettlementMenus/`), `UniqueSpawns/`, `Villages/` | MOD | Already well-isolated folders — the most direct wins. Each becomes `Modules//` basically as-is, plus an `ITORModule` implementation. | +| `Careers/` | MOD → merge into `Modules/Careers/` | Along with `CharacterDevelopment/CareerSystem/` (+`Choices/`, `CareerButton/`), `CharacterDevelopment` root's `TORCareers`/`TORCareerChoices`/`TORCareerChoiceGroups`/`CareerAbilityChargeSupplier`, `Quests/Careers/`, and the `AbilitySystem/Scripts` career scripts noted above. This is the largest single module — spans five current top-level folders. | +| `CustomResourceBehavior/` + `CustomResources/` (+`WaaaghMeter/`) | MOD → `Modules/CustomResources/` | The whole per-culture resource system (Prestige/Chivalry/DarkEnergy/ForestHarmony/CouncilFavor/OathGold/Teef/Waaagh) as one module domain. | +| `CustomDialogs/` (+`ConversationTags/`) | FW | A shared "extra conversation lines" surface multiple modules hook into (framework role, similar to `CustomEvents/`) — but audit for module-specific dialog behaviors (e.g. `DuelBehavior`) that should move out. | +| `CustomDialogs/DuelBehavior` | MOD → `Tournaments/` (or its own `Modules/Duel/`) | Honor-duel is one coherent feature, currently colocated in `CustomDialogs/`. | +| `CustomEvents/` | FW mechanism | Generic scripted-event framework (`CustomEvent`, `CustomEventsCampaignBehavior`). | +| `CustomEvents/SimpleCareerQuestBehavior` | MOD → `Careers/` | Career-flavor content colocated in the framework folder today. | +| `MapNotifications/` | FW → `Framework/UI/` | Generic notification popup helper used by every module. | +| Root files: `CampaignEventHelpers`, `TorRecruitmentHelpers`/`TORAIRecruitmentCampaignBehavior`, `TORPartyUpgraderCampaignBehavior`, `TORCaptivityCampaignBehavior`, `TORFactionDiscontinuationCampaignBehavior`, `TORStartupBehavior`, `SkillTrainerBehavior`, `TorMapBarSpriteWidget`, `TORCampaignMusicHandler` | FW | Campaign-wide systems with no single owning feature. | +| Root files: `GreenskinAICampaignBehavior` | MOD → `CustomResources/` (Waaagh) or its own `Modules/Greenskins/` | Greenskin/Waaagh-flavored AI behavior — needs a look at what it actually touches before placing. | +| Root files: `TORSpecialSettlementBehavior` | MOD → `TORCustomSettlement/` | | + +### `CharacterDevelopment/` — split + +| Folder | Verdict | Notes | +|---|---|---| +| `TORSkills`, `TORSkillEffects`, `TORAttributes`, `TORCharacterTraits`, `TORPerks`, `TORPerkHandlerCampaignBehavior` | FW | Base progression scaffolding every module can add skills/perks/traits into. | +| `TORCareers`, `TORCareerChoices`, `TORCareerChoiceGroups`, `CareerAbilityChargeSupplier` | MOD → `Careers/` | See Careers entry above. | +| `CareerSystem/` (whole subfolder) | MOD → `Careers/` | | + +### `Extensions/`, `Items/`, `Models/`, `Quests/` — split + +| Folder | Verdict | Notes | +|---|---|---| +| `Items/` root (`ItemTrait`, `ItemTraitManager`, `ItemTraitAgentComponent`, `ExtendedItemObjectManager`/`Properties`) | FW | Generic item-enchantment/metadata engine. | +| `Items/` root (`TorEnchantingIngredients`, item-trait tooltip VMs/widgets) | MOD → `Crafting/` | Crafting-flavored UI colocated in `Items/` today. | +| `Items/InventoryUseScriptsCampaignBehavior` | FW | Generic dispatcher. | +| `Items/WeaponHitScripts/`, `Items/InventoryUseScripts/` | FW interface, **SPLIT** implementations | The `IWeaponHitScript`/`IInventoryUseScript` contracts stay FW; concrete scripts move with whichever module grants the item that uses them. | +| `Models/` (generic combat/party/settlement-economy formula overrides — the majority) | FW | Stays a registration surface, but each `AddModel` call should move to be issued by the owning module (Framework or the module registering itself), not centrally in `SubModule.OnGameStart`. | +| `Models/` — `TORFaithModel` | MOD → `Religion/` | | +| `Models/` — `TORCustomResourceModel` | MOD → `CustomResources/` | | +| `Models/` — `TOREnchantmentCraftingModel`, `TOREnchantmentIngredientsModel`, `TORSmithingModel` | MOD → `Crafting/` | | +| `Models/` — `TORCompanionHiringPriceCalculationModel`, `TORCompanionTrainingModel` | MOD → `Companions/` | | +| `Models/` — `TORHiringCompatibilityModel` | MOD → `ServeAsAHireling/` (verify against usage) | | +| `Models/` — `TORDiplomacyModel`, `TORAllianceModel`, `TORTradeAgreementModel`, `TORKingdomDecisionPermissionModel` | MOD → `Diplomacy/` | | +| `Models/` — `TORVillageProductionCalculatorModel` | MOD → `Villages/` | | +| `Models/` — `TORTournamentModel` | MOD → `Tournaments/` | | +| `Models/` — `TORAbilityModel` | FW → `AbilitySystem/` | Spell damage/radius/duration scaling is engine-level, not one module's. | +| `Models/CustomBattleModels/` | FW | Parallel model set for Custom Battle mode as a whole, not one feature. | +| `Quests/TORQuestHelper`, `QuestPartyComponent` | FW | Generic quest-launch infra used by many modules' content. | +| `Quests/EngineerQuest` | MOD → `MasterEngineer/` | | +| `Quests/SpecializeLoreQuest` | MOD → `SpellTrainers/` | | +| `Quests/HuntCultistsQuestCampaignBehavior` | MOD → `Chaos/` (verify) | | +| `Quests/PlaguedVillageQuestCampaignBehavior` | MOD → `Villages/` or `RaiseDead/` (verify — theme needs a quick read before placing) | | +| `Quests/Careers/` | MOD → `Careers/` | | + +## Migration strategy + +Doing this in one pass is high-risk on a codebase this size (2,600-line `SubModule.cs`, 90 game +models, `SaveGameSystem` compatibility constraints). Suggested phased order, each phase +independently shippable: + +1. **Introduce the mechanism, touch nothing else.** Add `ITORModule`/`TORModuleAttribute`/`TORModuleRegistry` under a new `Framework/` folder (which starts out mostly empty re-exports). No behavior moves yet. +2. **Move the uncontroversial pure-infrastructure folders** (`Utilities/`, `Audio/`, `GameManagers/`, `SaveGameSystem/`) into `Framework/` — pure `namespace`/`using` churn, zero registration risk, builds confidence in the move tooling. +3. **Convert 2–3 already-isolated `CampaignMechanics/` features** (good first candidates: `BountyMaster/`, `PostBattleLoot/`, `Villages/` — small, few cross-references per their CLAUDE.md summaries) to `Modules/` + `ITORModule`, removing their lines from `SubModule.cs`. Validate the registry-discovery approach end-to-end in a real play session before scaling up. +4. **Migrate the rest of the clearly-isolated `CampaignMechanics/*` and `BattleMechanics/*` content folders** module-by-module, each as its own PR (small diff, easy to bisect if something regresses). +5. **Tackle the split folders last** (`Models/`, `Items/`, `Quests/`, `Extensions/UI/`, `HarmonyPatches/`) — these require pulling individual files out of a shared folder rather than moving a whole folder, so they're more error-prone and benefit from the muscle memory built in steps 3–4. +6. **Tackle `Careers/` last of all** — it's the largest and most cross-cutting module (spans five current folders); do it once the pattern is well-proven elsewhere. + +Each phase: move files, update `namespace`s, remove the corresponding lines from `SubModule.cs`, +add the module's `ITORModule` implementation, build, and do a smoke playtest (Career/quest +mechanics and save/load in particular, given `SaveGameSystem`'s "never renumber" constraint). + +## Open questions before executing + +- **Naming**: `Modules/` vs. keeping `CampaignMechanics/`/`BattleMechanics/` as the module root and only carving out `Framework/`? (Renaming touches every file's `namespace`.) +- **Reflection-based discovery** mirrors the existing `ViewModelExtensionManager` pattern, but is a runtime cost/load-order change worth confirming against Bannerlord's startup profiling before committing, vs. an explicit (but still short) list of module types in `SubModule.cs`. +- A few placements above are flagged **"verify"** — `GreenskinAICampaignBehavior`, `TORHiringCompatibilityModel`, `HuntCultistsQuestCampaignBehavior`, `PlaguedVillageQuestCampaignBehavior` — their current CLAUDE.md summaries don't pin down which module they truly belong to; worth a quick source read before moving. +- Should `SaveGameSystem`'s central definer be split by module now, or only for *new* types going forward (leaving existing ids where they are, since renumbering breaks saves)?