diff --git a/.gitignore b/.gitignore index 61e43d76..a51be1cc 100644 --- a/.gitignore +++ b/.gitignore @@ -351,4 +351,3 @@ Tools/TranslationTool/translation_issues_report_* .claude tmpclaude-* CLAUDE.md -INDEX.md diff --git a/CSharpSourceCode/CampaignMechanics/Careers/TORCareerPerkCampaignBehavior.cs b/CSharpSourceCode/CampaignMechanics/Careers/TORCareerPerkCampaignBehavior.cs index 5f7d16c8..25b09da3 100644 --- a/CSharpSourceCode/CampaignMechanics/Careers/TORCareerPerkCampaignBehavior.cs +++ b/CSharpSourceCode/CampaignMechanics/Careers/TORCareerPerkCampaignBehavior.cs @@ -9,6 +9,7 @@ using TaleWorlds.Core; using TaleWorlds.Library; using TaleWorlds.Localization; +using TOR_Core.CampaignMechanics.Crafting; using TOR_Core.CharacterDevelopment; using TOR_Core.CharacterDevelopment.CareerSystem; using TOR_Core.Extensions; diff --git a/CSharpSourceCode/CampaignMechanics/Crafting/CraftingModelsExtensions.cs b/CSharpSourceCode/CampaignMechanics/Crafting/CraftingModelsExtensions.cs new file mode 100644 index 00000000..ba8dcc20 --- /dev/null +++ b/CSharpSourceCode/CampaignMechanics/Crafting/CraftingModelsExtensions.cs @@ -0,0 +1,24 @@ +using System.Linq; +using TaleWorlds.CampaignSystem; +using TOR_Core.CampaignMechanics.Crafting.Models; + +namespace TOR_Core.CampaignMechanics.Crafting +{ + /// + /// Shortcuts to Campaign.Current.Models.GetGameModels().OfType for the models + /// this module owns - colocated here rather than in the shared Extensions/GameModelsExtensions + /// so that Framework layer doesn't need to know this module's concrete model types. + /// + public static class CraftingModelsExtensions + { + public static TORSmithingModel GetSmithingModel(this GameModels models) + { + return models.GetGameModels().OfType().LastOrDefault(); + } + + public static TOREnchantmentIngredientsModel GetEnchantmentIngredientModel(this GameModels models) + { + return models.GetGameModels().OfType().LastOrDefault(); + } + } +} diff --git a/CSharpSourceCode/CampaignMechanics/Crafting/CraftingModule.cs b/CSharpSourceCode/CampaignMechanics/Crafting/CraftingModule.cs new file mode 100644 index 00000000..cf7ff55c --- /dev/null +++ b/CSharpSourceCode/CampaignMechanics/Crafting/CraftingModule.cs @@ -0,0 +1,39 @@ +using TaleWorlds.CampaignSystem; +using TaleWorlds.Core; +using TaleWorlds.MountAndBlade; +using TOR_Core.CampaignMechanics.Crafting.Models; +using TOR_Core.Framework; + +namespace TOR_Core.CampaignMechanics.Crafting +{ + /// + /// Registration entry point for the Crafting module (weapon/armor enchanting, + /// artisan-district item duplication, related loot/models), so SubModule.cs only needs + /// one call per lifecycle hook instead of one line per behavior/model. + /// + [TORModule] + public class CraftingModule : ITORModule + { + public void OnSubModuleLoad() { } + + public void RegisterCampaignBehaviors(CampaignGameStarter starter) + { + starter.AddBehavior(new EnchanterTownBehavior()); + starter.AddBehavior(new TORArtisanDistrictCampaignBehavior()); + starter.AddBehavior(new PriestBehavior()); + starter.AddBehavior(new EnchantmentIngredientLootCampaignBehavior()); + starter.AddBehavior(new LootCampaignBehavior()); + } + + public void RegisterModels(IGameStarter gameStarterObject) + { + gameStarterObject.AddModel(new TORSmithingModel()); + gameStarterObject.AddModel(new TOREnchantmentIngredientsModel()); + gameStarterObject.AddModel(new TOREnchantmentCraftingModel()); + } + + public void RegisterMissionBehaviors(Mission mission) { } + + public void RegisterGameObjectTypes(Game game) { } + } +} diff --git a/CSharpSourceCode/HarmonyPatches/CraftingPatches.cs b/CSharpSourceCode/CampaignMechanics/Crafting/CraftingPatches.cs similarity index 98% rename from CSharpSourceCode/HarmonyPatches/CraftingPatches.cs rename to CSharpSourceCode/CampaignMechanics/Crafting/CraftingPatches.cs index b76e5dcd..73868358 100644 --- a/CSharpSourceCode/HarmonyPatches/CraftingPatches.cs +++ b/CSharpSourceCode/CampaignMechanics/Crafting/CraftingPatches.cs @@ -7,12 +7,11 @@ using TaleWorlds.CampaignSystem.CampaignBehaviors; using TaleWorlds.CampaignSystem.ViewModelCollection.WeaponCrafting.WeaponDesign; using TaleWorlds.Core; -using TOR_Core.CampaignMechanics.Crafting; +using TOR_Core.CampaignMechanics.Crafting.Models; using TOR_Core.Extensions; -using TOR_Core.Models; using TOR_Core.Utilities; -namespace TOR_Core.HarmonyPatches +namespace TOR_Core.CampaignMechanics.Crafting { [HarmonyPatch] public static class CraftingPatches diff --git a/CSharpSourceCode/Extensions/UI/CraftingVMExtension.cs b/CSharpSourceCode/CampaignMechanics/Crafting/CraftingVMExtension.cs similarity index 96% rename from CSharpSourceCode/Extensions/UI/CraftingVMExtension.cs rename to CSharpSourceCode/CampaignMechanics/Crafting/CraftingVMExtension.cs index a0b7252c..7dc2149b 100644 --- a/CSharpSourceCode/Extensions/UI/CraftingVMExtension.cs +++ b/CSharpSourceCode/CampaignMechanics/Crafting/CraftingVMExtension.cs @@ -2,8 +2,10 @@ using TaleWorlds.CampaignSystem.ViewModelCollection.WeaponCrafting; using TaleWorlds.Core.ViewModelCollection.Information; using TaleWorlds.Library; +using TOR_Core.Extensions; +using TOR_Core.Extensions.UI; -namespace TOR_Core.Extensions.UI +namespace TOR_Core.CampaignMechanics.Crafting { [ViewModelExtension(typeof(CraftingVM))] public class CraftingVMExtension : BaseViewModelExtension diff --git a/CSharpSourceCode/CampaignMechanics/Crafting/EnchanterTownBehavior.cs b/CSharpSourceCode/CampaignMechanics/Crafting/EnchanterTownBehavior.cs index 96f146a9..b112deb4 100644 --- a/CSharpSourceCode/CampaignMechanics/Crafting/EnchanterTownBehavior.cs +++ b/CSharpSourceCode/CampaignMechanics/Crafting/EnchanterTownBehavior.cs @@ -13,14 +13,12 @@ using TaleWorlds.Library; using TaleWorlds.ObjectSystem; using TaleWorlds.TwoDimension; -using TOR_Core.CampaignMechanics.Crafting; -using TOR_Core.CharacterDevelopment; using TOR_Core.Extensions; -using TOR_Core.Items; +using TOR_Core.Framework; using TOR_Core.Utilities; using static TOR_Core.Utilities.TORConstants; -namespace TOR_Core.CampaignMechanics.SpellTrainers; +namespace TOR_Core.CampaignMechanics.Crafting; public class EnchanterTownBehavior : CampaignBehaviorBase { @@ -278,6 +276,11 @@ private bool HasEnchanterAccess(string culture) return false; } + if (CraftingCareerHooks.EnchanterAccessGrants.Exists(grant => grant(Hero.MainHero, culture))) + { + return true; + } + switch (culture) { case TORConstants.Cultures.EMPIRE: @@ -306,11 +309,6 @@ private bool HasEnchanterAccess(string culture) return HasMatchingEnchanterCompanion(x => x.Culture.StringId == TORConstants.Cultures.BRETONNIA && x.IsSpellCaster()); case TORConstants.Cultures.ASRAI: - if (Hero.MainHero.HasCareer(TORCareers.Spellsinger)) - { - return true; - } - if (Hero.MainHero.Culture.StringId == TORConstants.Cultures.ASRAI && Hero.MainHero.IsSpellCaster() && Hero.MainHero.GetKnownLoreCount() > 0) @@ -586,37 +584,10 @@ string GetManual() void AddBeginnerBlueprintsForEnchantment() { - var career = Hero.MainHero.GetCareer(); - - if (Hero.MainHero.IsSpellCaster() && career == TORCareers.ImperialMagister) + foreach (var grantor in CraftingCareerHooks.BeginnerBlueprintGrantors) { - if (Hero.MainHero.HasKnownLore("LoreOfDeath")) - Hero.MainHero.AddEnchantmentBlueprint("emp_enchant_shyish_whisper", true); - - if (Hero.MainHero.HasKnownLore("LoreOfMetal")) - Hero.MainHero.AddEnchantmentBlueprint("emp_enchant_chamon_whisper", true); - - if (Hero.MainHero.HasKnownLore("LoreOfLight")) - Hero.MainHero.AddEnchantmentBlueprint("emp_enchant_hysh_whisper", true); - - if (Hero.MainHero.HasKnownLore("LoreOfHeavens")) - Hero.MainHero.AddEnchantmentBlueprint("emp_enchant_azyr_whisper", true); - - if (Hero.MainHero.HasKnownLore("LoreOfBeasts")) - Hero.MainHero.AddEnchantmentBlueprint("emp_enchant_ghur_whisper", true); - - if (Hero.MainHero.HasKnownLore("LoreOfLife")) - Hero.MainHero.AddEnchantmentBlueprint("emp_enchant_ghyran_whisper", true); - - if (Hero.MainHero.HasKnownLore("LoreOfFire")) - Hero.MainHero.AddEnchantmentBlueprint("emp_enchant_aqshy_whisper", true); + grantor(Hero.MainHero); } - - if (Hero.MainHero.IsSpellCaster() && Hero.MainHero.HasCareer(TORCareers.GrailDamsel)) - if (Hero.MainHero.HasKnownLore("LoreOfLife")) - Hero.MainHero.AddEnchantmentBlueprint("emp_enchant_ghyran_whisper", true); - - if (Hero.MainHero.HasCareer(TORCareers.Runelord)) Hero.MainHero.AddEnchantmentBlueprint("dw_rune_stone", true); } bool ConditionForDonation() diff --git a/CSharpSourceCode/CampaignMechanics/Crafting/EnchantingIngredientVM.cs b/CSharpSourceCode/CampaignMechanics/Crafting/EnchantingIngredientVM.cs index b1b1e391..f63ce8eb 100644 --- a/CSharpSourceCode/CampaignMechanics/Crafting/EnchantingIngredientVM.cs +++ b/CSharpSourceCode/CampaignMechanics/Crafting/EnchantingIngredientVM.cs @@ -1,7 +1,6 @@ using TaleWorlds.Core; using TaleWorlds.Core.ViewModelCollection.Information; using TaleWorlds.Library; -using TOR_Core.Items; namespace TOR_Core.CampaignMechanics.Crafting { diff --git a/CSharpSourceCode/CampaignMechanics/Crafting/EnchantingVM.cs b/CSharpSourceCode/CampaignMechanics/Crafting/EnchantingVM.cs index c6a11116..f12b00ad 100644 --- a/CSharpSourceCode/CampaignMechanics/Crafting/EnchantingVM.cs +++ b/CSharpSourceCode/CampaignMechanics/Crafting/EnchantingVM.cs @@ -6,9 +6,9 @@ using TaleWorlds.Core; using TaleWorlds.Library; using TaleWorlds.LinQuick; +using TOR_Core.CampaignMechanics.Crafting.Models; using TOR_Core.Extensions; using TOR_Core.Items; -using TOR_Core.Models; using TOR_Core.Utilities; namespace TOR_Core.CampaignMechanics.Crafting diff --git a/CSharpSourceCode/Items/InventoryUseScripts/EnchantmentBlueprintScript.cs b/CSharpSourceCode/CampaignMechanics/Crafting/EnchantmentBlueprintScript.cs similarity index 97% rename from CSharpSourceCode/Items/InventoryUseScripts/EnchantmentBlueprintScript.cs rename to CSharpSourceCode/CampaignMechanics/Crafting/EnchantmentBlueprintScript.cs index d78a1f97..d4853342 100644 --- a/CSharpSourceCode/Items/InventoryUseScripts/EnchantmentBlueprintScript.cs +++ b/CSharpSourceCode/CampaignMechanics/Crafting/EnchantmentBlueprintScript.cs @@ -8,9 +8,11 @@ using TaleWorlds.SaveSystem; using TOR_Core.CharacterDevelopment; using TOR_Core.Extensions; +using TOR_Core.Items; +using TOR_Core.Items.InventoryUseScripts; using TOR_Core.Utilities; -namespace TOR_Core.Items.InventoryUseScripts; +namespace TOR_Core.CampaignMechanics.Crafting; public class EnchantmentBlueprintScript : BaseInventoryUseScript { diff --git a/CSharpSourceCode/CampaignMechanics/Crafting/EnchantmentIngredientLootCampaignBehavior.cs b/CSharpSourceCode/CampaignMechanics/Crafting/EnchantmentIngredientLootCampaignBehavior.cs index d9b0817c..b11c79db 100644 --- a/CSharpSourceCode/CampaignMechanics/Crafting/EnchantmentIngredientLootCampaignBehavior.cs +++ b/CSharpSourceCode/CampaignMechanics/Crafting/EnchantmentIngredientLootCampaignBehavior.cs @@ -8,7 +8,6 @@ using TaleWorlds.Core; using TaleWorlds.LinQuick; using TOR_Core.Extensions; -using TOR_Core.Items; namespace TOR_Core.CampaignMechanics.Crafting; /// diff --git a/CSharpSourceCode/CampaignMechanics/Crafting/EnchantmentShopHelper.cs b/CSharpSourceCode/CampaignMechanics/Crafting/EnchantmentShopHelper.cs index bb6376b0..a792daab 100644 --- a/CSharpSourceCode/CampaignMechanics/Crafting/EnchantmentShopHelper.cs +++ b/CSharpSourceCode/CampaignMechanics/Crafting/EnchantmentShopHelper.cs @@ -35,6 +35,7 @@ public static void OpenEnchantmentRecipeShop(List prefixList, string cul MBInformationManager.ShowMultiSelectionInquiry(inquirydata, true); } + //Note that RequiredSkillValue is not only gating purchasability, but it's also setting the cost for custom resources. private readonly record struct PurchasableBlueprint(ItemObject Item, string BlueprintId, SkillObject RequiredSkill, int RequiredSkillValue, string Restriction, List EligibleHeroes); private static List GetPurchasableBlueprints(List prefixList) diff --git a/CSharpSourceCode/CampaignMechanics/Crafting/MODULE.md b/CSharpSourceCode/CampaignMechanics/Crafting/MODULE.md new file mode 100644 index 00000000..eb8cf09a --- /dev/null +++ b/CSharpSourceCode/CampaignMechanics/Crafting/MODULE.md @@ -0,0 +1,78 @@ +# Crafting — Module Reference + +Quick per-class index of `CampaignMechanics/Crafting/`. For the narrative version (how the +pieces fit together) see [`CLAUDE.md`](./CLAUDE.md) in this folder; this file is a flat +lookup table instead. + +## Registration + +| Class | File | What it does | +|---|---|---| +| `CraftingModule` | `CraftingModule.cs` | `[TORModule] : ITORModule` — registers this module's campaign behaviors (`EnchanterTownBehavior`, `TORArtisanDistrictCampaignBehavior`, `PriestBehavior`, `EnchantmentIngredientLootCampaignBehavior`, `LootCampaignBehavior`) and models (`TORSmithingModel`, `TOREnchantmentIngredientsModel`, `TOREnchantmentCraftingModel`) with `SubModule`. | + +## Campaign behaviors + +| Class | File | What it does | +|---|---|---| +| `EnchanterTownBehavior` | `EnchanterTownBehavior.cs` | Town-service entry point: maps culture → enchanter NPC template/dialogs and opens `EnchantingScreen`. | +| `TORArtisanDistrictCampaignBehavior` (+ `TorItemDuplicationData`, `TorItemBeingCraftedData`) | `TORArtisanDistrictCampaignBehavior.cs` | Artisan-district service: tracks duplicated/enchanted items (via `TORCampaignEvents.ItemDuplicated`) and queued item-crafting state; removes the vanilla smithy menu and rearranges town menus in its place. | +| `PriestBehavior` | `PriestBehavior.cs` | Town-service entry point for priest-blessed enchantments: maps culture/cult → priest NPC template and opens the blessing-flavored enchanting shop. | +| `EnchantmentIngredientLootCampaignBehavior` | `EnchantmentIngredientLootCampaignBehavior.cs` | Adds enchanting ingredients to post-battle loot (`OnCollectLootsItemsEvent`) by summing per-enemy drop factors from `TOREnchantmentIngredientsModel`; also clears any leftover ingredient stock from settlements daily. | +| `LootCampaignBehavior` | `LootCampaignBehavior.cs` | Adds/removes magical (item-trait) loot to/from the lootable pool, including pruning items no longer used by the player or companions. | + +## Models + +| Class | File | What it does | +|---|---|---| +| `TORSmithingModel` (`: DefaultSmithingModel`) | `Models/TORSmithingModel.cs` | Vanilla-smithing overrides: hides crafting templates with no accessible pieces (`ValidateHiddenCraftingTemplates`), filters NPC smithing orders by culture-appropriate weapon category, and applies career/religion energy-cost and refining-formula modifiers via `CraftingCareerHooks`. | +| `TOREnchantmentIngredientsModel` | `Models/TOREnchantmentIngredientsModel.cs` | Enchanting-ingredient economy: per-ingredient custom-resource cost, drop factor per defeated character/context, and the random+career-bonus roll that turns a battle's drop score into a loot amount. | +| `TOREnchantmentCraftingModel` | `Models/TOREnchantmentCraftingModel.cs` | Enchanting-session limits: max simultaneous enchantments/blessings a party of heroes can apply (career/perk/culture-driven), and the effective ingredient cost of a trait after career cost-reduction hooks. | + +## Static helpers + +| Class | File | What it does | +|---|---|---| +| `EnchantmentHelper` | `EnchantmentHelper.cs` | Blueprint data/eligibility queries (who can learn a blueprint, is it already known/in inventory) and `CreateEnchantedItem`/`CreateItemCopy` — clones an `ItemObject` with a fresh id and applies chosen traits/`ItemModifier`. | +| `EnchantmentShopHelper` | `EnchantmentShopHelper.cs` | Builds and shows the "learn a blueprint" purchase inquiry (eligible heroes, gold/custom-resource cost, requirement text) on top of `EnchantmentHelper`'s data. | +| `CraftingModelsExtensions` | `CraftingModelsExtensions.cs` | `GameModels` extension methods (`GetSmithingModel()`, `GetEnchantmentIngredientModel()`) — this module's equivalent of the shared `GameModelsExtensions`, kept local so `Framework` doesn't need to know these concrete model types. | +| `TorEnchantingIngredients` (+ `TorTradeGoodType` enum) | `TorEnchantingIngredients.cs` | Static lookup of the six enchanting-ingredient `ItemObject`s (Arcane Scroll, Blessed Water, Dragon Blood, Amber Crystal, Warpstone Dust, Gem Stone), loaded once per campaign via `LoadIngredients()`. | + +## View-models / UI + +| Class | File | What it does | +|---|---|---| +| `EnchantingVM` | `EnchantingVM.cs` | Root view-model for the enchanting screen: item list, trait list/selection, ingredient list, preview tableau. | +| `EnchantableItemVM` | `EnchantableItemVM.cs` | One equippable item eligible for enchanting, shown in `EnchantingVM`'s item list. | +| `EnchantableTraitVM` | `EnchantableTraitVM.cs` | One selectable enchantment trait/effect, with name/icon/description/tooltip and a selection callback. | +| `EnchantingIngredientVM` | `EnchantingIngredientVM.cs` | One ingredient row: current vs. pending amount, name/icon, tooltip. | +| `EnchantingIngredientWidget` (internal) | `EnchantingIngredientWidget.cs` | `: RichTextWidget` — renders an ingredient amount in red when it goes negative (insufficient stock). | +| `EnchantingItemTableauVM` | `EnchantingItemTableauVM.cs` | 3D item-preview tableau data (item id, banner code, item-modifier id) for the equipment being enchanted. | +| `CraftingVMExtension` | `CraftingVMExtension.cs` | `[ViewModelExtension(typeof(CraftingVM))]` — adds a "Refine All" button (repeats refinement until stamina/materials run out) to the vanilla smithing-refinement screen. | +| `RefinementVMExtension` | `RefinementVMExtension.cs` | `[ViewModelExtension(typeof(RefinementVM))]` — backs `CraftingVMExtension`'s "Refine All": computes the max repeatable-refinement count from stamina and material stock and executes them in a loop. | + +## Screen / state + +| Class | File | What it does | +|---|---|---| +| `EnchantingScreen` (`: ScreenBase, IGameStateListener`) | `EnchantingScreen.cs` | Gauntlet screen that hosts `EnchantingVM`; opened via `EnchantingScreen.Open()`. | +| `EnchantingState` (`: GameState`) | `EnchantingState.cs` | Minimal `GameState` paired with `EnchantingScreen` via `[GameStateScreen]`. | + +## Item scripts + +| Class | File | What it does | +|---|---|---| +| `EnchantmentBlueprintScript` (`: BaseInventoryUseScript`) | `EnchantmentBlueprintScript.cs` | Inventory-use script attached to blueprint items: on use, offers eligible party heroes (by skill/attribute/lore requirement) the chance to learn the referenced enchantment blueprint. | + +## Harmony patches + +| Class | File | What it does | +|---|---|---| +| `CraftingPatches` | `CraftingPatches.cs` | Filters the vanilla crafting-category popup and daily NPC smithing-order generation down to TOR's valid/culture-appropriate `CraftingTemplate`s; re-initializes saved crafted items before the game strips non-ready objects. | + +## See also + +- `Items/TorEnchantingIngredients` usage and `Items/ItemTrait` — the underlying enchantment + data model this UI drives. +- `CraftingCareerHooks` (in `Framework/`) — the career/religion cost-reduction and loot-bonus + hooks `TORSmithingModel`/`TOREnchantmentCraftingModel`/`TOREnchantmentIngredientsModel` + call into, kept out of this module so Crafting doesn't need to know Career/Religion types. diff --git a/CSharpSourceCode/Models/TOREnchantmentCraftingModel.cs b/CSharpSourceCode/CampaignMechanics/Crafting/Models/TOREnchantmentCraftingModel.cs similarity index 68% rename from CSharpSourceCode/Models/TOREnchantmentCraftingModel.cs rename to CSharpSourceCode/CampaignMechanics/Crafting/Models/TOREnchantmentCraftingModel.cs index 0ca8bb7f..aac67f90 100644 --- a/CSharpSourceCode/Models/TOREnchantmentCraftingModel.cs +++ b/CSharpSourceCode/CampaignMechanics/Crafting/Models/TOREnchantmentCraftingModel.cs @@ -1,16 +1,16 @@ using System; using System.Collections.Generic; -using System.Linq; using TaleWorlds.CampaignSystem; using TaleWorlds.Core; -using TOR_Core.AbilitySystem; using TOR_Core.CharacterDevelopment; using TOR_Core.CharacterDevelopment.CareerSystem; using TOR_Core.Extensions; +using TOR_Core.Framework; using TOR_Core.Items; using TOR_Core.Utilities; +using TOR_Core.CampaignMechanics.Crafting; -namespace TOR_Core.Models; +namespace TOR_Core.CampaignMechanics.Crafting.Models; public class TOREnchantmentCraftingModel : GameModel { @@ -57,20 +57,19 @@ public int GetEffectiveIngredientAmount(List heroes, ItemTrait itemTrait, { if (hero.HasKnownEnchantmentBlueprint(itemTrait.ItemTraitStringId)) { + // Note: this call is a different kind of coupling than the module-specific + // hooks below - PassiveEffectType is a generic, career-agnostic dispatch + // mechanism (CareerHelper doesn't know "Grail Damsel"/"Necrarch"/"Runelord" by + // name, it just applies every choice tagged EnchantmentCostReduction), it's + // just currently misplaced under CharacterDevelopment/CareerSystem alongside + // genuinely Careers-specific content. Left as-is; properly fixing it means + // relocating CareerHelper/PassiveEffectType to Framework, a separate, larger + // move with a much bigger blast radius (CareerHelper is used everywhere). CharacterDevelopment.CareerSystem.CareerHelper.ApplyBasicCareerPassives(hero, ref explainedNumber, PassiveEffectType.EnchantmentCostReduction, true); - // Greylord: For every known spell, reduce enchantment cost by 1% - if (hero.HasCareerChoice("ForbiddenScrollsOfSapheryPassive3")) + foreach (var factor in CraftingCareerHooks.EnchantmentCostReductionFactors) { - var choice = TORCareerChoices.GetChoice("ForbiddenScrollsOfSapheryPassive3"); - if (choice != null) - { - var spellCount = hero.GetExtendedInfo().AllAbilities - .Select(AbilityFactory.GetTemplate) - .Count(ability => ability != null && ability.IsSpell); - var reductionPercent = spellCount * choice.GetPassiveValue(); - explainedNumber.AddFactor(reductionPercent / 100f); - } + explainedNumber.AddFactor(factor(hero)); } } } diff --git a/CSharpSourceCode/Models/TOREnchantmentIngredientsModel.cs b/CSharpSourceCode/CampaignMechanics/Crafting/Models/TOREnchantmentIngredientsModel.cs similarity index 95% rename from CSharpSourceCode/Models/TOREnchantmentIngredientsModel.cs rename to CSharpSourceCode/CampaignMechanics/Crafting/Models/TOREnchantmentIngredientsModel.cs index 26a89805..1eea7b66 100644 --- a/CSharpSourceCode/Models/TOREnchantmentIngredientsModel.cs +++ b/CSharpSourceCode/CampaignMechanics/Crafting/Models/TOREnchantmentIngredientsModel.cs @@ -2,12 +2,12 @@ using TaleWorlds.CampaignSystem; using TaleWorlds.CampaignSystem.MapEvents; using TaleWorlds.Core; -using TOR_Core.CharacterDevelopment; +using TOR_Core.CampaignMechanics.Crafting; using TOR_Core.Extensions; -using TOR_Core.Items; +using TOR_Core.Framework; using TOR_Core.Utilities; -namespace TOR_Core.CampaignMechanics.Crafting; +namespace TOR_Core.CampaignMechanics.Crafting.Models; public class TOREnchantmentIngredientsModel : GameModel { @@ -48,14 +48,9 @@ public int CalculateResultAmount(float dropscore, TorTradeGoodType ingredient, f { float careerBonus = 1f; - // Orc Shaman enchantment loot bonus - if (Hero.MainHero.HasCareerChoice("BonesAnFirepitzPassive3")) + foreach (var bonus in CraftingCareerHooks.IngredientLootBonusFactors) { - var choice = TORCareerChoices.GetChoice("BonesAnFirepitzPassive3"); - if (choice != null) - { - careerBonus += choice.GetPassiveValue(); // 0.25 for 25% - } + careerBonus += bonus(Hero.MainHero); } return (int)(dropscore * GetDropAmplitude(ingredient) * RandomMultiplier(ingredient) * playerEarnedLootRate * careerBonus); diff --git a/CSharpSourceCode/Models/TORSmithingModel.cs b/CSharpSourceCode/CampaignMechanics/Crafting/Models/TORSmithingModel.cs similarity index 89% rename from CSharpSourceCode/Models/TORSmithingModel.cs rename to CSharpSourceCode/CampaignMechanics/Crafting/Models/TORSmithingModel.cs index 2cc8a9d2..681ea4e7 100644 --- a/CSharpSourceCode/Models/TORSmithingModel.cs +++ b/CSharpSourceCode/CampaignMechanics/Crafting/Models/TORSmithingModel.cs @@ -5,11 +5,12 @@ using TaleWorlds.CampaignSystem.GameComponents; using TaleWorlds.Core; using TaleWorlds.Library; -using TOR_Core.CharacterDevelopment; using TOR_Core.Extensions; +using TOR_Core.Framework; using TOR_Core.Utilities; +using RefiningFormula = TaleWorlds.Core.Crafting.RefiningFormula; -namespace TOR_Core.Models +namespace TOR_Core.CampaignMechanics.Crafting.Models { /// /// Categories for weapon templates used in culture-based order filtering. @@ -132,7 +133,7 @@ public virtual bool IsCultureAppropriateOrder(string templateId, string cultureI return false; } - public override int GetEnergyCostForRefining(ref Crafting.RefiningFormula refineFormula, Hero hero) + public override int GetEnergyCostForRefining(ref RefiningFormula refineFormula, Hero hero) { var value = base.GetEnergyCostForRefining(ref refineFormula, hero); return ApplyEnergyCostModifiers(value, hero); @@ -152,15 +153,14 @@ public override int GetEnergyCostForSmithing(ItemObject item, Hero hero) private int ApplyEnergyCostModifiers(int value, Hero hero) { - if (hero.HasCareer(TORCareers.Runelord)) + foreach (var modifier in CraftingCareerHooks.EnergyCostModifiers) { - if (Hero.MainHero.HasCareerChoice("ForgefireBurningPassive3")) - { - var reduction = value * 0.4f; - value -= (int)MathF.Round(reduction); - } + value = modifier(hero, value); } + // Note: this one is a Religion-module coupling (cult_of_grungni is a blessing, not + // a career), a separate kind of coupling from the Careers one this pass addresses - + // left as-is. if (hero.PartyBelongedTo != null && hero.PartyBelongedTo.HasBlessing("cult_of_grungni")) { var reduction = value * 0.25f; @@ -170,34 +170,22 @@ private int ApplyEnergyCostModifiers(int value, Hero hero) return value; } - public override IEnumerable GetRefiningFormulas( + public override IEnumerable GetRefiningFormulas( Hero weaponsmith) { var values = base.GetRefiningFormulas(weaponsmith); - - - if (weaponsmith.HasCareer(TORCareers.Runelord)) + if (CraftingCareerHooks.RefiningFormulaModifiers.Count > 0) { - var newValues = new List(); + var newValues = new List(); foreach (var value in values) { - if (weaponsmith.HasCareerChoice("ForgefireBurningPassive1") && value.Output == CraftingMaterials.Charcoal) + var modified = value; + foreach (var modifier in CraftingCareerHooks.RefiningFormulaModifiers) { - var entry = new Crafting.RefiningFormula(value.Input1, value.Input1Count, value.Input2, value.Input2Count, value.Output, - value.OutputCount + 1); - newValues.Add(entry); - continue; - } - if (weaponsmith.HasCareerChoice("ForgefireBurningPassive2") && value.Output is CraftingMaterials.Iron1 or CraftingMaterials.Iron2 or CraftingMaterials.Iron3 or CraftingMaterials.Iron4) - { - - var entry = new Crafting.RefiningFormula(value.Input1, value.Input1Count, value.Input2, value.Input2Count, value.Output, - value.OutputCount * 2); - newValues.Add(entry); - continue; + modified = modifier(weaponsmith, modified); } - newValues.Add(value); + newValues.Add(modified); } values = newValues; diff --git a/CSharpSourceCode/Extensions/UI/RefinementVMExtension.cs b/CSharpSourceCode/CampaignMechanics/Crafting/RefinementVMExtension.cs similarity index 98% rename from CSharpSourceCode/Extensions/UI/RefinementVMExtension.cs rename to CSharpSourceCode/CampaignMechanics/Crafting/RefinementVMExtension.cs index a3ab6ada..833efa2a 100644 --- a/CSharpSourceCode/Extensions/UI/RefinementVMExtension.cs +++ b/CSharpSourceCode/CampaignMechanics/Crafting/RefinementVMExtension.cs @@ -6,8 +6,10 @@ using TaleWorlds.CampaignSystem.ViewModelCollection.WeaponCrafting.Refinement; using TaleWorlds.Library; using TaleWorlds.MountAndBlade.View; +using TOR_Core.Extensions; +using TOR_Core.Extensions.UI; -namespace TOR_Core.Extensions.UI +namespace TOR_Core.CampaignMechanics.Crafting { [ViewModelExtension(typeof(RefinementVM))] public class RefinementVMExtension : BaseViewModelExtension diff --git a/CSharpSourceCode/Items/TorEnchantingIngredients.cs b/CSharpSourceCode/CampaignMechanics/Crafting/TorEnchantingIngredients.cs similarity index 98% rename from CSharpSourceCode/Items/TorEnchantingIngredients.cs rename to CSharpSourceCode/CampaignMechanics/Crafting/TorEnchantingIngredients.cs index 3be7273d..55558e06 100644 --- a/CSharpSourceCode/Items/TorEnchantingIngredients.cs +++ b/CSharpSourceCode/CampaignMechanics/Crafting/TorEnchantingIngredients.cs @@ -2,7 +2,7 @@ using TaleWorlds.CampaignSystem; using TaleWorlds.Core; -namespace TOR_Core.Items +namespace TOR_Core.CampaignMechanics.Crafting { public static class TorEnchantingIngredients { diff --git a/CSharpSourceCode/CharacterDevelopment/CareerSystem/CareerButton/RunelordCareerButtonBehavior.cs b/CSharpSourceCode/CharacterDevelopment/CareerSystem/CareerButton/RunelordCareerButtonBehavior.cs index 41b9cdc0..72c26e0c 100644 --- a/CSharpSourceCode/CharacterDevelopment/CareerSystem/CareerButton/RunelordCareerButtonBehavior.cs +++ b/CSharpSourceCode/CharacterDevelopment/CareerSystem/CareerButton/RunelordCareerButtonBehavior.cs @@ -5,6 +5,7 @@ using TaleWorlds.Core; using TaleWorlds.LinQuick; using TaleWorlds.Localization; +using TOR_Core.CampaignMechanics.Crafting; using TOR_Core.Extensions; using TOR_Core.Items; using TOR_Core.Utilities; diff --git a/CSharpSourceCode/CharacterDevelopment/CareerSystem/CraftingCareerHookRegistrations.cs b/CSharpSourceCode/CharacterDevelopment/CareerSystem/CraftingCareerHookRegistrations.cs new file mode 100644 index 00000000..1ba0a6f0 --- /dev/null +++ b/CSharpSourceCode/CharacterDevelopment/CareerSystem/CraftingCareerHookRegistrations.cs @@ -0,0 +1,139 @@ +using System.Linq; +using TaleWorlds.CampaignSystem; +using TaleWorlds.Core; +using TaleWorlds.Library; +using TOR_Core.AbilitySystem; +using TOR_Core.Extensions; +using TOR_Core.Framework; +using TOR_Core.Utilities; +using RefiningFormula = TaleWorlds.Core.Crafting.RefiningFormula; + +namespace TOR_Core.CharacterDevelopment.CareerSystem +{ + /// + /// The one place CharacterDevelopment/CareerSystem is allowed to know about + /// CampaignMechanics/Crafting: pushes each career's enchanting/smithing-specific effects + /// into Crafting's CraftingCareerHooks extension points, so Crafting's own code never + /// references a Career/CareerChoice by name. Called once from SubModule.BeginGameStart, + /// after TORCareers/TORCareerChoices exist (the closures below resolve lazily at call + /// time regardless, so exact ordering doesn't actually matter). + /// + public static class CraftingCareerHookRegistrations + { + public static void RegisterAll() + { + RegisterGreyLordEnchantmentCostReduction(); + RegisterOrcShamanIngredientLootBonus(); + RegisterBeginnerBlueprintGrants(); + RegisterSpellsingerEnchanterAccess(); + RegisterRunelordSmithingBonuses(); + } + + /// + /// Grey Lord's "ForbiddenScrollsOfSapheryPassive3": -1% enchantment cost per known spell. + /// Tagged PassiveEffectType.Special in GreyLordCareerChoices.cs (opts out of the generic + /// CareerHelper dispatch since it needs a per-hero-computed multiplier, not a flat value). + /// + private static void RegisterGreyLordEnchantmentCostReduction() + { + CraftingCareerHooks.EnchantmentCostReductionFactors.Add(hero => + { + if (!hero.HasCareerChoice("ForbiddenScrollsOfSapheryPassive3")) return 0f; + + var choice = TORCareerChoices.GetChoice("ForbiddenScrollsOfSapheryPassive3"); + if (choice == null) return 0f; + + var spellCount = hero.GetExtendedInfo().AllAbilities + .Select(AbilityFactory.GetTemplate) + .Count(ability => ability != null && ability.IsSpell); + + return spellCount * choice.GetPassiveValue() / 100f; + }); + } + + /// + /// Orc Shaman's "BonesAnFirepitzPassive3": +25% enchantment-ingredient loot. Also tagged + /// PassiveEffectType.Special (evaluated as an additive bonus factor on Hero.MainHero + /// specifically, not the generic per-choice dispatch). + /// + private static void RegisterOrcShamanIngredientLootBonus() + { + CraftingCareerHooks.IngredientLootBonusFactors.Add(hero => + { + if (!hero.HasCareerChoice("BonesAnFirepitzPassive3")) return 0f; + + var choice = TORCareerChoices.GetChoice("BonesAnFirepitzPassive3"); + return choice?.GetPassiveValue() ?? 0f; + }); + } + + /// + /// Free beginner enchantment blueprints granted just by having picked a relevant career - + /// Imperial Magister (per known Lore), Grail Damsel (Lore of Life), Runelord (rune stone). + /// + private static void RegisterBeginnerBlueprintGrants() + { + CraftingCareerHooks.BeginnerBlueprintGrantors.Add(hero => + { + if (hero.IsSpellCaster() && hero.GetCareer() == TORCareers.ImperialMagister) + { + if (hero.HasKnownLore("LoreOfDeath")) hero.AddEnchantmentBlueprint("emp_enchant_shyish_whisper", true); + if (hero.HasKnownLore("LoreOfMetal")) hero.AddEnchantmentBlueprint("emp_enchant_chamon_whisper", true); + if (hero.HasKnownLore("LoreOfLight")) hero.AddEnchantmentBlueprint("emp_enchant_hysh_whisper", true); + if (hero.HasKnownLore("LoreOfHeavens")) hero.AddEnchantmentBlueprint("emp_enchant_azyr_whisper", true); + if (hero.HasKnownLore("LoreOfBeasts")) hero.AddEnchantmentBlueprint("emp_enchant_ghur_whisper", true); + if (hero.HasKnownLore("LoreOfLife")) hero.AddEnchantmentBlueprint("emp_enchant_ghyran_whisper", true); + if (hero.HasKnownLore("LoreOfFire")) hero.AddEnchantmentBlueprint("emp_enchant_aqshy_whisper", true); + } + + if (hero.IsSpellCaster() && hero.HasCareer(TORCareers.GrailDamsel)) + if (hero.HasKnownLore("LoreOfLife")) + hero.AddEnchantmentBlueprint("emp_enchant_ghyran_whisper", true); + + if (hero.HasCareer(TORCareers.Runelord)) hero.AddEnchantmentBlueprint("dw_rune_stone", true); + }); + } + + /// + /// Spellsinger gets Asrai-culture enchanter access regardless of the hero's own culture + /// or a matching companion (the normal rule Crafting itself enforces). + /// + private static void RegisterSpellsingerEnchanterAccess() + { + CraftingCareerHooks.EnchanterAccessGrants.Add((hero, culture) => + culture == TORConstants.Cultures.ASRAI && hero.HasCareer(TORCareers.Spellsinger)); + } + + /// + /// Runelord: -40% smithing energy cost with ForgefireBurningPassive3; ForgefireBurningPassive1 + /// grants +1 Charcoal from refining, ForgefireBurningPassive2 doubles Iron refining output. + /// + private static void RegisterRunelordSmithingBonuses() + { + CraftingCareerHooks.EnergyCostModifiers.Add((hero, value) => + { + if (hero.HasCareer(TORCareers.Runelord) && Hero.MainHero.HasCareerChoice("ForgefireBurningPassive3")) + { + var reduction = value * 0.4f; + value -= (int)MathF.Round(reduction); + } + return value; + }); + + CraftingCareerHooks.RefiningFormulaModifiers.Add((hero, formula) => + { + if (!hero.HasCareer(TORCareers.Runelord)) return formula; + + if (hero.HasCareerChoice("ForgefireBurningPassive1") && formula.Output == CraftingMaterials.Charcoal) + { + return new RefiningFormula(formula.Input1, formula.Input1Count, formula.Input2, formula.Input2Count, formula.Output, formula.OutputCount + 1); + } + if (hero.HasCareerChoice("ForgefireBurningPassive2") && formula.Output is CraftingMaterials.Iron1 or CraftingMaterials.Iron2 or CraftingMaterials.Iron3 or CraftingMaterials.Iron4) + { + return new RefiningFormula(formula.Input1, formula.Input1Count, formula.Input2, formula.Input2Count, formula.Output, formula.OutputCount * 2); + } + return formula; + }); + } + } +} diff --git a/CSharpSourceCode/Extensions/GameModelsExtensions.cs b/CSharpSourceCode/Extensions/GameModelsExtensions.cs index 98f63178..d8292962 100644 --- a/CSharpSourceCode/Extensions/GameModelsExtensions.cs +++ b/CSharpSourceCode/Extensions/GameModelsExtensions.cs @@ -1,6 +1,5 @@ using System.Linq; using TaleWorlds.CampaignSystem; -using TOR_Core.CampaignMechanics.Crafting; using TOR_Core.Models; namespace TOR_Core.Extensions @@ -22,11 +21,6 @@ public static TORCustomResourceModel GetCustomResourceModel(this GameModels mode return models.GetGameModels().OfType().LastOrDefault(); } - public static TOREnchantmentIngredientsModel GetEnchantmentIngredientModel(this GameModels models) - { - return models.GetGameModels().OfType().LastOrDefault(); - } - public static TORCompanionTrainingModel GetCompanionTrainingModel(this GameModels models) { return models.GetGameModels().OfType().LastOrDefault(); @@ -46,10 +40,5 @@ public static TORSiegeEngineCalculationModel GetSiegeEngineCalculationModel(this { return models.GetGameModels().OfType().LastOrDefault(); } - - public static TORSmithingModel GetSmithingModel(this GameModels models) - { - return models.GetGameModels().OfType().LastOrDefault(); - } } } \ No newline at end of file diff --git a/CSharpSourceCode/Extensions/HeroExtensions.cs b/CSharpSourceCode/Extensions/HeroExtensions.cs index 44010f5d..dace2903 100644 --- a/CSharpSourceCode/Extensions/HeroExtensions.cs +++ b/CSharpSourceCode/Extensions/HeroExtensions.cs @@ -21,6 +21,7 @@ using FaceGen = TaleWorlds.Core.FaceGen; using LogLevel = NLog.LogLevel; using static TOR_Core.Utilities.TORConstants; +using TOR_Core.CampaignMechanics.Crafting; namespace TOR_Core.Extensions { diff --git a/CSharpSourceCode/Framework/CraftingCareerHooks.cs b/CSharpSourceCode/Framework/CraftingCareerHooks.cs new file mode 100644 index 00000000..b9f571db --- /dev/null +++ b/CSharpSourceCode/Framework/CraftingCareerHooks.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using TaleWorlds.CampaignSystem; +using RefiningFormula = TaleWorlds.Core.Crafting.RefiningFormula; + +namespace TOR_Core.Framework +{ + /// + /// Shared contract between CampaignMechanics/Crafting and whichever module wants to + /// influence enchanting/smithing (Careers, chiefly) - lives here rather than inside + /// Crafting's own folder so that neither module has to reference the other's namespace: + /// Crafting depends on this (Framework) to consume the hooks, and the module contributing + /// effects (today, CharacterDevelopment/CareerSystem/CraftingCareerHookRegistrations, + /// called once from SubModule.BeginGameStart after the career registries exist) depends on + /// this (Framework) to populate them. Nothing under CampaignMechanics/Crafting/ references + /// CharacterDevelopment/CareerSystem for career-specific content any more (the one + /// remaining reference, CareerHelper.ApplyBasicCareerPassives(..., + /// PassiveEffectType.EnchantmentCostReduction, ...) in TOREnchantmentCraftingModel, is a + /// different case - see its comment), and CraftingCareerHookRegistrations never references + /// CampaignMechanics/Crafting either. + /// + public static class CraftingCareerHooks + { + /// Additional multiplicative cost-reduction factors for enchanting (folded in via ExplainedNumber.AddFactor), evaluated once per hero contributing to an enchantment. + public static readonly List> EnchantmentCostReductionFactors = new(); + + /// Additional bonus factors for enchantment-ingredient loot amount (added to the base 1.0 multiplier), evaluated for Hero.MainHero. + public static readonly List> IngredientLootBonusFactors = new(); + + /// Grants free beginner enchantment blueprints; called once per Hero.MainHero when the enchanter town service opens. + public static readonly List> BeginnerBlueprintGrantors = new(); + + /// Extra "can use this culture's enchanter" checks, called as (Hero.MainHero, cultureId); true from any one grants access regardless of the normal culture/companion rule. + public static readonly List> EnchanterAccessGrants = new(); + + /// Additional energy-cost modifiers for smithing/refining/smelting, applied as (hero, runningCost) -> modifiedCost, chained in registration order. + public static readonly List> EnergyCostModifiers = new(); + + /// Per-formula modifiers for the refining-formula list, applied as (hero, formula) -> (possibly modified) formula, chained in registration order. + public static readonly List> RefiningFormulaModifiers = new(); + } +} diff --git a/CSharpSourceCode/Framework/ITORModule.cs b/CSharpSourceCode/Framework/ITORModule.cs new file mode 100644 index 00000000..58d5f3df --- /dev/null +++ b/CSharpSourceCode/Framework/ITORModule.cs @@ -0,0 +1,27 @@ +using TaleWorlds.CampaignSystem; +using TaleWorlds.Core; +using TaleWorlds.MountAndBlade; + +namespace TOR_Core.Framework +{ + /// + /// Registration class for module. We do this so every module is self-contained. + /// + public interface ITORModule + { + /// Template/XML loading, Harmony setup specific to this module, etc. Called from SubModule.OnSubModuleLoad. + void OnSubModuleLoad(); + + /// starter.AddBehavior(...) for every CampaignBehaviorBase this module owns. Called from SubModule.InitializeGameStarter. + void RegisterCampaignBehaviors(CampaignGameStarter starter); + + /// gameStarterObject.AddModel(...) for every GameModel this module owns. Called from SubModule.OnGameStart. + void RegisterModels(IGameStarter gameStarterObject); + + /// mission.AddMissionBehavior(...) for every mission behavior this module owns. Called from SubModule.OnMissionBehaviorInitialize. + void RegisterMissionBehaviors(Mission mission); + + /// game.ObjectManager.RegisterType(...) for every custom object type this module owns. Called from SubModule.BeginGameStart. + void RegisterGameObjectTypes(Game game); + } +} diff --git a/CSharpSourceCode/Framework/TORModuleAttribute.cs b/CSharpSourceCode/Framework/TORModuleAttribute.cs new file mode 100644 index 00000000..785de9b9 --- /dev/null +++ b/CSharpSourceCode/Framework/TORModuleAttribute.cs @@ -0,0 +1,17 @@ +using System; + +namespace TOR_Core.Framework +{ + /// + /// Marks an implementation as a module for future + /// reflection-based discovery/registration (see docs/vertical-slicing-proposal.md) - + /// the same pattern Extensions/UI's ViewModelExtensionManager already uses for + /// [ViewModelExtension]. No discovery/registry consumes this attribute yet; modules are + /// still registered by an explicit call from SubModule.cs. Tagging a module with this + /// now costs nothing and documents intent for when that registry is built. + /// + [AttributeUsage(AttributeTargets.Class)] + public class TORModuleAttribute : Attribute + { + } +} diff --git a/CSharpSourceCode/HarmonyPatches/ViewModelPatches.cs b/CSharpSourceCode/HarmonyPatches/ViewModelPatches.cs index d898c699..c97fa34e 100644 --- a/CSharpSourceCode/HarmonyPatches/ViewModelPatches.cs +++ b/CSharpSourceCode/HarmonyPatches/ViewModelPatches.cs @@ -8,6 +8,7 @@ using TaleWorlds.Library; using TaleWorlds.LinQuick; using TaleWorlds.MountAndBlade; +using TOR_Core.CampaignMechanics.Crafting; using TOR_Core.CharacterDevelopment; using TOR_Core.Extensions; using TOR_Core.Extensions.UI; diff --git a/CSharpSourceCode/INDEX.md b/CSharpSourceCode/INDEX.md new file mode 100644 index 00000000..8ad305cf --- /dev/null +++ b/CSharpSourceCode/INDEX.md @@ -0,0 +1,134 @@ +# TOR_Core — Index + +**The Old Realms** is a Mount & Blade II: Bannerlord total-conversion mod bringing Games +Workshop's *Warhammer Fantasy Battles* setting to Bannerlord. This is `TOR_Core`, the main +C# gameplay-logic module (there are sibling modules — `TOR_Armory` for art/data assets, +`TOR_Environment`, and Bannerlord's own `Native`/`SandBox`/`StoryMode`/`CustomBattle` — +referenced by the launch args in `TOR_Core.csproj`, but not present in this source tree). + +Every folder in this tree (down to leaf subfolders) has its own `CLAUDE.md` with details; +this file is the map of how they fit together. Start here, then drill into the folder that +owns the system you're touching. + +## Orientation + +- **Entry point**: `SubModule.cs` (`TOR_Core.SubModule : MBSubModuleBase`). Read this file + first when you need to know "where does X get registered/initialized" — it is the single + place that lists every `CampaignBehaviorBase`, `GameModel`, and mission behavior the mod + adds, plus Harmony setup and startup ordering. If a system isn't wired up, it starts here. +- **Project file**: `TOR_Core.csproj` — old-style (non-SDK) .NET Framework 4.8 project; + every source file must be listed in a `` element or it won't build. + References are DLLs from the installed game (`../../../bin/Win64_Shipping_Client/`) and + sibling modules (`Native`, `SandBox`, `StoryMode`, `CustomBattle`) — this is a Bannerlord + mod, not a standalone app; you cannot build/run it without a Bannerlord installation. +- **`lib/`** — two vendored DLLs (`ink-engine-runtime.dll`, `ink_compiler.dll`) for the + `Ink/` narrative-scripting integration. **`obj/`**/**`bin/`** — build output, ignore. +- **`Properties/`** — just `AssemblyInfo.cs`. + +## Architectural patterns you'll see everywhere + +- **`CampaignBehaviorBase` per mechanic** — almost every gameplay system in + `CampaignMechanics/` is one behavior class registered in `SubModule.InitializeGameStarter`. + To find how a mechanic starts, grep its behavior class name in `SubModule.cs`. +- **`GameModel` overrides** — `Models/` replaces vanilla formulas one at a time + (`TORXyzModel : DefaultXyzModel`), registered in `SubModule.OnGameStart`. To change a + formula, find the matching model here before writing a Harmony patch. +- **Harmony patches** (`HarmonyPatches/`) are the fallback for anything vanilla doesn't + expose a model/behavior/virtual method for. Most patch at `OnSubModuleLoad`; a few need + `[HarmonyPatchCategory("LatePatches")]` to run after `Game.Current`'s text manager exists. +- **XML-defined template data + a static loader/factory**: `AbilityTemplate` + (`AbilitySystem/AbilityFactory`), `TriggeredEffectTemplate` + (`BattleMechanics/TriggeredEffect/TriggeredEffectManager`), `StatusEffectTemplate` + (`BattleMechanics/StatusEffect/StatusEffectManager`), `ItemTrait` + (`Items/ItemTraitManager`), `ReligionObject`/`CareerObject`/`CareerChoiceObject` + (native `MBObjectManager` + XML). All loaded once in `SubModule.OnSubModuleLoad`/ + `BeginGameStart`. If you need to add a new spell/effect/trait/career-choice, you're + almost always adding a data entry + maybe one script class, not new infrastructure. +- **Utility-AI** (`BattleMechanics/AI`) — behaviors implement `IAgentBehavior` and score + themselves via `Axis`/`ScoringFunctions`; `DecisionManager` picks the best score. This + pattern is specific to spellcaster AI (`CastingAI/`) but the primitives + (`CommonAIFunctions/`) are reusable. +- **Extension methods over subclassing** — `Extensions/` adds behavior to vanilla types + (`Agent`, `Hero`, `CharacterObject`, ...) as static extension methods rather than wrapper + classes, since most vanilla types aren't designed to be subclassed. +- **Two ways to attach "extra data" to a vanilla object**: + - Runtime/campaign side data → `Extensions/ExtendedInfoSystem` (side dictionaries keyed + by string id, e.g. `hero.GetExtendedInfo()`). + - Extra bindable UI properties on a vanilla `ViewModel` → `Extensions/UI`'s + `BaseViewModelExtension`/`ViewModelExtensionManager` (reflection-based property/command + forwarding, registered via `[ViewModelExtension]`). +- **Save compatibility**: every persisted custom type must be registered in a + `SaveableTypeDefiner` (mainly `SaveGameSystem/TORSaveableTypeDefiner`, but a few + behaviors define their own inline) with a **stable, never-reused** numeric id — see that + file's own warning before adding or renumbering one. + +## The Warhammer domain model (so the folder docs make sense) + +- **Cultures** (`Utilities/TORConstants.Cultures`) map onto (and often reuse the game-object + slot of) vanilla Bannerlord cultures: Empire (`empire`), Bretonnia (`vlandia`), Sylvania + (`khuzait`), Mousillon (`mousillon`), Asrai/Wood Elves (`battania`), Eonir/Wood Elves + (`eonir`), Dawi/Dwarfs (`sturgia`), Greenskin (`aserai`) — plus Druchii, Beastmen, Chaos, + and several bandit-culture reskins. `Cultures.All` lists the 8 main playable ones. +- **Magic**: Winds-of-Magic **Spells** (Lores: Fire/Light/Heavens/Life/Metal/Beasts/Death, + High Magic, Dark Magic, Necromancy, Big Waaagh) and Dwarf **Rune Magic** are one system + (`AbilitySystem`); priestly **Prayers** are a parallel system tied to + `CampaignMechanics/Religion`; each **Career** has its own unique signature + `CareerAbility`. All three share the same `Ability`/`AbilityTemplate` runtime. +- **Careers** (`CharacterDevelopment/CareerSystem`) are Warhammer-flavored "prestige + classes" — Grail Knight, Black Grail Knight, Grail Damsel, Knight of the Old World, + Witch Hunter, Warrior Priest (+ of Ulric), Blood Knight, Vampire Count, Necromancer, + Necrarch, Imperial Magister, Waywatcher, Spellsinger, Warden, Grey Lord, Mercenary, + Ironbreaker, Runelord, Slayer, Orc Boss, Orc Shaman — each with a perk tree + (`CareerSystem/Choices`), a signature ability, and sometimes a special roster-screen + button (`CareerSystem/CareerButton`). +- **Per-culture "second currency"** (`CampaignMechanics/CustomResources`): Prestige + (Empire), Chivalry (Bretonnia), DarkEnergy (Sylvania/Mousillon), ForestHarmony (Asrai), + CouncilFavor (Eonir), OathGold (Dawi), Teef/Waaagh (Greenskin). +- **Bespoke settlement types** (`CampaignMechanics/TORCustomSettlement`): Chaos Portal, + Herdstone, Slaver Camp, Troll Cave (all raider-spawning lairs), Cursed Site, Oak of Ages, + Shrine, World Roots. +- **Damage/effects pipeline**: an `Ability`/weapon-hit fires a + `BattleMechanics/TriggeredEffect` → resolves target set → applies damage via + `BattleMechanics/DamageSystem/TORDamageHelper` and/or a + `BattleMechanics/StatusEffect` → both get scaled by `Models/TORAbilityModel` + (skill/perk effectiveness) and `CharacterDevelopment/CareerSystem/CareerHelper` + (career passives) along the way. + +## Top-level folder map + +| Folder | What it owns | +|---|---| +| `AbilitySystem/` | Spells, prayers, career abilities: the shared casting/effect runtime. | +| `Audio/` | Standalone file-based sound playback (ambient sounds). | +| `BattleMechanics/` | In-mission mechanics: AI, status effects, triggered effects, artillery, banners, firearms, dismemberment, voice, arena modes. | +| `CampaignMechanics/` | Every campaign-map mechanic (largest folder): factions, careers, religion, custom resources, diplomacy, crafting, custom settlements, quests-adjacent behaviors. | +| `CharacterDevelopment/` | Skills, perks, traits, attributes, and the Career data model. | +| `Extensions/` | Extension methods on vanilla types; the ExtendedInfo side-data system; the ViewModel-extension UI injection system. | +| `GameManagers/` | Early campaign bootstrapping, hotkeys, shader-related game managers. | +| `HarmonyPatches/` | Every Harmony patch, grouped by what vanilla system they touch. | +| `Ink/` | Branching-narrative integration (Inkle's Ink language). | +| `Items/` | Item traits/enchantments, weapon on-hit scripts, inventory-use scripts. | +| `Missions/` | Mission-open factory methods + scripted one-off fight controllers. | +| `Models/` | `GameModel` overrides (vanilla formula replacements) + Custom Battle variants. | +| `Quests/` | `QuestBase` quest classes, including Career storyline quests. | +| `SaveGameSystem/` | Save-type registration (`SaveableTypeDefiner`). | +| `Utilities/` | Cross-cutting static helpers (config, paths, constants, math, logging). | + +## Where to look for a given task + +- **"Add/tune a spell or prayer"** → `AbilitySystem/` (template + maybe a new + `AbilityScript`), `AbilitySystem/Spells/LoreObject` if it's a new Lore. +- **"Add a new status effect / DOT / buff"** → `BattleMechanics/StatusEffect`. +- **"A weapon should do something special on hit"** → `Items/WeaponHitScripts` + + `Items/ItemTrait`. +- **"Add/tune a Career perk or its passive"** → `CharacterDevelopment/CareerSystem/Choices`. +- **"Change how damage/resistance math works"** → `BattleMechanics/DamageSystem` + + `Models/TORAgentApplyDamageModel`/`TORAbilityModel`. +- **"Add a new campaign mechanic/town service"** → a new `CampaignBehaviorBase` under + `CampaignMechanics/`, registered in `SubModule.InitializeGameStarter`. +- **"Vanilla formula needs to behave differently"** → check `Models/` first; only reach for + `HarmonyPatches/` if there's no model hook for it. +- **"AI isn't casting/behaving right"** → `BattleMechanics/AI/CastingAI` (spellcasters) or + `BattleMechanics/AI/TeamAI` (formation/team AI). +- **"Add UI to an existing vanilla screen"** → `Extensions/UI` (`BaseViewModelExtension`) + rather than a Harmony patch on the screen class, if at all possible. diff --git a/CSharpSourceCode/Items/ItemTrait.cs b/CSharpSourceCode/Items/ItemTrait.cs index 02e5d40f..3ed00346 100644 --- a/CSharpSourceCode/Items/ItemTrait.cs +++ b/CSharpSourceCode/Items/ItemTrait.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Xml.Serialization; +using TOR_Core.CampaignMechanics.Crafting; using TOR_Core.Extensions.ExtendedInfoSystem; using static TaleWorlds.Core.ItemObject; diff --git a/CSharpSourceCode/Models/TORFaithModel.cs b/CSharpSourceCode/Models/TORFaithModel.cs index b72227c8..43f90747 100644 --- a/CSharpSourceCode/Models/TORFaithModel.cs +++ b/CSharpSourceCode/Models/TORFaithModel.cs @@ -4,11 +4,11 @@ using TaleWorlds.CampaignSystem; using TaleWorlds.CampaignSystem.Party; using TaleWorlds.Core; +using TOR_Core.CampaignMechanics.Crafting; using TOR_Core.CampaignMechanics.Religion; using TOR_Core.CharacterDevelopment; using TOR_Core.Extensions; using TOR_Core.Extensions.ExtendedInfoSystem; -using TOR_Core.Items; using TOR_Core.Utilities; namespace TOR_Core.Models diff --git a/CSharpSourceCode/SubModule.cs b/CSharpSourceCode/SubModule.cs index 4063acfb..bf03ad3c 100644 --- a/CSharpSourceCode/SubModule.cs +++ b/CSharpSourceCode/SubModule.cs @@ -167,7 +167,6 @@ protected override void InitializeGameStarter(Game game, IGameStarter starterObj starter.AddBehavior(new TORCaptivityCampaignBehavior()); starter.AddBehavior(new AssimilationCampaignBehavior()); starter.AddBehavior(new SpellTrainerInTownBehavior()); - starter.AddBehavior(new EnchanterTownBehavior()); starter.AddBehavior(new MasterEngineerTownBehaviour()); starter.AddBehavior(new PrestigeNobleTownBehavior()); starter.AddBehavior(new EonirFavorEnvoyTownBehavior()); @@ -194,12 +193,9 @@ protected override void InitializeGameStarter(Game game, IGameStarter starterObj starter.AddBehavior(new TORStartupBehavior()); starter.AddBehavior(new TORKingdomDecisionsCampaignBehavior()); starter.AddBehavior(new TORAllianceWarBehavior()); - starter.AddBehavior(new TORArtisanDistrictCampaignBehavior()); - starter.AddBehavior(new PriestBehavior()); starter.AddBehavior(new SkillTrainerBehavior()); - starter.AddBehavior(new EnchantmentIngredientLootCampaignBehavior()); - starter.AddBehavior(new LootCampaignBehavior()); starter.AddBehavior(new OathGoldBehavior()); + new CraftingModule().RegisterCampaignBehaviors(starter); starter.AddBehavior(new TeefBehavior()); starter.AddBehavior(new WaaaghBehavior()); starter.AddBehavior(new GreenskinBrawlBehavior()); @@ -277,11 +273,9 @@ protected override void OnGameStart(Game game, IGameStarter gameStarterObject) gameStarterObject.AddModel(new TOREquipmentSelectionModel()); gameStarterObject.AddModel(new TOREncounterModel()); gameStarterObject.AddModel(new TORVolunteerModel()); - gameStarterObject.AddModel(new TORSmithingModel()); - gameStarterObject.AddModel(new TOREnchantmentIngredientsModel()); + new CraftingModule().RegisterModels(gameStarterObject); gameStarterObject.AddModel(new TORCompanionTrainingModel()); gameStarterObject.AddModel(new TORVillageProductionCalculatorModel()); - gameStarterObject.AddModel(new TOREnchantmentCraftingModel()); gameStarterObject.AddModel(new TORCampaignTimeModel()); gameStarterObject.AddModel(new TORSiegeEngineCalculationModel()); gameStarterObject.AddModel(new TORHiringCompatibilityModel()); @@ -367,6 +361,7 @@ public override void BeginGameStart(Game game) _ = new TORCareers(); _ = new TORCareerChoiceGroups(); _ = new TORCareerChoices(); + CraftingCareerHookRegistrations.RegisterAll(); _ = new TORCampaignEvents(); MBObjectManager.Instance.LoadXML("Religions", false); diff --git a/CSharpSourceCode/TOR_Core.csproj b/CSharpSourceCode/TOR_Core.csproj index 950f8581..ef8a38c9 100644 --- a/CSharpSourceCode/TOR_Core.csproj +++ b/CSharpSourceCode/TOR_Core.csproj @@ -506,6 +506,10 @@ + + + + @@ -515,12 +519,18 @@ + + + + + + @@ -653,6 +663,7 @@ + @@ -716,16 +727,17 @@ - - + + + @@ -746,7 +758,6 @@ - @@ -791,14 +802,12 @@ - - @@ -850,8 +859,6 @@ - - @@ -877,7 +884,6 @@ - diff --git a/ModuleData/tor_custom_xmls/tor_itemtraits.xml b/ModuleData/tor_custom_xmls/tor_itemtraits.xml index b243c493..300368a7 100644 --- a/ModuleData/tor_custom_xmls/tor_itemtraits.xml +++ b/ModuleData/tor_custom_xmls/tor_itemtraits.xml @@ -2436,7 +2436,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_ghur_wildform}"3% physical resistance, 15% chance to receive a fleeting 3% physical resistance bonus for 10 seconds upon receiving damage. Stacks up to 3 times" - + emp_enchant_ghur_wildform Spellcraft @@ -2450,7 +2450,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_ghur_savagery}"10% extra damage done as physical, 10% chance to receive a fleeting 2% attack speed bonus for 20 seconds upon dealing damge. Stacks up to 5 times" - + emp_enchant_ghur_savagery Spellcraft @@ -2464,7 +2464,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_ghur_crows}"10% extra damage done as physical, 10% chance to summon vicious crows in a 3m radius, inflicting 2 frost damage per second for 20 seconds" - + emp_enchant_ghur_crows Spellcraft @@ -2478,7 +2478,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_ghur_echo}"15% extra damage done as physical, 15% extra missile speed, 10% to reduce movement speed of nearby enemies by 20% for 20 seconds upon dealing damage" - + emp_enchant_ghur_echo Spellcraft @@ -2492,7 +2492,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_ghur_whisper}"10% extra damage done as physical" - + emp_enchant_ghur_whisper Spellcraft @@ -2506,7 +2506,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_hysh_sanctuary}"3% physical resistance, 5% chance to snare nearby enemies with a net of Hysh for 20 seconds upon receiving damage" - + emp_enchant_hysh_sanctuary Spellcraft @@ -2520,7 +2520,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_hysh_brilliance}"15% chance to trigger a burst of light upon blocking damage. Reduces attack speed of nearby enemies within 3m for 20 seconds" - + emp_enchant_hysh_brilliance Spellcraft @@ -2534,7 +2534,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_hysh_timewarp}"20% extra damage done as magical, 10% chance to receive a 40% movement speed buff for 3 seconds upon dealing damage" - + emp_enchant_hysh_timewarp Spellcraft @@ -2548,7 +2548,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_hysh_clarity}"3 max Winds of Magic" - + emp_enchant_hysh_clarity Spellcraft @@ -2562,7 +2562,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_hysh_whisper}"10% extra damage done as magical" - + emp_enchant_hysh_whisper Spellcraft @@ -2576,7 +2576,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_hysh_providence}"5% magic resistance, 10 bonus skillpoints to Spellcraft" - + emp_enchant_hysh_providence Spellcraft @@ -2590,7 +2590,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_ghyran_brambles}"Deal 25 physical damage to enemies blocked in melee -10% fire resistance" - + emp_enchant_ghyran_brambles Spellcraft @@ -2604,7 +2604,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_ghyran_bloom}"6 extra HP, -10% fire resistance, 15% chance to regen 2HP per second for 5 seconds upon receiving damage" - + emp_enchant_ghyran_bloom Spellcraft @@ -2618,7 +2618,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_ghyran_cloak}"25% extra healing rate when travelling" - + emp_enchant_ghyran_cloak Spellcraft @@ -2632,7 +2632,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_ghyran_tendrils}"15% physical resistance, 25% to snare enemies within 3m with powerful roots for 20 seconds upon taking damage" - + emp_enchant_ghyran_tendrils Spellcraft @@ -2646,7 +2646,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_ghyran_whisper}"3 extra HP, -15% fire resistance" - + emp_enchant_ghyran_whisper Spellcraft @@ -2660,7 +2660,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_ghyran_intuition}"3 max Winds of Magic, -25% fire resistance" - + emp_enchant_ghyran_intuition Spellcraft @@ -2674,7 +2674,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_aqshy_hearth}"7% bonus to fire damage, -25% fire resistance" - + emp_enchant_aqshy_hearth Spellcraft @@ -2688,7 +2688,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_aqshy_firebreath}"25% extra damage done as fire, 20% to summon a fiery explosion dealing 60 fire damage in a 3m radius and inflicting burning (4 fire damage per second for 5 seconds)" - + emp_enchant_aqshy_firebreath Spellcraft @@ -2702,7 +2702,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_aqshy_cinders}"15% extra damage done as fire, 15% chance to inflict burning on nearby enemies in a 3m radius (4 fire damage per second for 5 seconds) upon dealing damage" - + emp_enchant_aqshy_cinders Spellcraft @@ -2716,7 +2716,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_aqshy_rage}"40% extra damage done as fire, -15% fire resistance" - + emp_enchant_aqshy_rage Spellcraft @@ -2730,7 +2730,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_aqshy_whisper}"12% extra damage done as fire" - + emp_enchant_aqshy_whisper Spellcraft @@ -2744,7 +2744,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_aqshy_fury}"Damaging burning enemies inflicts an extra 50 fire damage" - + emp_enchant_aqshy_fury Spellcraft @@ -2758,7 +2758,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_aqshy_curtain}"20% fire resistance, 15% chance to inflict fire DoT on blocked enemies (4 fire damage per second for 5 seconds). This DoT effect can spread onto enemies and allies alike" - + emp_enchant_aqshy_curtain Spellcraft @@ -2772,7 +2772,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_chamon_transmutation}"20% extra damage done as fire, projectiles penetrate targets" - + emp_enchant_chamon_transmutation Spellcraft @@ -2786,7 +2786,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_chamon_aegis}"20% extra shield HP, 5% physical resistance, 50% chance to decrease physical resistance of nearby enemies by 40% for 5 sec upon blocking damage" - + emp_enchant_chamon_aegis Spellcraft @@ -2800,7 +2800,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_chamon_laws}"50% extra armour penetration" - + emp_enchant_chamon_laws Spellcraft @@ -2814,7 +2814,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_chamon_feathers_lead}"8% physical resistance, 5% penalty to movement speed" - + emp_enchant_chamon_feathers_lead Spellcraft @@ -2828,7 +2828,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_chamon_lead_feathers}"3% penalty to physical resistance, 5% bonus to movement speed" - + emp_enchant_chamon_lead_feathers Spellcraft @@ -2842,7 +2842,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_chamon_forge}"30% extra reload speed, 10% extra damage done as magical" - + emp_enchant_chamon_forge Spellcraft @@ -2856,7 +2856,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_chamon_whisper}"3% physical resistance" - + emp_enchant_chamon_whisper Spellcraft @@ -2870,7 +2870,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_chamon_crucible}"20% extra damage done as magical, weapon gains the Cleave trait" - + emp_enchant_chamon_crucible Spellcraft @@ -2884,7 +2884,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_azyr_wind}"10% extra projectile speed" - + emp_enchant_azyr_wind Spellcraft @@ -2898,7 +2898,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_azyr_harmony}"4% bonus to lightning damage" - + emp_enchant_azyr_harmony Spellcraft @@ -2912,7 +2912,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_azyr_azure_mirror}"2% physical resistance, deal 15 lightning damage and knockdown to enemies attacking you in melee within 2m" - + emp_enchant_azyr_azure_mirror Spellcraft @@ -2926,7 +2926,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_azyr_gale}"15% extra armour penetration, 15% chance to summon a gust of wind knocking down enemies in a 3m radius dealing an immediate 10 lightning damage" - + emp_enchant_azyr_gale Spellcraft @@ -2940,7 +2940,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_azyr_foresight}"Gain 15% Physical Resistance and 15% bonus Lightning damage. Your attacks have a 15% chance to inspire nearby allies, granting them the same Lightning bonus for 20 seconds" - + emp_enchant_azyr_foresight Spellcraft @@ -2954,7 +2954,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_azyr_divination}"5% magic resistance, 7% extra party travel speed" - + emp_enchant_azyr_divination Spellcraft @@ -2968,7 +2968,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_azyr_whisper}"3% magic resistance, 3% extra party travel speed" - + emp_enchant_azyr_whisper Spellcraft @@ -2982,7 +2982,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_shyish_pale_grip}"7% bonus to magic damage, -3 HP" - + emp_enchant_shyish_pale_grip Spellcraft @@ -2996,7 +2996,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_shyish_messengers}"5% magic resistance, 10% chance to inflict horrific visions in a 7m radius upon taking damage. Nearby enemies suffer 4 magical damage per second for 20 seconds" - + emp_enchant_shyish_messengers Spellcraft @@ -3010,7 +3010,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_shyish_blight}"25% extra damage done as magical, 10% chance to reduce physical resistance of struck enemies by 25% for 5 seconds" - + emp_enchant_shyish_blight Spellcraft @@ -3024,7 +3024,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_shyish_pall}"15% extra damage done as magical, gain a stacking 10% magic resistance bonus upon killing enemies. Stacks up to 5 times and fades after a short while" - + emp_enchant_shyish_pall Spellcraft @@ -3038,7 +3038,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_shyish_withering}"10% extra damage done as magical, 10% chance to reduce movement speed of the struck enemy by 25% for 5 seconds" - + emp_enchant_shyish_withering Spellcraft @@ -3052,7 +3052,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_shyish_whisper}"12% extra damage done as magical" - + emp_enchant_shyish_whisper Spellcraft @@ -3066,7 +3066,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_shyish_deathsight}"Blocking hits of Undead units carries a 50% chance to apply an 'Exorcism', debuffing enemies making them 33% more vulnerable to Magical damage" - + emp_enchant_shyish_deathsight Spellcraft @@ -3080,7 +3080,7 @@ Invalid -1 {=str_tor_itemtraits_emp_blessing_sigmar_beacon}"10% magic resistance, 15% extra prayer radius" - + emp_blessing_sigmar_beacon Faith @@ -3094,7 +3094,7 @@ Invalid -1 {=str_tor_itemtraits_emp_blessing_sigmar_retribution}"Gain a fleeting 10% bonus to holy damage when blocking blows of Undead and Daemons. Stacks up to 5 times, each lasting 15 seconds" - + emp_blessing_sigmar_retribution Faith @@ -3108,7 +3108,7 @@ Invalid -1 {=str_tor_itemtraits_emp_blessing_sigmar_soulfire}"6% magic resistance, 3 extra HP, 10% chance to emit a damaging wave of holy energy dealing 35 damage in a 7m radius whenever attacked by Undead and Daemons in melee" - + emp_blessing_sigmar_soulfire Faith @@ -3122,7 +3122,7 @@ Invalid -1 {=str_tor_itemtraits_emp_blessing_sigmar_light}"6% magic resistance, 3 extra HP, 10% chance to gain 2HP per second for 20 seconds whenever attacked by Undead and Daemons in melee" - + emp_blessing_sigmar_light Faith @@ -3136,7 +3136,7 @@ Invalid -1 {=str_tor_itemtraits_emp_blessing_sigmar_exorcism}"20% extra damage done as holy, 10% chance to deal 100% bonus damage against Undead and Daemons" - + emp_blessing_sigmar_exorcism Faith @@ -3150,7 +3150,7 @@ Invalid -1 {=str_tor_itemtraits_emp_blessing_sigmar_justice}"12% extra damage done as holy" - + emp_blessing_sigmar_justice Faith @@ -3164,7 +3164,7 @@ Invalid -1 {=str_tor_itemtraits_emp_blessing_ulric_wrath}"350% extra shield damage, 15% to receive a fleeting 3% attack speed bonus upon dealing damage. Stacks up to 5 times, each lasting 15 seconds" - + emp_blessing_ulric_wrath Faith @@ -3178,7 +3178,7 @@ Invalid -1 {=str_tor_itemtraits_emp_blessing_ulric_wolf_god_bite}"10% extra swing speed, 15% to receive a fleeting 6% bonus to frost damage upon dealing damage. Stacks up to 5 times each lasting 15 seconds" - + emp_blessing_ulric_wolf_god_bite Faith @@ -3192,7 +3192,7 @@ Invalid -1 {=str_tor_itemtraits_emp_blessing_ulric_chill}"15% extra damage done as frost" - + emp_blessing_ulric_chill Faith @@ -3206,7 +3206,7 @@ Invalid -1 {=str_tor_itemtraits_emp_blessing_ulric_frenzy}"5% physical resistance, receiving melee damage carries a 15% chance in a 3m radius to give allies a 15% attack speed increase for 20 seconds" - + emp_blessing_ulric_frenzy Faith @@ -3220,7 +3220,7 @@ Invalid -1 {=str_tor_itemtraits_emp_blessing_ulric_winterfather_gift}"5% magic resistance, 20 bonus skillpoints to Two Handed" - + emp_blessing_ulric_winterfather_gift Faith @@ -3234,7 +3234,7 @@ Invalid -1 {=str_tor_itemtraits_emp_blessing_shallya_mercy}"6 extra HP" - + emp_blessing_shallya_mercy Faith @@ -3248,7 +3248,7 @@ Invalid -1 {=str_tor_itemtraits_emp_blessing_shallya_pacifism}"Mercy is piety. Chance to heal 2 HP for 5 seconds when knocking out enemies" - + emp_blessing_shallya_pacifism Faith @@ -3262,7 +3262,7 @@ Invalid -1 {=str_tor_itemtraits_emp_blessing_shallya_purity}"50% extra healing rate when travelling" - + emp_blessing_shallya_purity Faith @@ -3276,7 +3276,7 @@ Invalid -1 {=str_tor_itemtraits_emp_blessing_shallya_compassion}"-50% shield HP, completely heals the wearer and nearby allies upon shield destruction" - + emp_blessing_shallya_compassion Faith @@ -3290,7 +3290,7 @@ Invalid -1 {=str_tor_itemtraits_emp_blessing_shallya_martyrdom}"3 extra HP, taking damage has a 33% chance to heal nearby friendly units for 2HP for 6 seconds in a 3m radius" - + emp_blessing_shallya_martyrdom Faith @@ -3304,7 +3304,7 @@ Invalid -1 {=str_tor_itemtraits_bret_blessing_eerie_touch}"2% physical resistance, 25 bonus skillpoints to Riding" - + bret_blessing_eerie_touch Faith @@ -3318,7 +3318,7 @@ Invalid -1 {=str_tor_itemtraits_bret_blessing_fey_guidance}"10% extra party travel speed, 5% magic resistance" - + bret_blessing_fey_guidance Faith @@ -3332,7 +3332,7 @@ Invalid -1 {=str_tor_itemtraits_bret_blessing_grail_legacy}"30% extra damage done as holy, weapon gains the Cleave trait" - + bret_blessing_grail_legacy Faith @@ -3346,7 +3346,7 @@ Invalid -1 {=str_tor_itemtraits_bret_blessing_lady_ward}"10% magic resistance, Once per battle fatal wounds will revive you with 50% HP" - + bret_blessing_lady_ward Faith @@ -3360,7 +3360,7 @@ Invalid -1 {=str_tor_itemtraits_bret_blessing_scourge_evil}"5% extra swing speed, 50% chance to deal 40 holy damage in a 3m radius against Undead and Daemons" - + bret_blessing_scourge_evil Faith @@ -3374,7 +3374,7 @@ Invalid -1 {=str_tor_itemtraits_bret_blessing_shield_faith}"10% physical resistance, blocked hits reduce the cooldown of your Career Ability by 1 second" - + bret_blessing_shield_faith Faith @@ -3388,7 +3388,7 @@ Invalid -1 {=str_tor_itemtraits_bret_blessing_pilgrim_tenacity}"2 extra HP" - + bret_blessing_pilgrim_tenacity Faith @@ -3402,7 +3402,7 @@ Invalid -1 {=str_tor_itemtraits_bret_blessing_mists_sacred_lake}"3% physical resistance, 15% extra prayer and spell radius" - + bret_blessing_mists_sacred_lake Faith @@ -3416,7 +3416,7 @@ Invalid -1 {=str_tor_itemtraits_bret_blessing_wisdom_virtue}"3 extra HP, 5% magic resistance" - + bret_blessing_wisdom_virtue Faith @@ -3430,7 +3430,7 @@ Invalid -1 {=str_tor_itemtraits_vc_enchant_bonds_darkness}"5% bonus to magic damage, summon 3 Skeleton Warriors for every enemy felled in melee" - + vc_enchant_bonds_darkness Spellcraft @@ -3444,7 +3444,7 @@ Invalid -1 {=str_tor_itemtraits_vc_enchant_call_beyond}"10% bonus to magic damage, summon 3 Grave Guard for every enemy felled in melee" - + vc_enchant_call_beyond Spellcraft @@ -3458,7 +3458,7 @@ Invalid -1 {=str_tor_itemtraits_vc_enchant_unhallowed_pact}"-5% physical resistance, 33% extra spell radius" - + vc_enchant_unhallowed_pact Spellcraft @@ -3472,7 +3472,7 @@ Invalid -1 {=str_tor_itemtraits_vc_enchant_drinker_blood}"15% extra damage done as magical. Recover 1 HP for every instance of damage dealt in melee, triggers below 50% HP" - + vc_enchant_drinker_blood Spellcraft @@ -3486,7 +3486,7 @@ Invalid -1 {=str_tor_itemtraits_vc_enchant_nightshroud}"5% magic resistance, 15% chance to reduce attack speed and damage of nearby enemies upon receiving damage" - + vc_enchant_nightshroud Spellcraft @@ -3500,7 +3500,7 @@ Invalid -1 {=str_tor_itemtraits_vc_enchant_ethereal_whispers}"5% physical resistance, upon receiving ranged damage gain 50% ranged damage immunity for 20 seconds" - + vc_enchant_ethereal_whispers Spellcraft @@ -3514,7 +3514,7 @@ Invalid -1 {=str_tor_itemtraits_vc_enchant_mockery_asps}"25% extra damage done as magical, ranged shots reduce physical resistance of the target by 50%" - + vc_enchant_mockery_asps Spellcraft @@ -3528,7 +3528,7 @@ Invalid -1 {=str_tor_itemtraits_vc_enchant_touch_jet}"5% extra swing speed, each blow deals an extra 20% damage done as magical" - + vc_enchant_touch_jet Spellcraft @@ -3542,7 +3542,7 @@ Invalid -1 {=str_tor_itemtraits_vc_enchant_caress_void}"-10% physical resistance, 10 max Winds of Magic" - + vc_enchant_caress_void Spellcraft @@ -3556,7 +3556,7 @@ Invalid -1 {=str_tor_itemtraits_vc_enchant_unholy_blessing}"15% extra damage done as magical" - + vc_enchant_unholy_blessing Spellcraft @@ -3570,7 +3570,7 @@ Invalid -1 {=str_tor_itemtraits_vc_enchant_chilling_hand}"15% extra damage done as frost, 10% chance to slow the movement, attack and reload speed of nearby enemies with each strike" - + vc_enchant_chilling_hand Spellcraft @@ -3584,7 +3584,7 @@ Invalid -1 {=str_tor_itemtraits_vc_enchant_bulwark_blood_keep}"20% extra shield HP, 8% physical resistance" - + vc_enchant_bulwark_blood_keep Spellcraft @@ -3598,7 +3598,7 @@ Invalid -1 {=str_tor_itemtraits_vc_enchant_crimson_flood}"25% extra damage done as magical, 10% to send out a damaging wave of magic with each strike. Each wave drains 2 HP of the wearer" - + vc_enchant_crimson_flood Spellcraft @@ -3612,7 +3612,7 @@ Invalid -1 {=str_tor_itemtraits_vc_enchant_legacy_arkhan}"12% bonus to magic damage, 15% extra spell radius" - + vc_enchant_legacy_arkhan Spellcraft @@ -3626,7 +3626,7 @@ Invalid -1 {=str_tor_itemtraits_vc_enchant_secrets_wsoran}"5% bonus to magic damage, 4 max Winds of Magic" - + vc_enchant_secrets_wsoran Spellcraft @@ -3640,7 +3640,7 @@ Invalid -1 {=str_tor_itemtraits_we_enchant_blackbriar_kiss}"10% extra damage done as physical, 10% extra armour penetration" - + we_enchant_blackbriar_kiss Spellcraft @@ -3653,7 +3653,7 @@ Invalid -1 {=str_tor_itemtraits_we_enchant_emissaries_mirai}"15% extra magical damage to the weapon, and on hit has a 15% chance to curse enemies around the target within 3 meters, dealing about 3 magical damage per second for 5 seconds." - + we_enchant_emissaries_mirai Spellcraft @@ -3667,7 +3667,7 @@ Invalid -1 {=str_tor_itemtraits_we_enchant_song_doom}"10% extra damage done as physical, 15% extra missile speed" - + we_enchant_song_doom Spellcraft @@ -3680,7 +3680,7 @@ Invalid -1 {=str_tor_itemtraits_asrai_enchant_dusk_wood}"-8% physical resistance, 8 max Winds of Magic" - + asrai_enchant_dusk_wood Spellcraft @@ -3694,7 +3694,7 @@ Invalid -1 {=str_tor_itemtraits_we_enchant_moon_stone}"10% bonus to magic damage, 12% extra travel speed" - + we_enchant_moon_stone Spellcraft @@ -3707,7 +3707,7 @@ Invalid -1 {=str_tor_itemtraits_we_enchant_trickery_loec}"50% extra damage when striking at the backs of enemies. Same bonus applies to unaware enemies hit from any angle" - + we_enchant_trickery_loec Spellcraft @@ -3720,7 +3720,7 @@ Invalid -1 {=str_tor_itemtraits_asrai_enchant_leylines_weave}"Gain 8 Forest Harmony daily, 3% physical resistance" - + asrai_enchant_leylines_weave Spellcraft @@ -3733,7 +3733,7 @@ Invalid -1 {=str_tor_itemtraits_we_enchant_thorns_nettles}"-15% shield HP, deal 40 physical damage to enemies blocked in melee" - + we_enchant_thorns_nettles Spellcraft @@ -3746,7 +3746,7 @@ Invalid -1 {=str_tor_itemtraits_we_enchant_tranquillity_cadai}"15% extra damage done as magical and 15% magic resistance, 30% chance to grant nearby troops 15% magic resistance for a short time upon dealing damage" - + we_enchant_tranquillity_cadai Spellcraft @@ -3760,7 +3760,7 @@ Invalid -1 {=str_tor_itemtraits_asrai_enchant_radiance_woods}"3% Ward Save, 4 max Winds of Magic" - + asrai_enchant_radiance_woods Spellcraft @@ -3774,7 +3774,7 @@ Invalid -1 {=str_tor_itemtraits_we_enchant_touch_lileath}"5% magic resistance, 3 max Winds of Magic" - + we_enchant_touch_lileath Spellcraft @@ -3787,7 +3787,7 @@ Invalid -1 {=str_tor_itemtraits_we_enchant_dusk_dawn}"40% extra damage done as magical" - + we_enchant_dusk_dawn Spellcraft @@ -3800,7 +3800,7 @@ Invalid -1 {=str_tor_itemtraits_we_enchant_arcane_bodkins}"10% extra armour penetration" - + we_enchant_arcane_bodkins Spellcraft @@ -3813,7 +3813,7 @@ Invalid -1 {=str_tor_itemtraits_we_enchant_hagbane_tips}"50% chance to decrease the movement speed of the target by 40%" - + we_enchant_hagbane_tips Spellcraft @@ -3826,7 +3826,7 @@ Invalid -1 {=str_tor_itemtraits_we_enchant_moon_fire}"20% extra damage done as fire" - + we_enchant_moon_fire Spellcraft @@ -3839,7 +3839,7 @@ Invalid -1 {=str_tor_itemtraits_we_enchant_star_fire}"25% extra armour penetration" - + we_enchant_star_fire Spellcraft @@ -3852,7 +3852,7 @@ Invalid -1 {=str_tor_itemtraits_we_enchant_swift_shiver}"10% extra damage done as magical and 10% extra missile speed" - + we_enchant_swift_shiver Spellcraft @@ -3865,7 +3865,7 @@ Invalid -1 {=str_tor_itemtraits_we_enchant_trueflight}"40% extra armour penetration" - + we_enchant_trueflight Spellcraft @@ -3878,7 +3878,7 @@ Invalid -1 {=str_tor_itemtraits_asrai_enchant_tree_lord}"5 extra HP, 15% chance to summon a Dryad upon taking damage" - + asrai_enchant_tree_lord Spellcraft @@ -3891,7 +3891,7 @@ Invalid -1 {=str_tor_itemtraits_asrai_enchant_quake_hukon}"25% extra shield HP, 25% chance to knock down foes when blocking melee hits" - + asrai_enchant_quake_hukon Spellcraft @@ -3904,7 +3904,7 @@ Invalid -1 {=str_tor_itemtraits_asrai_enchant_morai_heg}"10% extra swing speed, 500% damage when striking at the backs of enemies. Same bonus applies to unaware enemies hit from any angle" - + asrai_enchant_morai_heg Spellcraft @@ -3918,7 +3918,7 @@ Invalid -1 {=str_tor_itemtraits_asrai_enchant_oakhart_blessing}"4% physical resistance, 4% bonus to magic damage" - + asrai_enchant_oakhart_blessing Spellcraft @@ -3932,7 +3932,7 @@ Invalid -1 {=str_tor_itemtraits_asrai_enchant_vengeance_khaine}"8% extra swing speed, 15% extra damage done as physical, 100% damage when striking at the heads of enemies" - + asrai_enchant_vengeance_khaine Spellcraft @@ -3946,7 +3946,7 @@ Invalid -1 {=str_tor_itemtraits_asrai_enchant_anath_raema}"15% extra damage done as magical and 15% extra missile speed, dismount damaged cavalry units" - + asrai_enchant_anath_raema Spellcraft @@ -3960,7 +3960,7 @@ Invalid -1 {=str_tor_itemtraits_asrai_enchant_trance_loec}"10% extra swing speed, 10% bonus to physical damage and 10% physical resistance" - + asrai_enchant_trance_loec Spellcraft @@ -3973,7 +3973,7 @@ Invalid -1 {=str_tor_itemtraits_asrai_enchant_embrace_isha}"4% Ward Save, 10% chance to recover 10 HP upon receiving damage" - + asrai_enchant_embrace_isha Spellcraft @@ -3987,7 +3987,7 @@ Invalid -1 {=str_tor_itemtraits_asrai_enchant_ferocity_kurnous}"30% extra damage done as magical, 50% extra missile speed" - + asrai_enchant_ferocity_kurnous Spellcraft @@ -4000,7 +4000,7 @@ Invalid -1 {=str_tor_itemtraits_eo_enchant_wisdom_hoeth}"7 max Winds of Magic, 5% bonus to magic damage" - + eo_enchant_wisdom_hoeth Spellcraft @@ -4014,7 +4014,7 @@ Invalid -1 {=str_tor_itemtraits_eo_enchant_storms_mathlann}"50% lightning resistance, 15% extra shield HP, 25% chance to summon a bolt of lightning upon blocking damage" - + eo_enchant_storms_mathlann Spellcraft @@ -4027,7 +4027,7 @@ Invalid -1 {=str_tor_itemtraits_eo_enchant_flames_asuryan}"60% extra damage done as fire, 15% fire resistance" - + eo_enchant_flames_asuryan Spellcraft @@ -4040,7 +4040,7 @@ Invalid -1 {=str_tor_itemtraits_eo_enchant_legacy_caledor}"50% fire resistance, 25% extra shield HP, 25% chance to conjure a fire explosion upon blocking damage" - + eo_enchant_legacy_caledor Spellcraft @@ -4053,7 +4053,7 @@ Invalid -1 {=str_tor_itemtraits_eo_enchant_sanctuary_saphery}"Upon receiving ranged damage gain complete physical ranged damage immunity for 30 seconds" - + eo_enchant_sanctuary_saphery Spellcraft @@ -4067,7 +4067,7 @@ Invalid -1 {=str_tor_itemtraits_eo_enchant_sarriel_whisper}"15% extra damage done as magical, 20% chance to reduce movement speed of the struck enemy by 25% for 5 seconds" - + eo_enchant_sarriel_whisper Spellcraft @@ -4081,7 +4081,7 @@ Invalid -1 {=str_tor_itemtraits_eo_enchant_anvil_vaul}"25% extra damage done as magical, ignores 75% of the target's armour" - + eo_enchant_anvil_vaul Spellcraft @@ -4095,7 +4095,7 @@ Invalid -1 {=str_tor_itemtraits_eo_enchant_veil_ladrielle}"5% physical resistance, 15% extra party travel speed" - + eo_enchant_veil_ladrielle Spellcraft @@ -4109,7 +4109,7 @@ Invalid -1 {=str_tor_itemtraits_eo_enchant_daroir_soulgem}"4% Ward Save, 5 extra HP" - + eo_enchant_daroir_soulgem Spellcraft @@ -4123,7 +4123,7 @@ Invalid -1 {=str_tor_itemtraits_eo_enchant_silver_tower_eyes}"15% extra missile speed, 200% extra headshot damage" - + eo_enchant_silver_tower_eyes Spellcraft @@ -4137,7 +4137,7 @@ Invalid -1 {=str_tor_itemtraits_eo_enchant_soulbound_shafts}"25% extra damage done as magical, projectiles penetrate targets" - + eo_enchant_soulbound_shafts Spellcraft @@ -4151,7 +4151,7 @@ Invalid -1 {=str_tor_itemtraits_eo_enchant_blood_aenarion}"5% extra movement speed, 5% physical resistance, 25% chance upon taking damage to receive a fleeting 4% attack speed bonus for 20 seconds. Stacks up to 5 times" - + eo_enchant_blood_aenarion Spellcraft @@ -4165,7 +4165,7 @@ Invalid -1 {=str_tor_itemtraits_eo_enchant_blood_aenarion_melee}"40% extra swing speed, 15% extra damage done as physical" - + eo_enchant_blood_aenarion_melee Spellcraft @@ -4179,7 +4179,7 @@ Invalid -1 {=str_tor_itemtraits_eo_enchant_grace_toriour}"5% extra movement speed, 5% physical resistance" - + eo_enchant_grace_toriour Spellcraft @@ -4193,7 +4193,7 @@ Invalid -1 {=str_tor_itemtraits_gs_enchant_brutality_gork}"15% extra damage done as physical, 5% penalty to swing speed" - + gs_enchant_brutality_gork Spellcraft @@ -4207,7 +4207,7 @@ Invalid -1 {=str_tor_itemtraits_gs_enchant_tuffness_gork}"4% physical resistance, 5% penalty to movement speed" - + gs_enchant_tuffness_gork Spellcraft @@ -4221,7 +4221,7 @@ Invalid -1 {=str_tor_itemtraits_gs_enchant_clobberin_shield}"15% extra shield HP, 30% chance to knock down foes when blocking melee hits" - + gs_enchant_clobberin_shield Spellcraft @@ -4235,7 +4235,7 @@ Invalid -1 {=str_tor_itemtraits_gs_enchant_stunties_bane}"25% bonus to physical damage, 200% extra shield damage, 50 extra physical damage against Dwarfs" - + gs_enchant_stunties_bane Spellcraft @@ -4249,7 +4249,7 @@ Invalid -1 {=str_tor_itemtraits_gs_enchant_wallopin_krunch}"40% extra damage done as physical, weapon gains the Cleave trait" - + gs_enchant_wallopin_krunch Spellcraft @@ -4263,7 +4263,7 @@ Invalid -1 {=str_tor_itemtraits_gs_enchant_cunning_mork}"8% extra swing speed, 10% penalty to physical damage" - + gs_enchant_cunning_mork Spellcraft @@ -4277,7 +4277,7 @@ Invalid -1 {=str_tor_itemtraits_gs_enchant_call_great_green}"3 max Winds of Magic, 4% magic resistance" - + gs_enchant_call_great_green Spellcraft @@ -4291,7 +4291,7 @@ Invalid -1 {=str_tor_itemtraits_gs_enchant_backstabba_frenzy}"10% extra swing speed, 10% extra damage done as physical, 400% extra damage when striking at the backs of enemies. Same bonus applies to unaware enemies hit from any angle" - + gs_enchant_backstabba_frenzy Spellcraft @@ -4305,7 +4305,7 @@ Invalid -1 {=str_tor_itemtraits_gs_enchant_gaze_spider_god}"10% extra missile speed, ranged shots reduce physical resistance of the target by 35%" - + gs_enchant_gaze_spider_god Spellcraft @@ -4319,7 +4319,7 @@ Invalid -1 {=str_tor_itemtraits_gs_enchant_shadow_bad_moon}"15 extra magic damage, enemy wizards damaging you with spells lose 30 Winds of Magic" - + gs_enchant_shadow_bad_moon Spellcraft @@ -4333,7 +4333,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_shielding}"5% physical resistance, 33% extra shield HP" - + dw_rune_shielding Crafting @@ -4347,7 +4347,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_stone}"4% physical resistance" - + dw_rune_stone Crafting @@ -4361,7 +4361,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_vigour}"5 extra HP" - + dw_rune_vigour Crafting @@ -4375,7 +4375,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_parrying}"25% chance to knock down foes when blocking melee hits" - + dw_rune_parrying Crafting @@ -4389,7 +4389,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_protection}"5% magic resistance, 2 extra HP" - + dw_rune_protection Crafting @@ -4403,7 +4403,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_force}"33% extra shield HP, 70% chance to knock down foes when blocking melee hits" - + dw_rune_force Crafting @@ -4417,7 +4417,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_iron}"7% physical resistance" - + dw_rune_iron Crafting @@ -4431,7 +4431,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_impact}"10% physical resistance, 30% chance to knock down foes when blocking melee hits" - + dw_rune_impact Crafting @@ -4445,7 +4445,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_fortitude}"3% Ward Save, 4 extra HP" - + dw_rune_fortitude Crafting @@ -4459,7 +4459,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_spell_eating}"10% magic resistance, 50% chance to gain 100% magic resistance for a short time upon taking damage" - + dw_rune_spell_eating Crafting @@ -4473,7 +4473,7 @@ Invalid -1 {=str_tor_itemtraits_dw_master_rune_preservation}"6 extra HP, 10% chance to recover 2 HP upon taking damage" - + dw_master_rune_preservation Crafting @@ -4487,7 +4487,7 @@ Invalid -1 {=str_tor_itemtraits_dw_master_rune_steel}"10% physical resistance" - + dw_master_rune_steel Crafting @@ -4501,7 +4501,7 @@ Invalid -1 {=str_tor_itemtraits_dw_master_rune_skaldour}"100% fire resistance" - + dw_master_rune_skaldour Crafting @@ -4515,7 +4515,7 @@ Invalid -1 {=str_tor_itemtraits_dw_master_rune_gromril}"5% Ward Save" - + dw_master_rune_gromril Crafting @@ -4529,7 +4529,7 @@ Invalid -1 {=str_tor_itemtraits_dw_master_rune_adamant}"10% Ward Save, 50% extra shield HP" - + dw_master_rune_adamant Crafting @@ -4543,7 +4543,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_striking}"5% extra swing speed, 10% extra damage done as physical" - + dw_rune_striking Crafting @@ -4557,7 +4557,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_fire}"20% extra damage done as fire" - + dw_rune_fire Crafting @@ -4571,7 +4571,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_speed}"15% extra swing speed" - + dw_rune_speed Crafting @@ -4585,7 +4585,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_might}"20% extra damage done as physical, 15% extra armour penetration" - + dw_rune_might Crafting @@ -4599,7 +4599,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_clear_sights}"10% extra damage done as physical, 15% faster reload speed" - + dw_rune_clear_sights Crafting @@ -4613,7 +4613,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_head_wrecking}"15% extra missile speed, 200% extra headshot damage" - + dw_rune_head_wrecking Crafting @@ -4627,7 +4627,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_unbinding}"25% extra damage done as magical, 15% magic resistance" - + dw_rune_unbinding Crafting @@ -4641,7 +4641,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_beastslaying}"500% damage against mounts and large enemies" - + dw_rune_beastslaying Crafting @@ -4655,7 +4655,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_reloading}"50% faster reload speed" - + dw_rune_reloading Crafting @@ -4669,7 +4669,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_cleaving}"25% extra damage done as physical, weapon gains the Cleave trait" - + dw_rune_cleaving Crafting @@ -4683,7 +4683,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_fury}"25% extra swing speed, 15% extra damage done as physical" - + dw_rune_fury Crafting @@ -4697,7 +4697,7 @@ Invalid -1 {=str_tor_itemtraits_dw_master_rune_breaking}"500% extra shield damage, 50% extra damage done as physical" - + dw_master_rune_breaking Crafting @@ -4711,7 +4711,7 @@ Invalid -1 {=str_tor_itemtraits_dw_master_rune_swiftness}"50% extra swing speed, 15% extra damage done as physical" - + dw_master_rune_swiftness Crafting @@ -4725,7 +4725,7 @@ Invalid -1 {=str_tor_itemtraits_dw_master_rune_flight}"33% extra missile speed, 25% chance to regain throwing ammunition upon landing hits" - + dw_master_rune_flight Crafting @@ -4739,7 +4739,7 @@ Invalid -1 {=str_tor_itemtraits_dw_master_rune_alaric}"Ignore 95% of the target's armour" - + dw_master_rune_alaric Crafting @@ -4753,7 +4753,7 @@ Invalid -1 {=str_tor_itemtraits_dw_master_rune_skalf}"50% bonus to physical damage, 75% extra damage done as magical" - + dw_master_rune_skalf Crafting @@ -5021,7 +5021,7 @@ Invalid -1 {=str_tor_itemtraits_emp_enchant_myrmidia_wisdom}"+100 Polearm skill and 10% Ward Save. When damaged, 30% chance to expose enemies within 5m for 8 seconds, reducing their attack speed and physical resistance by 25%" - + emp_enchant_myrmidia_wisdom Faith @@ -5049,7 +5049,7 @@ Invalid -1 {=str_tor_itemtraits_gs_enchant_mork_kunningly_brutal}"20% movement speed and 15% bonus magical damage. When damaged, 25% chance to engulf enemies within 5m in Mork's miasma for 8 seconds, reducing movement speed by 30% and dealing 4 magical damage per second" - + gs_enchant_mork_kunningly_brutal Spellcraft @@ -5078,7 +5078,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_grimnir_slayers_oath}"15% Ward Save and 15 extra HP. Taking damage grants 35% attack speed for 5 seconds" - + dw_rune_grimnir_slayers_oath Crafting @@ -5107,7 +5107,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_grungni_drongrundum_spirit}"50% extra lightning damage and 30% armor penetration. Dealing damage grants 25% attack speed for 5 seconds" - + dw_rune_grungni_drongrundum_spirit Crafting @@ -5135,7 +5135,7 @@ Invalid -1 {=str_tor_itemtraits_dw_rune_valaya_ancestor_queen_salve}"25 extra HP. When damaged, 25% chance to heal friendly troops within 3m for 2 HP per second over 15 seconds" - + dw_rune_valaya_ancestor_queen_salve Crafting @@ -5157,7 +5157,7 @@ Invalid -1 {=str_tor_itemtraits_asrai_enchant_vaul_draugnir_breath}"20% Ward Save and 20% movement speed" - + asrai_enchant_vaul_draugnir_breath Spellcraft @@ -5185,7 +5185,7 @@ Invalid -1 {=str_tor_itemtraits_eo_enchant_asuryan_phoenix_eternal}"30% extra fire damage. Hits have a 30% chance to ignite enemies within 3m for 4 fire damage per second over 12 seconds" - + eo_enchant_asuryan_phoenix_eternal Spellcraft @@ -5213,7 +5213,7 @@ Invalid -1 {=str_tor_itemtraits_asrai_enchant_loec_tricksters_dance}"20% swing speed. Dealing damage grants 25% movement speed for 5 seconds" - + asrai_enchant_loec_tricksters_dance Spellcraft @@ -5242,7 +5242,7 @@ Invalid -1 {=str_tor_itemtraits_gs_enchant_gork_brutal_kunnin}"Cleave and 20% extra physical damage. Dealing damage grants 25% attack speed for 5 seconds" - + gs_enchant_gork_brutal_kunnin Spellcraft diff --git a/docs/enchantment-blueprint-storage-proposal.md b/docs/enchantment-blueprint-storage-proposal.md new file mode 100644 index 00000000..53cf9632 --- /dev/null +++ b/docs/enchantment-blueprint-storage-proposal.md @@ -0,0 +1,229 @@ +# Enchantment Blueprint Storage — Proposal + +Companion to [`vertical-slicing-proposal.md`](./vertical-slicing-proposal.md), scoped to the +Crafting module. Proposes collapsing the per-hero "known enchantment blueprints" lists into a +single campaign-scoped set owned by the Crafting module, and moving skill gating from +purchase time to enchanting-table time. + +This is a proposal for discussion, not a plan already agreed — see +[Open questions](#open-questions) at the end. + +## Current shape + +Storage is `HeroExtendedInfo.KnownEnchantmentBlueprints` — `[SaveableField(10)] List`, +one list per `Hero`, living in `Extensions/ExtendedInfoSystem/HeroExtendedInfo.cs`. + +```mermaid +flowchart LR + subgraph W["Writes (grants)"] + W1["EnchantmentShopHelper
(town purchase)"] + W2["EnchantmentBlueprintScript
(use a blueprint item)"] + W3["CraftingCareerHookRegistrations
(career choice unlock)"] + W4["InkStory / console"] + end + STORE[("HeroExtendedInfo
.KnownEnchantmentBlueprints
one List<string> per hero")] + subgraph R["Reads"] + R1["EnchantingVM.OnItemSelected
UNION over party heroes"] + R2["EnchantmentHelper
.IsBlueprintKnownByParty
UNION over party heroes"] + R3["InkStory.LearnRandomUnknown…
UNION over party heroes"] + R4["TOREnchantmentCraftingModel
.GetEffectiveIngredientAmount
per-hero, STACKS"] + R5["RunelordCareerButtonBehavior
MainHero only"] + R6["Runelord/Runesmith/OrcShaman
quest counters — MainHero only"] + R7["EnchantmentHelper
.GetEligibleHeroesForBlueprint
per-hero (genuine)"] + end + W1 --> STORE + W2 --> STORE + W3 --> STORE + W4 --> STORE + STORE --> R1 + STORE --> R2 + STORE --> R3 + STORE --> R4 + STORE --> R5 + STORE --> R6 + STORE --> R7 +``` + +**The data is stored per hero but almost never read that way.** Three of the seven read sites +immediately union it back across the party; the granularity only survives in four places, and +three of those are arguably defects: + +| Site | Behavior | Assessment | +|---|---|---| +| `TOREnchantmentCraftingModel.GetEffectiveIngredientAmount` | Loops party heroes; **every** hero who knows the trait applies their own career discount | Discount stacks with how many heroes happen to know the same blueprint. Almost certainly unintended. | +| `RunelordCareerButtonBehavior` (`:327`, `:414`) | Reads `Hero.MainHero` only | A rune a companion learned is invisible to the rune-application button, even though the enchanting table offers it. | +| `RunelordQuest` / `RunesmithQuest` / `OrcShamanQuest2` | Count `Hero.MainHero`'s list only | Combined with the shop hiding anything `IsBlueprintKnownByParty`, a companion learning a rune removes it from the shop *and* never credits the quest counter. Already documented in-code at `RunelordQuest.cs:46`. | +| `EnchantmentHelper.GetEligibleHeroesForBlueprint` | Genuinely per-hero | The one load-bearing use — but see below, the gate it enforces doesn't actually come from this list. | + +There is also a silent-loss problem: because the table unions over *current* party members, a +companion dying or leaving takes their blueprints with them. The player loses enchantments they +paid gold + custom resource for, with no notification. + +### The per-hero list isn't what gates learning + +Worth being precise, because it's the crux: `GetEligibleHeroesForBlueprint`'s restriction check +reads `info.KnownLores` and `hero.HasAttribute(restriction)` — *not* +`KnownEnchantmentBlueprints`. The list is only consulted to skip heroes who already know the +blueprint. So "only a Death-lore caster can learn Shyish Whisper" survives centralization +untouched; it was never enforced by the stored list. + +## Proposal 1 — one campaign-scoped set, owned by Crafting + +Replace the N per-hero lists with a single `HashSet` on a Crafting-module +`CampaignBehaviorBase`, persisted through behavior-level `SyncData` (which +`EnchanterTownBehavior`, `PriestBehavior` and `TORArtisanDistrictCampaignBehavior` already +use). Either a small new `EnchantmentBlueprintBehavior` or a field on the existing artisan +behavior — a dedicated behavior is cleaner to reason about and to register in `CraftingModule`. + +Behavior-level `SyncData` means **no `TORSaveableTypeDefiner` id is needed**, so this sidesteps +the "never renumber" constraint entirely for the new store. + +### Why this branch specifically + +`KnownEnchantmentBlueprints` is a Crafting concept living in `Extensions/ExtendedInfoSystem`, +which `vertical-slicing-proposal.md` classifies as **Framework**. That is exactly the +arrow-pointing-the-wrong-way its key invariant calls out. Centralizing it into the module +deletes a Framework → module data coupling *and* takes the save data with it — the same move +the proposal recommends for `SaveGameSystem` type definitions. + +### What each call site becomes + +- `hero.HasKnownEnchantmentBlueprint(id)` → `EnchantmentBlueprints.IsKnown(id)` +- `EnchantmentHelper.IsBlueprintKnownByParty(id)` → the same `IsKnown(id)` call; the helper + collapses to nothing +- `EnchantingVM.OnItemSelected` — the nested per-hero loop collapses to a single + `ItemTrait.All.Where(x => x.IsCraftable && IsKnown(x) && ItemTrait.IsValidFor(x, itemType))`. + The dead debug loop at `EnchantingVM.cs:126–138` (computes `he`/`ve`, throws them away) goes + with it. +- Quest counters now count the same set the shop hides from — the desync at + `RunelordQuest.cs:46` closes on its own +- `RunelordCareerButtonBehavior` starts seeing companion-learned runes +- `GetEffectiveIngredientAmount` needs an explicit decision — "each knowing hero stacks a + discount" stops being expressible, which is the point + +### Migration + +1. Keep reading `[SaveableField(10)]` for one release. +2. On `OnAfterSessionLaunchedEvent`, union every hero's list into the central set, once. +3. Stop writing field 10. **Never reuse id 10** — per the warning in + `SaveGameSystem/TORSaveableTypeDefiner`. + +### The one real decision it forces + +Career cost reduction currently stacks per knowing hero. Once there's one set, "whose career +discount applies?" has to be answered explicitly: + +| Option | Behavior | Trade-off | +|---|---|---| +| **(a) Best in party** *(recommended)* | `partyHeroes.Max(discount)` | Predictable, closest to apparent intent, keeps companions meaningful. | +| (b) MainHero only | Only the player's career matters | Simplest; drops the "hire a Runelord companion" fantasy. | +| (c) Attribution map | `Dictionary` alongside the set | Preserves current flavour but re-introduces most of the complexity being removed. | + +## Proposal 2 — move skill gating to the enchanting table + +Skill is currently checked in three places under three different rules, and *not* checked in +the one place it would matter: + +| Where | Rule today | +|---|---| +| `EnchantmentShopHelper.CreateInquiryElement` | Row **disabled** unless some eligible hero has `skill >= requiredSkillValue`. Note the eligible list itself is built with `requireRequiredSkill: false`, so skill gets evaluated twice, two different ways. | +| `EnchantmentBlueprintScript.OnUse` | Hard filter — a hero under the threshold isn't even offered. | +| `EnchantingVM.OnItemSelected` (the table) | **No skill check at all.** | + +So skill is a purchase-time toll with no ongoing meaning: buy at Smithing 150, drop Smithing to +0, craft it forever. And inversely, being 5 points short shows a greyed row the player can do +nothing about except leave and come back. + +**Agreed — the check belongs at the table.** Reasons, in order of weight: + +1. It becomes a *live* requirement instead of a one-time toll. Skill starts actually mattering. +2. It removes the dead-end greyed row. Buying a recipe you can't yet execute becomes a + legitimate goal to work toward — purchase is acquiring the knowledge, skill is being able to + execute it. That's the better progression story. +3. One rule in one place instead of three variants. +4. It kills the `SelectRecipientHero` null path: with 2+ eligible heroes and MainHero not among + them, `FirstOrDefault(x => x == Hero.MainHero)` returns null and the purchased blueprint + silently becomes an inventory item instead of being learned. + +Is there *any* benefit to the purchase-time lock? One, and it's weak: it stops the player +spending gold and custom resource on something unusable. That's better served by a tooltip +warning than a disabled row — and the current implementation doesn't deliver it consistently +anyway, since career-granted blueprints +(`CraftingCareerHookRegistrations`) bypass the shop entirely. + +### Caveats worth deciding up front + +- **Don't hide, disable.** A known-but-unusable blueprint must still appear in the table, + greyed, with "Requires Smithing 150" — otherwise the player thinks they lost it. This is the + same mistake the current party-union makes when a companion leaves. +- **Whose skill?** Recommend best-in-party, matching option (a) above. Consistency between the + cost-reduction rule and the skill rule matters more than which one is picked. +- **Career grants would newly be gated.** Runelord/Imperial Magister blueprints arrive without + passing through the shop, so today they skip skill checks entirely. A table-time check starts + applying to them — a real balance change on those careers, and worth a deliberate call rather + than shipping it as a side effect. + +## Plan of attack + +Sequenced so that the risky change (save format) lands *before* anything reads it, and the +behavior changes land one at a time afterwards. Each phase is independently shippable and +independently revertible. + +```mermaid +flowchart LR + P0["P0 — read shim
no behavior change"] --> P1["P1 — central store
dual-write, nothing reads it"] + P1 --> P2["P2 — flip the read
FIRST behavior change"] + P2 --> P3a["P3a — Runelord button"] + P2 --> P3b["P3b — quest counters"] + P2 --> P3c["P3c — cost reduction
(balance call)"] + P3a --> P5["P5 — cleanup
≥1 release later"] + P3b --> P5 + P3c --> P5 + P4["P4 — skill gating
independent"] -.no dependency.-> P5 +``` + +| Phase | Change | Behavior change? | Verify | Revert | +|---|---|---|---|---| +| **P0** | Add `EnchantmentBlueprints.IsKnown(id)`, implemented as *today's* party union. Point the three union call sites at it (`EnchantingVM.OnItemSelected`, `IsBlueprintKnownByParty`, `InkStory`). Storage untouched. | **No** — byte-identical | Enchanting table offers the same traits as before | Trivial | +| **P1** | Add `EnchantmentBlueprintBehavior` (`HashSet` + `SyncData`). Dual-write on every grant. One-time migration unions existing hero lists on `OnAfterSessionLaunchedEvent`. **Nothing reads the set yet.** | **No** — set is write-only | Save/load round-trip; on a save with companions, central set == party union | Safe: no reader depends on it | +| **P2** | `IsKnown` reads the central set instead of the union. Hero lists still written as a safety net. | **Yes** — first one | Drop a companion who knew a blueprint; table still offers it | Flip one method body back | +| **P3a** | `RunelordCareerButtonBehavior` (`:327`, `:414`) reads `IsKnown` | **Yes** — fixes companion-learned runes being invisible | Companion learns a rune → button sees it | Independent | +| **P3b** | Quest counters (`RunelordQuest`, `RunesmithQuest`, `OrcShamanQuest2`) count the central set | **Yes** — closes the `RunelordQuest.cs:46` desync | Companion learns a rune → counter increments | Independent | +| **P3c** | `GetEffectiveIngredientAmount` → best-in-party discount (option (a)) | **Yes** — *balance*: stacking discount goes away | Ingredient cost with 1 vs 3 knowing heroes is now identical | Independent | +| **P4** | Skill check moves to table population; disabled-not-hidden affordance; shop stops disabling rows | **Yes** — *balance* | Under-skilled known blueprint shows greyed with reason, not hidden | Independent of P0–P3 | +| **P5** | Drop dual-write, remove `[SaveableField(10)]`, delete `IsBlueprintKnownByParty` and the dead `EnchantingVM.cs:126–138` debug loop | No | Load a pre-migration save | — | + +Notes on the sequencing: + +- **P0 + P1 are the safety net.** Together they get the whole codebase talking to one API and + the new store populated and persisted, with behavior provably unchanged. If review only has + appetite for one thing, land these — they make every later phase a small diff. +- **P2 is the smallest possible "it changed" commit** — one method body. That's deliberate: it + is the point where blame lands if the enchanting table starts behaving oddly. +- **P3a–P3c are the actual player-facing value** and are listed separately on purpose. In the + original sketch they were invisible side effects of a mega-refactor; each is a real bug fix + that deserves its own verification and its own revert. +- **P3c and P4 are balance changes, not refactors.** Different review question ("do we want + this?" rather than "is this correct?"), so they should not ride along inside a refactor PR. +- **P4 has no dependency on P0–P3** — if the storage work stalls, skill gating can still ship. +- **P5 is gated on a release boundary**, not on P3/P4 merging: per + [Migration](#migration), field 10 must survive one release before removal, and id 10 must + never be reused. + +### Decide before starting + +- Cost-reduction rule — option (a)/(b)/(c) above. Blocks **P3c**. +- Whether career-granted blueprints become skill-gated. Blocks **P4**. + +Everything else can be settled in review. + +## Open questions + +- Set on a behavior vs. keeping a thin `hero.HasKnownEnchantmentBlueprint` shim over it for one + release, to avoid touching ~10 call sites in the same PR as the storage change? +- Should the Runelord *unit-rune* application path (`RunelordCareerButtonBehavior`) share the + same skill rule as the enchanting table, or keep its own? It has a separate + ingredient-cost path (`GetIngredientCost`, 3× / 2× multiplier) already. +- `OrcShamanQuest2`/`RunelordQuest`/`RunesmithQuest` count blueprints as a progress metric. + With one central set, does a career-granted blueprint count toward the quest? Today it does + for MainHero and doesn't for a companion — after centralizing, it always would.