From 68f32ef5e386e04d1edc70dceb3c969ebdd81f0a Mon Sep 17 00:00:00 2001 From: MidnightsFX Date: Wed, 2 Sep 2026 21:05:47 -0700 Subject: [PATCH] Tempering & Shardstone configuration (#27) * tempering config exposed * sdk upgrade, EQAS update to 3.0.1 * project update * changelog cleanup --- AdvancedPortals/AdvancedPortals.cs | 18 +- AdvancedPortals/AdvancedPortals.csproj | 57 ++-- ConsoleHelper/ConsoleHelper.csproj | 42 ++- Directory.Build.props | 96 ++++++ Directory.Build.targets | 35 +++ DvergerColor/ImprovedDvergerCirclet.csproj | 52 ++-- Environment.props.example | 17 ++ EpicLoot/EpicLoot.cs | 2 +- EpicLoot/EpicLoot.csproj | 51 ++++ EpicLoot/Package/CHANGELOG.md | 25 ++ EpicLoot/Package/manifest.json | 2 +- EpicLoot/config/adventuredata.json | 36 ++- EpicLoot/config/shardstones.json | 177 ++++++++++- EpicLoot/localizations/English.json | 61 +++- EpicLoot/src/Adventure/AdventureDataConfig.cs | 11 + .../src/Adventure/AdventureDataManager.cs | 4 + EpicLoot/src/Config/ELConfig.cs | 148 ++++++++-- .../ItemDrop_Patch_MagicItemTooltip.cs | 6 +- EpicLoot/src/Magic/AutoAddEnchantableItems.cs | 7 + EpicLoot/src/Magic/EquipmentEffectCache.cs | 67 ++++- .../Magic/MagicItemEffects/AddCarryWeight.cs | 18 +- .../Helpers/BloodBlockSelfDamage.cs | 19 +- .../MagicItemEffects/Helpers/EffectConfig.cs | 59 ++++ .../Helpers/PenaltyScaling.cs | 67 ++++- .../Helpers/ShardEffectDefinitions.cs | 277 +++++++++++++++--- .../SharedSEManModifyMaxCarryWeightPatch.cs | 45 +++ .../src/Magic/MagicItemEffects/Paralyze.cs | 3 + .../Shards/AdrenalineFrostWave.cs | 52 +++- .../Shards/AdrenalineIncreasesHealthRegen.cs | 21 +- .../MagicItemEffects/Shards/AnchoredBlock.cs | 8 +- .../Shards/ArmorFromMovementPenalty.cs | 11 +- .../MagicItemEffects/Shards/BloodDrinker.cs | 48 ++- .../MagicItemEffects/Shards/Bloodrage.cs | 35 ++- .../Shards/BonemassCorpseRot.cs | 41 ++- .../MagicItemEffects/Shards/BurdenedBlock.cs | 45 ++- .../Shards/CarryWeightForMovementPenalty.cs | 26 +- .../Shards/ChanceDoubleDamage.cs | 21 +- .../Shards/ChanceToCritOnHit.cs | 32 +- .../Magic/MagicItemEffects/Shards/Conduit.cs | 37 ++- .../Shards/DamageBonusFromPlayerWeight.cs | 4 + .../DamageIncreaseFromMovementPenalty.cs | 4 + .../Shards/EikthyrShockingCharge.cs | 67 ++++- .../Shards/EitrImbueAttack.cs | 25 +- .../Shards/ElderForestsAid.cs | 42 ++- .../GainAdrenalineWhenApplyingPoison.cs | 57 +++- .../Shards/GainMaxCarryWeightFromRested.cs | 32 +- .../Shards/HealthGainPerXDamageDone.cs | 33 ++- .../Shards/HealthOnEitrUse.cs | 33 ++- .../MagicItemEffects/Shards/IcyWeight.cs | 8 +- .../IncreaseXPGainFromMovementPenalty.cs | 10 +- .../MagicItemEffects/Shards/Inspiration.cs | 62 ++-- .../Shards/KillsReduceNextBloodCost.cs | 42 ++- .../Magic/MagicItemEffects/Shards/Kindling.cs | 36 ++- .../Shards/LuckWhileFishing.cs | 52 +++- .../MagicItemEffects/Shards/LuckyLoot.cs | 47 ++- .../MagicItemEffects/Shards/MeteorSummoner.cs | 71 +++-- .../Shards/ModerIcyRetribution.cs | 43 ++- .../Shards/NightCarryWeight.cs | 20 +- .../Shards/PerfectDodgeEffects.cs | 59 +++- .../MagicItemEffects/Shards/QueenEverflow.cs | 34 ++- .../MagicItemEffects/Shards/RunningOnEmpty.cs | 30 +- .../StaminaIncreaseForMovementPenalty.cs | 10 +- .../StaminaRegenBonusFromPlayerWeight.cs | 11 +- .../MagicItemEffects/Shards/StormFury.cs | 44 ++- .../Shards/StrikeCausesLightning.cs | 21 +- .../SummonBatWhenActivatingAdrenaline.cs | 49 +++- .../MagicItemEffects/Shards/Trailblazer.cs | 62 +++- .../MagicItemEffects/Shards/TravelLight.cs | 65 ++-- EpicLoot/src/Magic/MagicItemEffects/Slow.cs | 84 +++++- EpicLoot/src/Magic/PlayerExtensions.cs | 12 +- EpicLoot/src/Patching/FilePatching.cs | 13 +- EpicLoot/src/Patching/PatchReloadDebouncer.cs | 59 ++++ EpicLoot/src/ShardStones/Shards.cs | 59 +++- EpicLoot/src/Tempering/TemperMan.cs | 97 +++++- EpicLoot/src/Tempering/TemperPanel.cs | 24 +- EpicLoot/src/Tempering/TemperRequirement.cs | 1 + EpicLootAPI/EpicLootAPI/EpicLootAPI.csproj | 53 ++-- .../EquipmentAndQuickSlots.cs | 26 +- EquipmentAndQuickSlots/Package/CHANGELOG.md | 9 + EquipmentAndQuickSlots/Package/README.md | 10 + EquipmentAndQuickSlots/Package/manifest.json | 2 +- .../Properties/AssemblyInfo.cs | 4 +- .../src/Compatibility/BetterUICompat.cs | 43 +++ EquipmentAndQuickSlots/src/EquipmentPanel.cs | 128 +++++++- .../src/InventoryPatches.cs | 52 +++- .../src/QuickSlotsHotBar.cs | 9 + .../EquipmentAndQuickSlotsAPI.csproj | 9 +- ImprovedBuildHud/ImprovedBuildHud.csproj | 48 ++- ItsJustWood/ItsJustWood.csproj | 50 ++-- Jam/Jam.cs | 11 +- Jam/Jam.csproj | 55 ++-- .../MinimalStatusEffects.csproj | 49 ++-- Paths.props | 28 +- .../StationsAreContainers.csproj | 62 ++-- ValheimMods.sln | 10 +- 95 files changed, 3063 insertions(+), 796 deletions(-) create mode 100644 Directory.Build.props create mode 100644 Directory.Build.targets create mode 100644 Environment.props.example create mode 100644 EpicLoot/src/Magic/MagicItemEffects/Helpers/EffectConfig.cs create mode 100644 EpicLoot/src/Magic/MagicItemEffects/Helpers/SharedSEManModifyMaxCarryWeightPatch.cs create mode 100644 EpicLoot/src/Patching/PatchReloadDebouncer.cs create mode 100644 EquipmentAndQuickSlots/src/Compatibility/BetterUICompat.cs diff --git a/AdvancedPortals/AdvancedPortals.cs b/AdvancedPortals/AdvancedPortals.cs index b3916a4a3..07d315414 100644 --- a/AdvancedPortals/AdvancedPortals.cs +++ b/AdvancedPortals/AdvancedPortals.cs @@ -1,6 +1,7 @@ using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; +using Common; using HarmonyLib; using JetBrains.Annotations; using Jotunn.Configs; @@ -105,6 +106,13 @@ private void Awake() true, true, ref BlackMarblePortalAllowPreviousPortalItems); AssetBundle assetBundle = LoadAssetBundle("advancedportals"); + if (assetBundle == null) + { + // Everything below needs the bundle. Bailing here reports one clear cause instead of a + // cascade of null-reference errors from the piece registrations that follow. + APLogger.LogError("Failed to load the 'advancedportals' asset bundle. Advanced Portals is disabled for this session."); + return; + } LoadBuildPiece(assetBundle, "portal_ancient", new PieceConfig() { @@ -235,12 +243,14 @@ public string GetExtendedDescription(string description, bool synchronizedSettin return description + (synchronizedSetting ? " [Synced with Server]" : " [Not Synced with Server]"); } + // The assembly is named explicitly rather than taken from Assembly.GetCallingAssembly(). When + // another mod hooks Awake, MonoMod recompiles it as a dynamic method (DMD<...::Awake>), and the + // "calling assembly" is then that dynamic assembly, not this one. The resource lookup misses, + // LoadFromStream(null) throws "ArgumentNullException: stream", and the mod fails to load — + // intermittently, since it depends on which other mods are present. public static AssetBundle LoadAssetBundle(string filename) { - Assembly assembly = Assembly.GetCallingAssembly(); - AssetBundle assetBundle = AssetBundle.LoadFromStream(assembly.GetManifestResourceStream($"{assembly.GetName().Name}.{filename}")); - - return assetBundle; + return AssetBundleLoader.LoadFromResources(filename, typeof(AdvancedPortals).Assembly); } private static void LoadBuildPiece(AssetBundle assetBundle, string assetName, PieceConfig piececonfig) diff --git a/AdvancedPortals/AdvancedPortals.csproj b/AdvancedPortals/AdvancedPortals.csproj index f1c51e95c..cb6e5cc30 100644 --- a/AdvancedPortals/AdvancedPortals.csproj +++ b/AdvancedPortals/AdvancedPortals.csproj @@ -1,59 +1,52 @@  - - + - Debug - AnyCPU - {0DDDE55C-5DAF-4B53-B5E2-83B5C44C4682} Library - Properties + net472 AdvancedPortals AdvancedPortals - v4.7.2 10.0 512 true + true + + false + + false + false + - + + + DEBUG;TRACE + false true full - false - bin\Debug\ - DEBUG;TRACE - prompt 4 - true - - pdbonly - true - bin\Release\ + + TRACE - prompt + true + pdbonly 4 - true - - - - - + advancedportals - - - - - - - + + + + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/')) @@ -94,4 +87,4 @@ - \ No newline at end of file + diff --git a/ConsoleHelper/ConsoleHelper.csproj b/ConsoleHelper/ConsoleHelper.csproj index 759c0dd00..79caaf27f 100644 --- a/ConsoleHelper/ConsoleHelper.csproj +++ b/ConsoleHelper/ConsoleHelper.csproj @@ -1,41 +1,37 @@  - - + - Debug - AnyCPU - {815F37D9-CEEA-4CDA-ABFB-C62FEA26B5F1} Library - Properties + net472 ConsoleHelper ConsoleHelper - v4.7.2 10.0 512 true + + false + + false + false + - + + + DEBUG;TRACE + false true full - false - bin\Debug\ - DEBUG;TRACE - prompt 4 true - - pdbonly - true - bin\Release\ + + TRACE - prompt + true + pdbonly 4 - - - - - - \ No newline at end of file + diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 000000000..e74626403 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,96 @@ + + + + + + + + + + + + $(MSBuildThisFileDirectory) + + + + + + $([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Steam App 892970', 'InstallLocation', null, RegistryView.Registry64, RegistryView.Registry32)) + <_SteamLibraryPath>$([MSBuild]::GetRegistryValueFromView('HKEY_CURRENT_USER\SOFTWARE\Valve\Steam', 'SteamPath', null, RegistryView.Registry32)) + $(_SteamLibraryPath)\steamapps\common\Valheim + C:\Program Files\Steam\steamapps\common\Valheim + C:\Program Files (x86)\Steam\steamapps\common\Valheim + + + + $(HOME)/.steam/steam/steamapps/common/Valheim + $(HOME)/Library/Application Support/Steam/steamapps/common/Valheim/Contents/MacOS + + + + + + $(GamePath)\BepInEx\ + + + + <_VhGaleProfiles>$(AppData)\com.kesomannen.gale\valheim\profiles + <_VhR2Profiles>$(AppData)\r2modmanPlus-local\Valheim\profiles + <_VhTsmmProfiles>$(AppData)\Thunderstore Mod Manager\DataFolder\Valheim\profiles + + + <_VhBepInExHits Condition="'$(_VhBepInExHits)' == '' And Exists('$(_VhGaleProfiles)')">$([System.IO.Directory]::GetFiles('$(_VhGaleProfiles)', 'BepInEx.dll', SearchOption.AllDirectories)) + <_VhBepInExHits Condition="'$(_VhBepInExHits)' == '' And Exists('$(_VhR2Profiles)')">$([System.IO.Directory]::GetFiles('$(_VhR2Profiles)', 'BepInEx.dll', SearchOption.AllDirectories)) + <_VhBepInExHits Condition="'$(_VhBepInExHits)' == '' And Exists('$(_VhTsmmProfiles)')">$([System.IO.Directory]::GetFiles('$(_VhTsmmProfiles)', 'BepInEx.dll', SearchOption.AllDirectories)) + <_VhBepInExDll Condition="'$(_VhBepInExHits)' != ''">$(_VhBepInExHits.Split(';')[0]) + $([System.IO.Path]::GetFullPath($([System.IO.Path]::Combine($([System.IO.Path]::GetDirectoryName('$(_VhBepInExDll)')), '..'))))\ + + + + + + + + $(GamePath) + $(GamePathManaged.TrimEnd('\')) + $(BepinexPath.TrimEnd('\')) + + + + + + + + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 000000000..1f26c1d94 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + diff --git a/DvergerColor/ImprovedDvergerCirclet.csproj b/DvergerColor/ImprovedDvergerCirclet.csproj index 604735abe..da3b97042 100644 --- a/DvergerColor/ImprovedDvergerCirclet.csproj +++ b/DvergerColor/ImprovedDvergerCirclet.csproj @@ -1,50 +1,42 @@  - - + - Debug - AnyCPU - {CBED8160-747F-4022-98CE-61A8EE201652} Library - Properties + net472 DvergerColor ImprovedDvergerCirclet - v4.7.2 10.0 512 true + true + + false + + false + false + - + + + DEBUG;TRACE + false true full - false - bin\Debug\ - DEBUG;TRACE - prompt 4 - true - - pdbonly - true - bin\Release\ + + TRACE - prompt + true + pdbonly 4 - true - - - - - - - - - + - + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/')) @@ -85,4 +77,4 @@ - \ No newline at end of file + diff --git a/Environment.props.example b/Environment.props.example new file mode 100644 index 000000000..0300c3fdc --- /dev/null +++ b/Environment.props.example @@ -0,0 +1,17 @@ + + + + + + C:\Program Files (x86)\Steam\steamapps\common\Valheim + + + + + diff --git a/EpicLoot/EpicLoot.cs b/EpicLoot/EpicLoot.cs index 5f7283284..bd74cac82 100644 --- a/EpicLoot/EpicLoot.cs +++ b/EpicLoot/EpicLoot.cs @@ -37,7 +37,7 @@ namespace EpicLoot; public sealed class EpicLoot : BaseUnityPlugin { public const string PluginId = "randyknapp.mods.epicloot"; public const string DisplayName = "Epic Loot"; - public const string Version = "0.13.3"; + public const string Version = "0.13.4"; private static string ConfigFileName = PluginId + ".cfg"; private static string ConfigFileFullPath = BepInEx.Paths.ConfigPath + Path.DirectorySeparatorChar + ConfigFileName; diff --git a/EpicLoot/EpicLoot.csproj b/EpicLoot/EpicLoot.csproj index dd3840149..b9bbff998 100755 --- a/EpicLoot/EpicLoot.csproj +++ b/EpicLoot/EpicLoot.csproj @@ -2,6 +2,57 @@ + + + + + + + + + + + + + + + + $(GamePathManaged)assembly_googleanalytics.dll + False + + + $(GamePathManaged)assembly_lux.dll + False + + + $(GamePathManaged)assembly_postprocessing.dll + False + + + $(GamePathManaged)assembly_simplemeshcombine.dll + False + + + $(GamePathManaged)assembly_sunshafts.dll + False + + + $(GamePathManaged)SoftReferenceableAssets.dll + False + + + $(GamePathManaged)gui_framework.dll + False + + Library net481 diff --git a/EpicLoot/Package/CHANGELOG.md b/EpicLoot/Package/CHANGELOG.md index 233d639f0..e6c6aa684 100644 --- a/EpicLoot/Package/CHANGELOG.md +++ b/EpicLoot/Package/CHANGELOG.md @@ -1,3 +1,28 @@ +**0.13.4** + +Changes: +* Shardstone effect tuning is now exposed to config + * New `Global` block in `shardstones.json` for tunables shared by several shards +* Tempering costs are configurable per rarity through a `Tempering.CostsByRarity` block in `adventuredata.json`. A rarity left out keeps its built-in default +* The same effect declared on several shard slots with disagreeing `Config` blocks is reported rather than silently resolved + +Bugfixes: +* Editing `shardstones.json` live, now properly updates effects +* Slow is clamped to a floor of 10% +* A tempering cost naming an item that cannot be resolved is warned about once and skipped +* Fixes Adrenaline Surge's "Seconds per 1%" tooltip not localizing +* A patch edit now reloads the rebuilt configs into the running game +* The patch file watcher now covers subdirectories, so patches shipped in `patches//` are watched +* Patch files that changed but rebuilt nothing now say so in the log instead of failing silently +* A baseconfig file that deserializes to null is rejected and the currently loaded config is kept + +Performance: +* The movement-penalty measurement is cached per physics step +* Magic effect value lookups hold on to the last player's table and no longer allocate a delegate per call on the per-tick paths +* Every shard effect sitting on a hot vanilla method bails on a zero effect value before doing any other work +* The comparison tooltip only walks the player's equipment while Ctrl is actually held + + **0.13.3** New Content: diff --git a/EpicLoot/Package/manifest.json b/EpicLoot/Package/manifest.json index 561f49a93..ce8690416 100644 --- a/EpicLoot/Package/manifest.json +++ b/EpicLoot/Package/manifest.json @@ -1,6 +1,6 @@ { "name": "EpicLoot", - "version_number": "0.13.3", + "version_number": "0.13.4", "website_url": "https://discord.gg/ZNhYeavv3C", "description": "Adds loot drops, magic items, and enchanting to Valheim.", "dependencies": [ diff --git a/EpicLoot/config/adventuredata.json b/EpicLoot/config/adventuredata.json index a22a62cf0..5e3125bda 100755 --- a/EpicLoot/config/adventuredata.json +++ b/EpicLoot/config/adventuredata.json @@ -742,5 +742,39 @@ "$mod_epicloot_bounties_suffix_the_wicked" ] } - } + }, + "Tempering": { + "CostsByRarity": { + "Magic": [ + { "Item": "Coins", "Amount": 10 }, + { "Item": "EssenceMagic", "Amount": 10 }, + { "Item": "ReagentMagic", "Amount": 10 }, + { "Item": "DustMagic", "Amount": 10 } + ], + "Rare": [ + { "Item": "ForestToken", "Amount": 1 }, + { "Item": "EssenceRare", "Amount": 10 }, + { "Item": "ReagentRare", "Amount": 10 }, + { "Item": "DustRare", "Amount": 10 } + ], + "Epic": [ + { "Item": "IronBountyToken", "Amount": 1 }, + { "Item": "EssenceEpic", "Amount": 10 }, + { "Item": "ReagentEpic", "Amount": 10 }, + { "Item": "DustEpic", "Amount": 10 } + ], + "Legendary": [ + { "Item": "GoldBountyToken", "Amount": 1 }, + { "Item": "EssenceLegendary", "Amount": 10 }, + { "Item": "ReagentLegendary", "Amount": 10 }, + { "Item": "DustLegendary", "Amount": 10 } + ], + "Mythic": [ + { "Item": "GoldBountyToken", "Amount": 2 }, + { "Item": "EssenceMythic", "Amount": 10 }, + { "Item": "ReagentMythic", "Amount": 10 }, + { "Item": "DustMythic", "Amount": 10 } + ] + } + } } diff --git a/EpicLoot/config/shardstones.json b/EpicLoot/config/shardstones.json index 64a1efa35..b98f262b6 100644 --- a/EpicLoot/config/shardstones.json +++ b/EpicLoot/config/shardstones.json @@ -1,4 +1,10 @@ { + "Global": { + "Values": { + "MovementPenaltyReference": 0.4, + "BloodBlockSelfDamagePercent": 5 + } + }, "Shards": { "Red": { "Category": "Core", @@ -38,6 +44,9 @@ "Epic": 9, "Legendary": 12, "Mythic": 15 + }, + "Config": { + "EitrPerTrigger": 100 } }, "Shield": { @@ -47,7 +56,7 @@ "Rare": 1.5, "Epic": 2, "Legendary": 2.5, - "Mythic": 3 + "Mythic": 3 } }, "Head": { @@ -242,6 +251,9 @@ "Epic": 13, "Legendary": 15, "Mythic": 17 + }, + "Config": { + "EitrCostPerDamage": 1 } }, "RangedWeapon": { @@ -252,6 +264,9 @@ "Epic": 25, "Legendary": 30, "Mythic": 35 + }, + "Config": { + "EitrCostPerDamage": 1 } }, "MagicWeapon": { @@ -414,6 +429,9 @@ "Epic": 12, "Legendary": 15, "Mythic": 18 + }, + "Config": { + "DamagePerTrigger": 75 } }, "Trinket": { @@ -536,6 +554,10 @@ "Epic": 8, "Legendary": 10, "Mythic": 12 + }, + "Config": { + "MaxStacks": 5, + "BuffDuration": 10 } }, "Utility": { @@ -556,6 +578,9 @@ "Epic": 60, "Legendary": 80, "Mythic": 100 + }, + "Config": { + "BuffDuration": 1 } } } @@ -648,6 +673,13 @@ "Epic": 4, "Legendary": 5, "Mythic": 6 + }, + "Config": { + "Cooldown": 10, + "BatLifetime": 30, + "SpawnRadius": 2, + "SummonCountOffset": -1, + "ConcurrentPerSummon": 2 } }, "Utility": { @@ -828,7 +860,7 @@ "EffectType": "AnchoredBlock", "ValuesPerRarity": { "Magic": 0.25, - "Rare": 0.50, + "Rare": 0.5, "Epic": 0.75, "Legendary": 1, "Mythic": 1.25 @@ -994,6 +1026,9 @@ "Epic": 4, "Legendary": 5, "Mythic": 6 + }, + "Config": { + "Cooldown": 30 } }, "Shoulders": { @@ -1188,6 +1223,10 @@ "Epic": 3, "Legendary": 4, "Mythic": 5 + }, + "Config": { + "MaxStacks": 5, + "BuffDuration": 10 } }, "Legs": { @@ -1198,6 +1237,10 @@ "Epic": 9, "Legendary": 12, "Mythic": 15 + }, + "Config": { + "MaxHealthPercentPerValue": 2.5, + "MinHealthReduction": 10 } }, "Trinket": { @@ -1320,6 +1363,10 @@ "Epic": 3, "Legendary": 4, "Mythic": 5 + }, + "Config": { + "TickInterval": 3, + "Radius": 30 } }, "Utility": { @@ -1432,6 +1479,10 @@ "Epic": 2, "Legendary": 2.5, "Mythic": 3 + }, + "Config": { + "Radius": 10, + "SpeedFloor": 0.4 } }, "Utility": { @@ -1514,6 +1565,10 @@ "Epic": 4, "Legendary": 5, "Mythic": 6 + }, + "Config": { + "MaxReduction": 1, + "BuffDuration": 30 } }, "Chest": { @@ -1586,6 +1641,9 @@ "Epic": 7, "Legendary": 9, "Mythic": 11 + }, + "Config": { + "DamageMultiplier": 2 } }, "RangedWeapon": { @@ -1596,6 +1654,9 @@ "Epic": 7, "Legendary": 9, "Mythic": 11 + }, + "Config": { + "DamageMultiplier": 2 } }, "MagicWeapon": { @@ -1606,6 +1667,9 @@ "Epic": 7, "Legendary": 9, "Mythic": 11 + }, + "Config": { + "DamageMultiplier": 2 } }, "Shield": { @@ -1626,6 +1690,12 @@ "Epic": 20, "Legendary": 25, "Mythic": 30 + }, + "Config": { + "ProcChance": 0.25, + "MinSkillLevel": 2, + "LevelWeightExponent": 2, + "MaxLevelUpsPerProc": 20 } }, "Chest": { @@ -1636,6 +1706,13 @@ "Epic": 6, "Legendary": 8, "Mythic": 10 + }, + "Config": { + "BonusRollsMin": 1, + "BonusRollsMax": 6, + "MaxMultiplier": 10, + "MaxUnitsPerEntry": 100, + "MaxAddedUnitsPerDeath": 300 } }, "Legs": { @@ -1646,6 +1723,25 @@ "Epic": 15, "Legendary": 20, "Mythic": 25 + }, + "Config": { + "TripleChance": 20, + "RollBase": 40, + "RollPerValue": 12, + "RollCurve": 2, + "FloorFraction": 0.05, + "Flint": 5, + "Coins": 15, + "Amber": 40, + "Ruby": 80, + "Chitin": 100, + "AmberPearl": 120, + "CopperScrap": 180, + "SilverNecklace": 240, + "IronScrap": 300, + "SerpentScale": 360, + "RunestoneEpic": 500, + "RunestoneLegendary": 640 } }, "Trinket": { @@ -1768,6 +1864,9 @@ "Epic": 2, "Legendary": 2.5, "Mythic": 3 + }, + "Config": { + "TickInterval": 10 } }, "Utility": { @@ -1788,6 +1887,9 @@ "Epic": 8, "Legendary": 10, "Mythic": 12 + }, + "Config": { + "DamagePerTrigger": 200 } } } @@ -1810,6 +1912,9 @@ "Epic": 8, "Legendary": 10, "Mythic": 12 + }, + "Config": { + "DamagePerTrigger": 200 } }, "RangedWeapon": { @@ -1820,6 +1925,9 @@ "Epic": 5, "Legendary": 6, "Mythic": 7 + }, + "Config": { + "DamagePerTrigger": 200 } }, "MagicWeapon": { @@ -1830,6 +1938,9 @@ "Epic": 3, "Legendary": 4, "Mythic": 5 + }, + "Config": { + "DamagePerTrigger": 200 } }, "Shield": { @@ -1880,6 +1991,9 @@ "Epic": 26, "Legendary": 32, "Mythic": 40 + }, + "Config": { + "SecondsPerPercent": 0.5 } }, "Utility": { @@ -1948,10 +2062,14 @@ "EffectType": "BurdenedBlock", "ValuesPerRarity": { "Magic": 0.25, - "Rare": 0.50, + "Rare": 0.5, "Epic": 0.75, "Legendary": 1, "Mythic": 1.25 + }, + "Config": { + "WeightThreshold": 300, + "WeightPerBonus": 50 } }, "Head": { @@ -2012,6 +2130,10 @@ "Epic": 9, "Legendary": 12, "Mythic": 15 + }, + "Config": { + "CarryWeightPerValue": 10, + "MinResultingCarryWeight": 50 } } } @@ -2029,6 +2151,13 @@ "Epic": 8, "Legendary": 12, "Mythic": 16 + }, + "Config": { + "MinMoveSpeed": 1.5, + "SpawnInterval": 0.35, + "PatchRadius": 2.5, + "PatchLifetime": 3, + "PatchTickInterval": 0.5 } }, "TypeEffects": {} @@ -2046,6 +2175,10 @@ "Epic": 5, "Legendary": 8, "Mythic": 12 + }, + "Config": { + "DamagePerValue": 6, + "Radius": 1 } }, "TypeEffects": {} @@ -2066,6 +2199,13 @@ "Epic": 15, "Legendary": 20, "Mythic": 25 + }, + "Config": { + "MaxCharges": 15, + "DamageFraction": 0.3, + "ConeLength": 4, + "ConeMaxWidth": 4, + "DischargeIgnoreWindow": 0.3 } }, "TypeEffects": {} @@ -2086,6 +2226,12 @@ "Epic": 15, "Legendary": 20, "Mythic": 25 + }, + "Config": { + "Cooldown": 30, + "CooldownPerValue": 1.5, + "BaseRadius": 6, + "RadiusPerTier": 0.15 } }, "TypeEffects": {} @@ -2105,6 +2251,11 @@ "Epic": 15, "Legendary": 20, "Mythic": 25 + }, + "Config": { + "Cooldown": 5, + "Radius": 2, + "PoisonPerTier": 20 } }, "TypeEffects": {} @@ -2124,6 +2275,12 @@ "Epic": 15, "Legendary": 20, "Mythic": 25 + }, + "Config": { + "BaseCooldown": 140, + "CooldownPerRarity": 20, + "Radius": 8, + "FrostPerTier": 8 } }, "TypeEffects": {} @@ -2142,6 +2299,14 @@ "Epic": 8, "Legendary": 10, "Mythic": 12 + }, + "Config": { + "MaxCharges": 25, + "SpawnHeight": 20, + "MinDistance": 5, + "MaxDistance": 15, + "ProjectileSpeed": 20, + "ExplosionRadius": 4 } }, "TypeEffects": {} @@ -2160,6 +2325,10 @@ "Epic": 9, "Legendary": 10, "Mythic": 11 + }, + "Config": { + "MaxStacks": 10, + "BuffDuration": 10 } }, "TypeEffects": {} @@ -2182,4 +2351,4 @@ "TypeEffects": {} } } -} \ No newline at end of file +} diff --git a/EpicLoot/localizations/English.json b/EpicLoot/localizations/English.json index d4a6a5196..098fd4a8f 100644 --- a/EpicLoot/localizations/English.json +++ b/EpicLoot/localizations/English.json @@ -55,11 +55,70 @@ "mod_epicloot_config_flint": "Flint", "mod_epicloot_config_serpentscale": "Serpent Scale", "mod_epicloot_config_triplechance": "Triple Catch Chance", - "mod_epicloot_config_secondspercent": "Seconds per 1%", + "mod_epicloot_config_secondsperpercent": "Seconds per 1%", "mod_epicloot_config_procchance": "Proc Chance", "mod_epicloot_config_bonusrollsmin": "Min Bonus Loot Rolls", "mod_epicloot_config_bonusrollsmax": "Max Bonus Loot Rolls", + "mod_epicloot_config_cooldown": "Cooldown (s)", + "mod_epicloot_config_basecooldown": "Base Cooldown (s)", + "mod_epicloot_config_cooldownperrarity": "Cooldown per Rarity (s)", + "mod_epicloot_config_cooldownpervalue": "Cooldown per Value (s)", + "mod_epicloot_config_buffduration": "Duration (s)", + "mod_epicloot_config_tickinterval": "Tick Interval (s)", + "mod_epicloot_config_radius": "Radius (m)", + "mod_epicloot_config_baseradius": "Base Radius (m)", + "mod_epicloot_config_radiuspertier": "Radius per Value (m)", + "mod_epicloot_config_maxcharges": "Hits to Charge", + "mod_epicloot_config_damagepertrigger": "Damage per Trigger", + "mod_epicloot_config_eitrpertrigger": "Eitr per Trigger", + "mod_epicloot_config_damagemultiplier": "Damage Multiplier", + "mod_epicloot_config_damagefraction": "Damage Fraction", + "mod_epicloot_config_damagepervalue": "Damage per Value", + "mod_epicloot_config_frostpertier": "Frost Damage per Value", + "mod_epicloot_config_poisonpertier": "Poison Damage per Value", + "mod_epicloot_config_spawnheight": "Spawn Height (m)", + "mod_epicloot_config_spawnradius": "Spawn Radius (m)", + "mod_epicloot_config_spawninterval": "Spawn Interval (s)", + "mod_epicloot_config_mindistance": "Min Launch Distance (m)", + "mod_epicloot_config_maxdistance": "Max Launch Distance (m)", + "mod_epicloot_config_projectilespeed": "Projectile Speed (m/s)", + "mod_epicloot_config_explosionradius": "Explosion Radius (m)", + "mod_epicloot_config_conelength": "Cone Length (m)", + "mod_epicloot_config_conemaxwidth": "Cone Width (m)", + "mod_epicloot_config_dischargeignorewindow": "Discharge Ignore Window (s)", + "mod_epicloot_config_minmovespeed": "Min Move Speed", + "mod_epicloot_config_patchradius": "Patch Radius (m)", + "mod_epicloot_config_patchlifetime": "Patch Lifetime (s)", + "mod_epicloot_config_patchtickinterval": "Patch Tick Interval (s)", + "mod_epicloot_config_speedfloor": "Speed Floor", + "mod_epicloot_config_batlifetime": "Bat Lifetime (s)", + "mod_epicloot_config_summoncountoffset": "Summon Count Offset", + "mod_epicloot_config_concurrentpersummon": "Max Concurrent per Summon", + "mod_epicloot_config_maxreduction": "Max Reduction", + "mod_epicloot_config_maxhealthpercentpervalue": "Max Health % per Value", + "mod_epicloot_config_minhealthreduction": "Min Health Reduction", + "mod_epicloot_config_carryweightpervalue": "Carry Weight per Value", + "mod_epicloot_config_minresultingcarryweight": "Min Carry Weight", + "mod_epicloot_config_weightthreshold": "Weight Threshold", + "mod_epicloot_config_weightperbonus": "Weight per Bonus", + "mod_epicloot_config_eitrcostperdamage": "Eitr Cost per Damage", + "mod_epicloot_config_minskilllevel": "Min Skill Level", + "mod_epicloot_config_levelweightexponent": "Level Weight Exponent", + "mod_epicloot_config_maxlevelupsperproc": "Max Level-Ups per Proc", + "mod_epicloot_config_maxmultiplier": "Max Drop Multiplier", + "mod_epicloot_config_maxunitsperentry": "Max Units per Entry", + "mod_epicloot_config_maxaddedunitsperdeath": "Max Added Units per Kill", + "mod_epicloot_config_rollbase": "Roll Base", + "mod_epicloot_config_rollpervalue": "Roll per Value", + "mod_epicloot_config_rollcurve": "Roll Curve", + "mod_epicloot_config_floorfraction": "Floor Fraction", + "mod_epicloot_config_chitin": "Chitin", + "mod_epicloot_config_copperscrap": "Copper Scrap", + "mod_epicloot_config_ironscrap": "Iron Scrap", + "mod_epicloot_config_runestoneepic": "Epic Runestone", + "mod_epicloot_config_runestonelegendary": "Legendary Runestone", + "mod_epicloot_me_modifylowhealth_config_max": "Max Low Health Threshold", "mod_epicloot_me_riches_config_coins": "Coin Value", "mod_epicloot_me_riches_config_amber": "Amber Value", diff --git a/EpicLoot/src/Adventure/AdventureDataConfig.cs b/EpicLoot/src/Adventure/AdventureDataConfig.cs index 67c3b7949..5fb98e8e8 100644 --- a/EpicLoot/src/Adventure/AdventureDataConfig.cs +++ b/EpicLoot/src/Adventure/AdventureDataConfig.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using EpicLoot.Crafting; namespace EpicLoot.Adventure { @@ -167,6 +168,15 @@ public class BountiesConfig public BountyTargetNameConfig Names; } + [Serializable] + public class TemperingConfig + { + // Left empty rather than seeded with the defaults: Newtonsoft APPENDS to pre-initialized + // collections, and an absent rarity key has to fall through to TemperMan's hardcoded + // default instead of merging with it. TemperMan.ApplyConfig owns the fallback. + public Dictionary> CostsByRarity = new Dictionary>(); + } + [Serializable] public class AdventureDataConfig { @@ -175,5 +185,6 @@ public class AdventureDataConfig public GambleConfig Gamble; public TreasureMapConfig TreasureMap; public BountiesConfig Bounties; + public TemperingConfig Tempering; } } diff --git a/EpicLoot/src/Adventure/AdventureDataManager.cs b/EpicLoot/src/Adventure/AdventureDataManager.cs index ea9066965..01c716638 100755 --- a/EpicLoot/src/Adventure/AdventureDataManager.cs +++ b/EpicLoot/src/Adventure/AdventureDataManager.cs @@ -30,6 +30,10 @@ public static void Initialize(AdventureDataConfig config) Config.SecretStash.RollsPerRarity = new List { 1, 1, 1, 1, 1 }; } + // Every load path - first load, embedded-default fallback, file-watcher hot reload and the + // server->client RPC - routes through here, so this is the only hook tempering costs need. + TemperMan.ApplyConfig(Config?.Tempering); + OnSetupAdventureData?.Invoke(); SecretStash = new SecretStashAdventureFeature(); diff --git a/EpicLoot/src/Config/ELConfig.cs b/EpicLoot/src/Config/ELConfig.cs index 5fd04e89c..a0e4bb313 100755 --- a/EpicLoot/src/Config/ELConfig.cs +++ b/EpicLoot/src/Config/ELConfig.cs @@ -81,6 +81,7 @@ internal class ELConfig { public static ConfigEntry AlwaysShowWelcomeMessage; public static ConfigEntry OutputPatchedConfigFiles; + public static ConfigEntry VerifyPenaltyScalingCache; public static ConfigEntry EnchantingTableUpgradesActive; public static ConfigEntry EnableLimitedBountiesInProgress; public static ConfigEntry MaxInProgressBounties; @@ -227,6 +228,13 @@ internal class ELConfig { private static readonly List<(ConfigEntryBase Entry, string Location)> BoundEntries = new List<(ConfigEntryBase, string)>(); + /// + /// One "re-read this baseconfig file into the live config" callback per file, in the order + /// registered them. See . + /// + private static readonly List<(string FileName, Func ReloadFromDisk)> BaseConfigReloaders = + new List<(string, Func)>(); + public ELConfig(ConfigFile Config) { // ensure all the config values are created cfg = Config; @@ -609,6 +617,10 @@ private static void CreateConfigValues() { "Sets whether or not the welcome message is displayed on startup, this is automatically set to false once the player has viewed the message."); OutputPatchedConfigFiles = BindClient(SectionDebug, "OutputPatchedConfigFiles", false, "Just a debug flag for testing the patching system, do not use."); + VerifyPenaltyScalingCache = BindClient(SectionDebug, "Verify Penalty Scaling Cache", false, + "Recomputes the movement-penalty measurement on every read and logs any disagreement with the " + + "per-step cached value. Costs a full status-effect speed pass per read -- for diagnosing a " + + "suspected stale scaling factor only, do not leave on."); EnableHotReloadPatches = BindServer(SectionDebug, "Enable Hot Reloading Patches", true, "Controls whether or not patch edits can be live-reloaded. Can cause lag when recompiling patches."); AlwaysRefreshCoreConfigs = BindServer(SectionDebug, "Always Refresh Core Configs", false, @@ -831,26 +843,24 @@ ZPackage SendInitialConfig() { // Setup the initial synchronization for network connection SynchronizationManager.Instance.AddInitialSynchronization(targetRPC, SendInitialConfig); - // Encapsulated file watcher modification method for the config file - void FileModified(object sender, FileSystemEventArgs e) { - if (e.FullPath != baseCfgLocation || !File.Exists(baseCfgLocation)) { - return; + // Reads the file back into the live config. Shared by the file watcher and by the hot-reload + // pass, which cannot wait for the watcher (see ReloadBaseConfigsFromDisk). + bool ReloadFromDisk() { + if (!File.Exists(baseCfgLocation)) { + return false; } - EpicLoot.Log($"Config file {baseCfgLocation} {e.FullPath} has been modified, attempting to update config."); - - bool validUpdate = false; try { T contents = JsonConvert.DeserializeObject(File.ReadAllText(baseCfgLocation)); + if (contents == null) { + throw new InvalidDataException("file deserialized to null"); + } + EpicLoot.Log($"Config file {baseCfgLocation} has been modified, updating config."); setupMethod(contents); - validUpdate = true; } catch (Exception ex) { EpicLoot.LogWarningForce($"Config file {baseCfgLocation} is invalid and config will not be updated." + ex); - } - - if (validUpdate == false) { - return; + return false; } if (GUIManager.IsHeadless()) { @@ -861,6 +871,24 @@ void FileModified(object sender, FileSystemEventArgs e) { EpicLoot.LogError($"Error while server syncing {filename} configs"); } } + + return true; + } + + // Registered in call order, so the load-order dependencies InitializeConfig encodes + // (adventuredata before iteminfo, shardstones before shardstoneconversions) still hold on a + // hot reload. Thirteen independent watchers fire in whatever order the OS delivers them. + BaseConfigReloaders.RemoveAll(reloader => reloader.FileName == filename); + BaseConfigReloaders.Add((filename, ReloadFromDisk)); + + // Encapsulated file watcher modification method for the config file + void FileModified(object sender, FileSystemEventArgs e) { + if (e.FullPath != baseCfgLocation || !File.Exists(baseCfgLocation)) { + return; + } + + EpicLoot.Log($"Config file {baseCfgLocation} {e.FullPath} has been modified, attempting to update config."); + ReloadFromDisk(); } // Setup the file watcher for the config file. NotifyFilter must include FileName: @@ -918,6 +946,67 @@ public static void StartupProcessModifiedLocalizations() { } + /// + /// Re-reads baseconfig files into the live config, in registration order. + /// + /// + /// The files to reload, with extension ("loottables.json"). Null reloads every registered file; + /// an empty collection reloads none. + /// + internal static void ReloadBaseConfigsFromDisk(ICollection fileNames) { + foreach ((string fileName, Func reloadFromDisk) in BaseConfigReloaders) { + if (fileNames != null && !fileNames.Contains(fileName)) { + continue; + } + + reloadFromDisk(); + } + } + + /// + /// Rebuilds the configs from the patch files on disk and puts the result into the running game. + /// + /// The reload has to be driven from here rather than left to the per-file FileSystemWatchers. + /// Those events are asynchronous and are marshalled onto the main thread, so they cannot be + /// delivered until this callback returns -- which used to mean the auto-add pass below ran + /// against the pre-patch config still in memory and wrote it straight back over the files + /// FilePatching had just rebuilt. The patch survived on neither disk nor in memory, and only + /// took effect after a restart. + /// + internal static void RunPatchHotReload() { + List rebuiltTargets = FilePatching.ReloadAndApplyAllPatches(); + if (rebuiltTargets.Count == 0) { + // Nothing on disk changed -- a stray json in patches/, or a patch file that failed to + // parse. Re-deriving the auto-added items would just rewrite the configs unchanged. + // Forced: this is the "my patch did nothing" case, and every other breadcrumb on this + // path is Info, which the default Error log level hides. + EpicLoot.LogForce("Patch files changed, but no config file needed rebuilding. " + + "Check the log above for a patch file that failed to parse."); + return; + } + + EpicLoot.LogForce($"Patch files changed; rebuilt and reloaded {string.Join(", ", rebuiltTargets)}."); + HashSet rebuiltFiles = new HashSet(rebuiltTargets.Select(target => $"{target}.json")); + ReloadBaseConfigsFromDisk(rebuiltFiles); + + if (AutoAddEquipment.Value == false && AutoRemoveEquipmentNotFound.Value == false) { + return; + } + + // The scan classifies the ItemDrops currently loaded, so outside a world it finds nothing and + // writes configs stripped of every item. It runs again on the next world load regardless. + if (ZNetScene.instance == null) { + EpicLoot.LogForce("Patches were rebuilt, but the equipment auto-add pass needs a loaded " + + "world and was skipped; it runs again when you load one."); + return; + } + + AutoAddEnchantableItems.CheckAndAddAllEnchantableItems(false); + // The auto-add pass merges onto the live config and writes the result back out, so re-read + // the files it rewrote instead of waiting on their watchers. + ReloadBaseConfigsFromDisk(AutoAddEnchantableItems.RewrittenConfigFiles); + } + private static void IngestPatchFilesFromDisk(object s, FileSystemEventArgs e) { if (EnableHotReloadPatches.Value == false) { return; @@ -928,30 +1017,36 @@ private static void IngestPatchFilesFromDisk(object s, FileSystemEventArgs e) { return; } - // Do not process directories, setup a new watcher- otherwise they get ingored even with subdirectory watching. + // Directory events carry no patch content, and the watcher spans subdirectories itself. // Directory.Exists instead of File.GetAttributes: a Deleted event (or a Changed event racing // an atomic-save rename) arrives for a path that no longer exists, and the GetAttributes // throw was silently swallowed upstream -- the reload below then never ran. if (Directory.Exists(e.FullPath)) { - SetupPatchConfigFileWatch(e.FullPath); - EpicLoot.Log($"Adding subdirectory filewatcher: {e.FullPath}"); return; } - FileInfo fileInfo = new FileInfo(e.FullPath); - if (!fileInfo.FullName.Contains(".json")) { + // Match what ProcessPatchDirectory ingests (*.json). EndsWith, not Contains: the temp files + // an editor writes alongside an atomic save ("foo.json~", "foo.json.tmp") are not patches, + // and the rename to the real name raises its own event. + if (!e.FullPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) { return; } - EpicLoot.Log($"Processing patch file update: {fileInfo}"); - FilePatching.ReloadAndApplyAllPatches(); - - if (AutoAddEquipment.Value == true || AutoRemoveEquipmentNotFound.Value == true) { - AutoAddEnchantableItems.CheckAndAddAllEnchantableItems(false); - } + EpicLoot.Log($"Processing patch file update: {e.FullPath}"); + PatchReloadDebouncer.Schedule(); } + private static FileSystemWatcher _patchWatcher; + public static void SetupPatchConfigFileWatch(string path) { + // Replacing rather than stacking: a second watcher on the same tree would just double every + // event. The field also keeps the watcher rooted -- a collected one stops raising events. + if (_patchWatcher != null) { + _patchWatcher.EnableRaisingEvents = false; + _patchWatcher.Dispose(); + _patchWatcher = null; + } + FileSystemWatcher newPatchWatcher = new FileSystemWatcher(path); newPatchWatcher.Created += new FileSystemEventHandler(IngestPatchFilesFromDisk); newPatchWatcher.Changed += new FileSystemEventHandler(IngestPatchFilesFromDisk); @@ -960,10 +1055,13 @@ public static void SetupPatchConfigFileWatch(string path) { // FileName included so dropping in / deleting / renaming a patch file actually fires // (LastWrite alone only reports in-place content writes). newPatchWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName; - // newPatchWatcher.IncludeSubdirectories = true; + // ProcessPatchDirectory recurses, so patches shipped in patches// are loaded at + // startup; without this they were loaded but never watched, and only a subdirectory created + // while the game ran ever got a watcher of its own. + newPatchWatcher.IncludeSubdirectories = true; newPatchWatcher.SynchronizingObject = ThreadingHelper.SynchronizingObject; newPatchWatcher.EnableRaisingEvents = true; - // newPatchWatcher.Filter = "*.json"; + _patchWatcher = newPatchWatcher; } diff --git a/EpicLoot/src/GamePatches/ItemDrop_Patch_MagicItemTooltip.cs b/EpicLoot/src/GamePatches/ItemDrop_Patch_MagicItemTooltip.cs index 094f9c748..47aa06607 100755 --- a/EpicLoot/src/GamePatches/ItemDrop_Patch_MagicItemTooltip.cs +++ b/EpicLoot/src/GamePatches/ItemDrop_Patch_MagicItemTooltip.cs @@ -53,8 +53,12 @@ public static bool Prefix(ItemDrop.ItemData item, UITooltip tooltip, out string { __state = null; string tooltipText; + // ZInput.GetKey before HasEquipmentOfType: this prefix runs every frame the cursor rests on a + // slot, and HasEquipmentOfType walks the player's equipment through GetMagicEquipment, which + // allocates two lists and runs the registered equipment providers. Only the comparison + // tooltip needs that answer, and only while Ctrl is actually held. if (item.IsEquipable() && !item.m_equipped && Player.m_localPlayer != null && - Player.m_localPlayer.HasEquipmentOfType(item.m_shared.m_itemType) && ZInput.GetKey(KeyCode.LeftControl)) + ZInput.GetKey(KeyCode.LeftControl) && Player.m_localPlayer.HasEquipmentOfType(item.m_shared.m_itemType)) { ItemDrop.ItemData otherItem = Player.m_localPlayer.GetEquipmentOfType(item.m_shared.m_itemType); tooltipText = item.GetTooltip(); diff --git a/EpicLoot/src/Magic/AutoAddEnchantableItems.cs b/EpicLoot/src/Magic/AutoAddEnchantableItems.cs index 12bc946f1..1dd90fa76 100644 --- a/EpicLoot/src/Magic/AutoAddEnchantableItems.cs +++ b/EpicLoot/src/Magic/AutoAddEnchantableItems.cs @@ -51,6 +51,13 @@ public static AutoSorterConfiguration GetCFG() // scan -- and rewrite iteminfo/loottables/adventuredata on disk -- on every world join. public static readonly Action OnMapDataLoadedHandler = () => CheckAndAddAllEnchantableItems(); + /// + /// The baseconfig files this pass writes back out. A caller that needs the live config to + /// match disk without waiting on the file watchers reloads exactly these. + /// + public static readonly string[] RewrittenConfigFiles = + ["adventuredata.json", "iteminfo.json", "loottables.json"]; + public static void CheckAndAddAllEnchantableItems(bool deregister = true) { if (deregister) diff --git a/EpicLoot/src/Magic/EquipmentEffectCache.cs b/EpicLoot/src/Magic/EquipmentEffectCache.cs index f3679d410..99ff1e79f 100644 --- a/EpicLoot/src/Magic/EquipmentEffectCache.cs +++ b/EpicLoot/src/Magic/EquipmentEffectCache.cs @@ -1,4 +1,4 @@ -using HarmonyLib; +using HarmonyLib; using JetBrains.Annotations; using System; using System.Collections.Generic; @@ -11,6 +11,14 @@ public static class EquipmentEffectCache public static ConditionalWeakTable> EquippedValues = new ConditionalWeakTable>(); + // Mono's ConditionalWeakTable takes a lock and probes an ephemeron table on every lookup, and the + // hot readers (SEMan.ModifyMaxCarryWeight, SEMan.ModifyStaminaRegen, ItemData.GetArmor) each ask + // for a handful of effects per fixed tick, always for the same player. Hold on to the table that + // was looked up last so those runs cost a reference compare instead. Cleared by Reset, so this can + // never hand back a dictionary the weak table has already dropped. + private static Player _lastPlayer; + private static Dictionary _lastValues; + [HarmonyPatch(typeof(Humanoid), nameof(Humanoid.UnequipItem))] public static class EquipmentEffectCache_Humanoid_UnequipItem_Patch { @@ -39,22 +47,69 @@ public static void Prefix(Humanoid __instance) public static void Reset(Player player) { + if (player == null) + { + return; + } + EquippedValues.Remove(player); + + if (ReferenceEquals(player, _lastPlayer)) + { + _lastPlayer = null; + _lastValues = null; + } } - public static float? Get(Player player, string effect, Func calculate) + private static Dictionary ValuesFor(Player player) { - if (effect == null || player == null) + if (ReferenceEquals(player, _lastPlayer)) { - return 0f; // default fail out if the requested key is null + return _lastValues; } Dictionary values = EquippedValues.GetOrCreateValue(player); - if (values.TryGetValue(effect, out float? value)) + _lastPlayer = player; + _lastValues = values; + return values; + } + + /// + /// Memoized read that does not need the the overload takes. + /// That delegate is built on every call, hit or miss, because the argument is evaluated before the + /// lookup happens -- two heap allocations each time. Callers on per-tick paths use this and + /// instead, so only an actual miss pays for building the value. + /// + public static bool TryGetValue(Player player, string effect, out float? value) + { + if (effect == null || player == null) + { + value = 0f; // default fail out if the requested key is null + return true; + } + + return ValuesFor(player).TryGetValue(effect, out value); + } + + public static void Store(Player player, string effect, float? value) + { + if (effect == null || player == null) + { + return; + } + + ValuesFor(player)[effect] = value; + } + + public static float? Get(Player player, string effect, Func calculate) + { + if (TryGetValue(player, effect, out float? value)) { return value; } - return values[effect] = calculate(); + value = calculate(); + Store(player, effect, value); + return value; } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/AddCarryWeight.cs b/EpicLoot/src/Magic/MagicItemEffects/AddCarryWeight.cs index 16536e3bf..969701dc8 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/AddCarryWeight.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/AddCarryWeight.cs @@ -1,18 +1,14 @@ -using HarmonyLib; - namespace EpicLoot.MagicItemEffects { - [HarmonyPatch(typeof(SEMan), nameof(SEMan.ModifyMaxCarryWeight))] - public static class AddCarryWeight_SEMan_ModifyMaxCarryWeight_Patch + public static class AddCarryWeight { - public static void Postfix(SEMan __instance, ref float limit) + // ModifyMaxCarryWeight handler invoked by SharedSEManModifyMaxCarryWeightPatch. Unlike the shard + // handlers this applies to any Player, not just the local one -- that was the guard the original + // standalone patch used, and it is what lets a tooltip or a weight bar read against a player the + // local client does not control. + public static void ModifyMaxCarryWeight(Player player, ref float limit) { - if (__instance.m_character.IsPlayer()) - { - var player = __instance.m_character as Player; - var carryWeightBonus = player.GetTotalActiveMagicEffectValue(MagicEffectType.AddCarryWeight); - limit += carryWeightBonus; - } + limit += player.GetTotalActiveMagicEffectValue(MagicEffectType.AddCarryWeight); } } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Helpers/BloodBlockSelfDamage.cs b/EpicLoot/src/Magic/MagicItemEffects/Helpers/BloodBlockSelfDamage.cs index 4aed338fc..807aba051 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Helpers/BloodBlockSelfDamage.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Helpers/BloodBlockSelfDamage.cs @@ -6,8 +6,22 @@ namespace EpicLoot.src.Magic.MagicItemEffects.Helpers { // BloodStaggerBlock). Each shard keeps its own Humanoid.UpdateBlock postfix so carrying both costs // blood twice, but the trigger logic lives here once: fire on the first frame of a block // (m_blockTimer is -1 while idle and is set to 0 on the block's first UpdateBlock pass) and charge - // the local player 5% of max health as untyped true damage, with the shared hit/blood fx. + // the local player a share of max health as untyped true damage, with the shared hit/blood fx. public static class BloodBlockSelfDamage { + // Percent of max health charged per block start. Shared by both blood block shards, so it lives + // in the Global block of config/shardstones.json ("BloodBlockSelfDamagePercent") rather than on + // either effect. A plain static field because this sits on a per-frame UpdateBlock postfix; + // EffectConfig.ApplyGlobalConfig refreshes it once per config load. + public const float DefaultSelfDamagePercent = 5f; + public static float SelfDamagePercent = DefaultSelfDamagePercent; + + // Config setup hook, called from EffectConfig.ApplyGlobalConfig. Clamped to 0..100: a negative + // share would heal on block, and over 100% would one-shot the player on their first block. + public static void RefreshGlobalConfig() { + SelfDamagePercent = Mathf.Clamp( + EffectConfig.Global("BloodBlockSelfDamagePercent", DefaultSelfDamagePercent), 0f, 100f); + } + private static GameObject sfx = null; private static GameObject vfx = null; @@ -34,7 +48,8 @@ public static void OnBlockStart(Humanoid instance, string effectType) { hit.SetAttacker(player); // self dmg as player. I want to trigger on hit effects. // Can scrap if its too powerful or jank. I expect this effect to go under utilized. - hit.m_damage.m_damage = (player.GetMaxHealth() / 20f); // 5% hardcoded as true damage untyped dmg doesnt run through armor or known resistances + // True damage: untyped damage does not run through armor or any known resistance. + hit.m_damage.m_damage = player.GetMaxHealth() * (SelfDamagePercent / 100f); hit.m_staggerMultiplier = 0f; // addtions to validate hit diff --git a/EpicLoot/src/Magic/MagicItemEffects/Helpers/EffectConfig.cs b/EpicLoot/src/Magic/MagicItemEffects/Helpers/EffectConfig.cs new file mode 100644 index 000000000..47b67d3af --- /dev/null +++ b/EpicLoot/src/Magic/MagicItemEffects/Helpers/EffectConfig.cs @@ -0,0 +1,59 @@ +using EpicLoot.ShardStones; +using System.Collections.Generic; +using UnityEngine; + +namespace EpicLoot.src.Magic.MagicItemEffects.Helpers { + // Reads a shard effect's per-effect tunables -- the "Config" block authored on its grid entry in + // config/shardstones.json, merged over the code-side defaults registered in + // ShardEffectDefinitions.EffectConfigs. This is the shard-side equivalent of the "Config" block a + // magiceffects.json entry carries, and it resolves to the same place: the synthesized + // MagicItemEffectDefinition.Config, which is also what the Shift-detail tooltip renders. + // + // Every key needs a code default at its call site. A player's existing on-disk shardstones.json + // keeps winning until they accept the ConfigVersionManager rewrite prompt, so a key added to the + // embedded config is simply absent for them until then -- the fallback argument is what they run on. + // + // Hot-path rule (see CLAUDE.md, "Read the memoized effect value first"): Get costs two dictionary + // lookups. That is fine after the GetTotalActiveMagicEffectValue == 0 bail every effect already + // does, and not before it. Globals are the exception -- they feed 50Hz vanilla methods, so they are + // resolved once per config load into plain static fields rather than looked up per read. + public static class EffectConfig { + private static Dictionary _global = new Dictionary(); + + public static float Get(string effectType, string key, float fallback) { + var config = MagicItemEffectDefinitions.GetEffectConfig(effectType); + return config != null && config.TryGetValue(key, out var value) ? value : fallback; + } + + // Rounds rather than truncates, so an author writing 2.5 for a count gets 3 instead of 2. + public static int GetInt(string effectType, string key, int fallback) { + return Mathf.RoundToInt(Get(effectType, key, fallback)); + } + + // For counts where zero or negative would disable the effect outright rather than tune it -- + // stack caps, charge thresholds. A misconfiguration should weaken an effect, never delete it. + public static int GetIntAtLeast(string effectType, string key, int fallback, int min) { + return Mathf.Max(min, GetInt(effectType, key, fallback)); + } + + public static float GetClamped(string effectType, string key, float fallback, float min, float max) { + return Mathf.Clamp(Get(effectType, key, fallback), min, max); + } + + // Cross-effect tunables from the "Global" block. Prefer a static field refreshed by + // ApplyGlobalConfig over calling this per read; this overload exists for cold paths. + public static float Global(string key, float fallback) { + return _global.TryGetValue(key, out var value) ? value : fallback; + } + + // Called from Shards.InitializeShardDefinitions, so it runs on every load path the config file + // has: first load, embedded-default fallback, file-watcher hot reload, and the server->client + // RPC. Pushes the resolved values into the classes that own them, so their hot paths read a + // plain static field. + public static void ApplyGlobalConfig(ShardGlobalConfig config) { + _global = config?.Values ?? new Dictionary(); + PenaltyScaling.RefreshGlobalConfig(); + BloodBlockSelfDamage.RefreshGlobalConfig(); + } + } +} diff --git a/EpicLoot/src/Magic/MagicItemEffects/Helpers/PenaltyScaling.cs b/EpicLoot/src/Magic/MagicItemEffects/Helpers/PenaltyScaling.cs index 4811a3a08..6ef0a27be 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Helpers/PenaltyScaling.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Helpers/PenaltyScaling.cs @@ -1,4 +1,5 @@ -using UnityEngine; +using EpicLoot.Config; +using UnityEngine; namespace EpicLoot.src.Magic.MagicItemEffects.Helpers { // Helpers for scaling shard effects by how heavy the player's loadout is, as measured by their net movement penalty. @@ -6,7 +7,21 @@ public static class PenaltyScaling { // Net movement penalty (as a positive fraction of base speed) treated as "fully committed" to a // heavy loadout, i.e. where MovementPenaltyFactor reaches 1. ~20% speed loss is roughly a full set // of the heaviest armor. - public const float MovementPenaltyReference = 0.40f; + // + // Tunable as "MovementPenaltyReference" in the Global block of config/shardstones.json. It is a + // plain static field rather than a per-read config lookup because MovementPenaltyFactor sits under + // GetArmor / GetMaxCarryWeight / ModifyStaminaRegen, which run at 50Hz; EffectConfig.ApplyGlobalConfig + // refreshes it once per config load instead. Seven shards scale off it, so moving it retunes all of + // them together -- that is the point of it living in Global rather than on one effect. + public const float DefaultMovementPenaltyReference = 0.40f; + public static float MovementPenaltyReference = DefaultMovementPenaltyReference; + + // Config setup hook, called from EffectConfig.ApplyGlobalConfig. Clamped away from zero because + // MovementPenaltyFactor divides by this. + public static void RefreshGlobalConfig() { + MovementPenaltyReference = Mathf.Max(0.001f, + EffectConfig.Global("MovementPenaltyReference", DefaultMovementPenaltyReference)); + } // How loaded the player's pack is: 0 (empty) .. 1 (at or over the carry cap). public static float WeightFactor(Player player) { @@ -52,6 +67,21 @@ public static float CurrentSpeed(Player player) { return Mathf.Max(0f, speed); } + // One measurement per player per simulation step. CurrentSpeed runs the entire status-effect + // speed pipeline -- every status effect's ModifySpeed, plus the ApplyStatusEffectSpeedMods + // postfixes our own shards and other mods install -- and the effects that scale by the penalty + // sit on hot vanilla methods (GetMaxCarryWeight, GetArmor, GetDamage, ModifyStaminaRegen, + // RaiseSkill) that are reached several times within one step. Those repeats were re-deriving a + // number that could not have changed: every input CurrentSpeed reads (the equipment modifier + // array, the status effect list, m_currentVel) is fixed for the duration of a step. + // + // Keyed on the frame *and* the fixed-step time because FixedUpdate runs more than once per + // rendered frame below 50fps, and each of those physics steps is entitled to its own reading. + private static Player _penaltyPlayer; + private static int _penaltyFrame = -1; + private static float _penaltyFixedTime = float.NaN; + private static float _penaltyValue; + // The player's net movement-speed penalty as a positive fraction of their reference speed // (e.g. 0.15 == moving at 85% of base jog speed). A net speed gain reads as 0, not a negative. public static float MovementPenalty(Player player) { @@ -60,9 +90,40 @@ public static float MovementPenalty(Player player) { return 0f; } + var frame = Time.frameCount; + var fixedTime = Time.fixedTime; + var cached = _penaltyFrame == frame && _penaltyFixedTime == fixedTime + && ReferenceEquals(_penaltyPlayer, player); + + // Null-conditional because the config entry is bound in Awake; nothing should reach this + // before then, but a hot path is the wrong place to find out otherwise. + if (cached && ELConfig.VerifyPenaltyScalingCache?.Value != true) { + return _penaltyValue; + } + _measuringSpeed = true; try { - return Mathf.Clamp01(1f - CurrentSpeed(player) / reference); + var penalty = Mathf.Clamp01(1f - CurrentSpeed(player) / reference); + + if (cached) { + // Verification pass: the cached reading is still the one returned, so enabling this + // cannot change what the effects see -- it only reports when the two disagree. + if (!Mathf.Approximately(penalty, _penaltyValue)) { + EpicLoot.LogWarningForce( + $"PenaltyScaling: cached movement penalty {_penaltyValue} != recomputed {penalty} " + + $"(frame {frame}, fixedTime {fixedTime})"); + } + + return _penaltyValue; + } + + // Only recorded on the measured path -- a re-entrant call bails at the guard above with + // 0f, and that 0 must not be cached as this step's reading. + _penaltyPlayer = player; + _penaltyFrame = frame; + _penaltyFixedTime = fixedTime; + _penaltyValue = penalty; + return penalty; } finally { _measuringSpeed = false; } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Helpers/ShardEffectDefinitions.cs b/EpicLoot/src/Magic/MagicItemEffects/Helpers/ShardEffectDefinitions.cs index 0707064d5..f6e3345ac 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Helpers/ShardEffectDefinitions.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Helpers/ShardEffectDefinitions.cs @@ -1,5 +1,6 @@ -using EpicLoot.MagicItemEffects.Shards; +using EpicLoot.MagicItemEffects.Shards; using System.Collections.Generic; +using UnityEngine; namespace EpicLoot.Magic.MagicItemEffects.Helpers { // Registers a MagicItemEffectDefinition for every shard effect type that the loaded overhaul config does @@ -9,25 +10,35 @@ namespace EpicLoot.Magic.MagicItemEffects.Helpers { // itself comes from the shard (ShardSocketManager.ResolveSocketedEffect); these definitions supply the // display text/requirements and the value ranges used by the loose-shard preview and compendium. // - // Wired to OnSetupMagicItemEffectDefinitions (see EpicLoot.RegisterMagicEffectEvents) so it re-runs - // after every config (re)load, which clears and rebuilds AllDefinitions. ShardStones types are - // fully qualified because this namespace ends in ".Shards", which would otherwise shadow the + // Runs from two places, because either config file can reload independently: the + // OnSetupMagicItemEffectDefinitions event (magiceffects.json) and Shards.InitializeShardDefinitions + // (shardstones.json). It retracts its own previous output first, so re-running is safe -- and that is + // what makes a live edit or a server push of the shard grid take effect without a restart. ShardStones + // types are fully qualified because this namespace ends in ".Shards", which would otherwise shadow the // EpicLoot.ShardStones.Shards class. public static class ShardEffectDefinitions { - // Per-effect Config blocks for shard effects that read tunables from MagicItemEffectDefinition.Config - // (via MagicItemEffectDefinitions.GetEffectConfig). This mirrors the "Config" attribute a - // magiceffects.json entry would carry; it is supplied here because shard effects are defined in code - // rather than the overhaul config. Keys also surface in the detailed (Shift) tooltip -- see - // MagicItem.GetEffectText / MagicItemEffectDefinition.GetConfigLabel. + // Code-side defaults for the per-effect tunables. These are the fallback half of the merge in + // BuildConfig; the authored half is the "Config" block on the effect's grid entry in + // config/shardstones.json, which overlays these per key. + // + // Keeping a code default for every key is not optional: a player's existing on-disk + // shardstones.json keeps winning until they accept the ConfigVersionManager rewrite prompt, so a + // key that only exists in the embedded config is simply absent for them until then. + // + // Keys also surface in the detailed (Shift) tooltip -- see MagicItem.GetEffectDetailBlock and + // MagicItemEffectDefinition.GetConfigLabel -- so prefer a key name that already has a shared + // mod_epicloot_config_ label token over inventing a per-effect one. private static readonly Dictionary> EffectConfigs = new Dictionary> { - // Queen's Everflow (QueenEverflow): how many times the regen buff may stack. - { MagicEffectType.Everflow, new Dictionary { { "MaxStacks", QueenEverflow.DefaultMaxStacks } } }, - // Dodge Momentum (PerfectDodge): how many times the damage buff may stack. - { MagicEffectType.PerfectDodge, new Dictionary { { "MaxStacks", PerfectDodge.DefaultMaxStacks } } }, - // Lucky Fishing (LuckWhileFishing): the bonus-treasure table (prefab -> value threshold, - // same semantic as the Riches config) plus the triple-catch sub-roll chance. + // Queen's Everflow: how many times the regen buff may stack, and how long a stack survives. + { MagicEffectType.Everflow, QueenEverflow.DefaultConfig }, + // Dodge Momentum (PerfectDodge): how many times the damage buff may stack, and its duration. + { MagicEffectType.PerfectDodge, PerfectDodge.DefaultConfig }, + // Dodge Agility: duration of the post-dodge speed buff. + { MagicEffectType.PerfectDodgeGivesSpeed, PerfectDodgeGivesSpeed.DefaultConfig }, + // Lucky Fishing: the bonus-treasure table (prefab -> value threshold, same semantic as the + // Riches config), the triple-catch sub-roll chance, and the shape of the treasure roll. { MagicEffectType.LuckWhileFishing, LuckWhileFishing.DefaultConfig }, // The three coin-economy effects below are no longer assigned to any shard slot (Golden was // re-themed from coins to luck). BuildDefinition is only reached for effects the grid @@ -41,17 +52,72 @@ public static class ShardEffectDefinitions { { MagicEffectType.Coinplated, Coinplated.DefaultConfig }, // Wager (was Golden head): how much damage each wagered coin buys. { MagicEffectType.Wager, Wager.DefaultConfig }, - // Inspiration (Golden head): the percent chance that any single skill-XP gain inspires. + // Inspiration (Golden head): the percent chance that any single skill-XP gain inspires, + // which skills are eligible, and how the level-up walk is bounded. { MagicEffectType.Inspiration, Inspiration.DefaultConfig }, - // Lucky Loot (Golden chest): the range of extra magic-item table rolls a proc earns. + // Lucky Loot (Golden chest): the range of extra magic-item table rolls a proc earns, and + // the caps on how far the ordinary drop list may be multiplied. { MagicEffectType.LuckyLoot, LuckyLoot.DefaultConfig }, - // Bloodrage (DarkRed chest): how many times the damage buff may stack. - { MagicEffectType.Bloodrage, new Dictionary { { "MaxStacks", Bloodrage.DefaultMaxStacks } } }, - // Adrenaline Surge (AdrenalineIncreasesHealthRegen): seconds of buff granted per point of - // shard value, which is what turns the single rarity ramp into both a regen % and a duration. - { MagicEffectType.AdrenalineIncreasesHealthRegen, - new Dictionary - { { "SecondsPerPercent", AdrenalineIncreasesHealthRegen.DefaultSecondsPerPercent } } }, + // Bloodrage (DarkRed chest): how many times the damage buff may stack, and its duration. + { MagicEffectType.Bloodrage, Bloodrage.DefaultConfig }, + // Adrenaline Surge: seconds of buff granted per point of shard value, which is what turns + // the single rarity ramp into both a regen % and a duration. + { MagicEffectType.AdrenalineIncreasesHealthRegen, AdrenalineIncreasesHealthRegen.DefaultConfig }, + + // -------- Boss shard actives -------- + // Meteor: hits to charge, plus the launch geometry and impact radius. + { MagicEffectType.MeteorSummoner, MeteorSummoner.DefaultConfig }, + // Eikthyr's Shocking Charge: hits to charge, what share of the banked damage the cone + // deals, and the cone's reach and width. + { MagicEffectType.ShockingCharge, EikthyrShockingCharge.DefaultConfig }, + // Moder's Icy Retribution: the rarity-scaled cooldown, nova radius, and frost per value point. + { MagicEffectType.IcyRetribution, ModerIcyRetribution.DefaultConfig }, + // Bonemass' Corpse Rot: cooldown, burst radius, and poison per value point. + { MagicEffectType.CorpseRot, BonemassCorpseRot.DefaultConfig }, + // The Elder's Forest Aid: the value-scaled ensnare cooldown and radius. + { MagicEffectType.ForestsAid, ElderForestsAid.DefaultConfig }, + // Trailblazer: the burning trail's cadence, radius, and lifetime. + { MagicEffectType.Trailblazer, Trailblazer.DefaultConfig }, + // Adrenaline Frost Wave: search radius and how hard the chill bites. + { MagicEffectType.AdrenalineFrostWave, AdrenalineFrostWave.DefaultConfig }, + // Summon Bat: cooldown, lifetime, spawn ring, and how value maps to bat counts. + { MagicEffectType.SummonBatWhenActivatingAdrenaline, SummonBatWhenActivatingAdrenaline.DefaultConfig }, + // Strike Causes Lightning: damage per value point, and the strike's blast radius. + { MagicEffectType.StrikeCausesLightning, StrikeCausesLightning.DefaultConfig }, + + // -------- Accumulator / threshold effects -------- + // Conduit: lightning dealt per eitr payout. + { MagicEffectType.Conduit, Conduit.DefaultConfig }, + // Health per X damage done: damage banked per heal payout. + { MagicEffectType.HealthGainPerXDamageDone, HealthGainPerXDamageDone.DefaultConfig }, + // Health on eitr use: eitr spent per heal payout. + { MagicEffectType.HealthOnEitrUse, HealthOnEitrUse.DefaultConfig }, + // Kindling: fire damage taken per stamina payout. + { MagicEffectType.Kindling, Kindling.DefaultConfig }, + // Kills reduce next blood cost: the discount cap, and how long it stays banked. + { MagicEffectType.KillsReduceNextBloodCost, KillsReduceNextBloodCost.DefaultConfig }, + // Poison adrenaline pulse: cadence, and how far a poisoned foe still counts. + { MagicEffectType.GainAdrenalineWhenApplyingPoison, GainAdrenalineWhenApplyingPoison.DefaultConfig }, + // Storm Fury: cadence of the storm adrenaline pulse. + { MagicEffectType.StormFury, StormFury.DefaultConfig }, + // Running on Empty: cooldown between health-to-stamina charges. + { MagicEffectType.RunningOnEmpty, RunningOnEmpty.DefaultConfig }, + + // -------- Proc / scaling effects -------- + // Double-damage proc: the multiplier the proc applies. + { MagicEffectType.ChanceDoubleDamage, ChanceDoubleDamage.DefaultConfig }, + // ChanceToCritOnHit is declared and implemented but assigned to no grid slot and absent + // from the overhaul config, so like the three coin effects above this entry is dormant -- + // and keeping it is what makes the effect revivable with a single shardstones.json edit. + { MagicEffectType.ChanceToCritOnHit, ChanceToCritOnHit.DefaultConfig }, + // Blood Drinker: max health traded per value point, and the floor on the trade. + { MagicEffectType.BloodDrinker, BloodDrinker.DefaultConfig }, + // Travel Light: carry weight removed per value point, and the floor it clamps to. + { MagicEffectType.TravelLight, TravelLight.DefaultConfig }, + // Burdened Block: the carry weight the bonus starts at, and weight per bonus step. + { MagicEffectType.BurdenedBlock, BurdenedBlock.DefaultConfig }, + // Eitr Imbue: eitr paid per point of bonus spirit damage. + { MagicEffectType.EitrImbueAttack, EitrImbueAttack.DefaultConfig }, }; // Effects that need an adrenaline pool but whose type name does not contain "Adrenaline", which is @@ -61,33 +127,123 @@ public static class ShardEffectDefinitions { MagicEffectType.StormFury, }; + // Definitions this class created, so a rebuild can retract its own previous output. Reference + // equality is what makes retraction safe: MagicItemEffectDefinitions.Initialize clears + // AllDefinitions wholesale, and API.AddMagicEffect may have replaced an entry since, so a type we + // registered last time is only ours to remove if the object under it is still the one we added. + private static readonly Dictionary Synthesized = + new Dictionary(); + + // Definitions owned by someone else (the overhaul config, API.AddMagicEffect) whose Config we + // overlaid grid keys onto, paired with the Config they carried before we touched it, so the + // overlay can be lifted and reapplied rather than accumulating across reloads. + private static readonly List Overlays = new List(); + + private class OverlayRecord { + public MagicItemEffectDefinition Definition; + public Dictionary OriginalConfig; + } + public static void RegisterShardEffectDefinitions() { + RetractPreviousOutput(); + foreach (var pair in CollectShardEffects()) { - if (MagicItemEffectDefinitions.AllDefinitions.ContainsKey(pair.Key)) { - continue; // already defined by the overhaul config or another source + if (MagicItemEffectDefinitions.AllDefinitions.TryGetValue(pair.Key, out var existing) && + existing != null) { + // Already defined by the overhaul config or another source, so that definition wins -- + // but the grid entry's Config still has to mean something, or authoring one on a shared + // effect like LifeGainOnHit would silently do nothing. Overlay just those keys. + ApplyConfigOverlay(existing, pair.Value); + continue; } - MagicItemEffectDefinitions.Add(BuildDefinition(pair.Key, pair.Value)); + var definition = BuildDefinition(pair.Key, pair.Value); + MagicItemEffectDefinitions.Add(definition); + Synthesized[pair.Key] = definition; + } + } + + // Undoes the last run so this one rebuilds from the current grid instead of no-opping on the + // "already defined" check. Anything that is no longer ours is left alone. + private static void RetractPreviousOutput() { + foreach (var pair in Synthesized) { + if (MagicItemEffectDefinitions.AllDefinitions.TryGetValue(pair.Key, out var current) && + ReferenceEquals(current, pair.Value)) { + MagicItemEffectDefinitions.AllDefinitions.Remove(pair.Key); + } + } + Synthesized.Clear(); + + foreach (var overlay in Overlays) { + // If magiceffects.json reloaded in between, this definition object is gone and its + // replacement already carries a pristine Config -- nothing to restore. + if (MagicItemEffectDefinitions.AllDefinitions.TryGetValue(overlay.Definition.Type, out var current) && + ReferenceEquals(current, overlay.Definition)) { + overlay.Definition.Config = overlay.OriginalConfig; + } + } + Overlays.Clear(); + } + + private static void ApplyConfigOverlay(MagicItemEffectDefinition definition, + global::EpicLoot.ShardStones.ShardEffectDefinition shardEffect) { + if (shardEffect.Config == null || shardEffect.Config.Count == 0) { + return; + } + + Overlays.Add(new OverlayRecord { + Definition = definition, + OriginalConfig = definition.Config, + }); + + var merged = definition.Config != null + ? new Dictionary(definition.Config) + : new Dictionary(); + foreach (var entry in shardEffect.Config) { + merged[entry.Key] = entry.Value; } + definition.Config = merged; + + EpicLoot.Log($"Shard grid overlaid {shardEffect.Config.Count} Config key(s) onto the existing " + + $"'{definition.Type}' enchantment definition."); } - // Every effect type used by any shard, mapped to its per-rarity value ramp. Effects are globally - // unique across shards, so first occurrence wins. - private static Dictionary> CollectShardEffects() { - var result = new Dictionary>(); + // Every effect type used by any shard, mapped to the grid entry that declares it. Effects are + // globally unique across shards, so first occurrence wins -- but an effect assigned to several + // slots is authored once per slot, so a later copy carrying a different Config is a silent + // divergence worth naming. + private static Dictionary CollectShardEffects() { + var result = new Dictionary(); + var sources = new Dictionary(); + + void Consider(global::EpicLoot.ShardStones.ShardEffectDefinition effect, string source) { + if (effect == null || string.IsNullOrEmpty(effect.EffectType)) { + return; + } + + if (!result.ContainsKey(effect.EffectType)) { + result[effect.EffectType] = effect; + sources[effect.EffectType] = source; + return; + } - void Consider(ShardStones.ShardEffectDefinition effect) { - if (effect != null && !string.IsNullOrEmpty(effect.EffectType) && - !result.ContainsKey(effect.EffectType)) { - result[effect.EffectType] = effect.ValuesPerRarity; + if (!SameConfig(result[effect.EffectType].Config, effect.Config)) { + EpicLoot.LogWarning($"Shard effect '{effect.EffectType}' is declared with conflicting " + + $"Config blocks: {sources[effect.EffectType]} wins, {source} is ignored. Give every " + + "slot that declares this effect the same Config, or leave the duplicates empty."); } } - foreach (var shard in global::EpicLoot.ShardStones.Shards.ShardDefinitions.ShardEffects.Values) { - Consider(shard.UniformEffect); + foreach (var shardPair in global::EpicLoot.ShardStones.Shards.ShardDefinitions.ShardEffects) { + var shard = shardPair.Value; + if (shard == null) { + continue; + } + + Consider(shard.UniformEffect, $"{shardPair.Key}/Uniform"); if (shard.TypeEffects != null) { - foreach (var effect in shard.TypeEffects.Values) { - Consider(effect); + foreach (var slotPair in shard.TypeEffects) { + Consider(slotPair.Value, $"{shardPair.Key}/{slotPair.Key}"); } } } @@ -95,8 +251,26 @@ void Consider(ShardStones.ShardEffectDefinition effect) { return result; } + private static bool SameConfig(Dictionary a, Dictionary b) { + var countA = a?.Count ?? 0; + var countB = b?.Count ?? 0; + if (countA != countB) { + return false; + } + if (countA == 0) { + return true; + } + + foreach (var entry in a) { + if (!b.TryGetValue(entry.Key, out var other) || !Mathf.Approximately(entry.Value, other)) { + return false; + } + } + return true; + } + private static MagicItemEffectDefinition BuildDefinition(string type, - Dictionary valuesPerRarity) { + global::EpicLoot.ShardStones.ShardEffectDefinition shardEffect) { var lower = type.ToLowerInvariant(); var requirements = new MagicItemEffectRequirements { NoRoll = true }; @@ -112,16 +286,31 @@ private static MagicItemEffectDefinition BuildDefinition(string type, DisplayText = $"$mod_epicloot_me_{lower}_display", Description = $"$mod_epicloot_me_{lower}_desc", Requirements = requirements, - ValuesPerRarity = BuildValues(valuesPerRarity), - Config = EffectConfigs.TryGetValue(type, out var config) - ? new Dictionary(config) - : new Dictionary(), + ValuesPerRarity = BuildValues(shardEffect.ValuesPerRarity), + Config = BuildConfig(type, shardEffect.Config), CanBeAugmented = false, CanBeDisenchanted = false, CanBeRunified = false, }; } + // Code defaults first, the grid entry's authored keys overlaid on top. Merging rather than + // replacing is what lets a partial "Config" block retune one knob without blanking the rest -- + // and it is why LuckWhileFishing's treasure table survives someone overriding only TripleChance. + private static Dictionary BuildConfig(string type, Dictionary authored) { + var merged = EffectConfigs.TryGetValue(type, out var defaults) + ? new Dictionary(defaults) + : new Dictionary(); + + if (authored != null) { + foreach (var entry in authored) { + merged[entry.Key] = entry.Value; + } + } + + return merged; + } + private static MagicItemEffectDefinition.ValuesPerRarityDef BuildValues( Dictionary valuesPerRarity) { return new MagicItemEffectDefinition.ValuesPerRarityDef { diff --git a/EpicLoot/src/Magic/MagicItemEffects/Helpers/SharedSEManModifyMaxCarryWeightPatch.cs b/EpicLoot/src/Magic/MagicItemEffects/Helpers/SharedSEManModifyMaxCarryWeightPatch.cs new file mode 100644 index 000000000..44e32a3b3 --- /dev/null +++ b/EpicLoot/src/Magic/MagicItemEffects/Helpers/SharedSEManModifyMaxCarryWeightPatch.cs @@ -0,0 +1,45 @@ +using EpicLoot.MagicItemEffects; +using EpicLoot.MagicItemEffects.Shards; +using HarmonyLib; +using JetBrains.Annotations; + +namespace EpicLoot.src.Magic.MagicItemEffects.Helpers { + // Single consolidated Harmony patch for SEMan.ModifyMaxCarryWeight, replacing the five individual + // [HarmonyPatch] classes each carry-weight effect used to declare on this same method. + // + // This is one of the hottest vanilla methods the mod touches. Player.FixedUpdate -> UpdateStats -> + // IsEncumbered -> GetMaxCarryWeight reaches it every fixed tick (50Hz), the inventory weight bar + // reaches it every frame the inventory is open, and PenaltyScaling.WeightFactor reaches it again + // from the per-tick stamina regen path. So the guards are ordered cheapest-first and every handler + // reads its memoized effect value before doing any work that is not a field compare. + // + // Order is load-bearing at one point: TravelLight runs last. It clamps the running total against a + // floor rather than adding to it, so it has to see the bonuses the other four contributed. As five + // separate postfixes that ordering was whatever Harmony happened to pick. + [HarmonyPatch(typeof(SEMan), nameof(SEMan.ModifyMaxCarryWeight))] + internal static class SharedSEManModifyMaxCarryWeightPatch { + + [HarmonyPostfix] + [UsedImplicitly] + private static void Postfix(SEMan __instance, float baseLimit, ref float limit) { + if (!(__instance.m_character is Player player)) { + return; + } + + AddCarryWeight.ModifyMaxCarryWeight(player, ref limit); + + // The shard effects below read the local player's own equipment, which is not replicated for + // remote players, so they are gated to the local player exactly as their own patches were. + if (player != Player.m_localPlayer) { + return; + } + + NightCarryWeight.ModifyMaxCarryWeight(player, baseLimit, ref limit); + GainMaxCarryWeightFromRested.ModifyMaxCarryWeight(player, __instance, ref limit); + CarryWeightForMovementPenalty.ModifyMaxCarryWeight(player, baseLimit, ref limit); + + // Last -- see the note above. + TravelLight.ModifyMaxCarryWeight(player, ref limit); + } + } +} diff --git a/EpicLoot/src/Magic/MagicItemEffects/Paralyze.cs b/EpicLoot/src/Magic/MagicItemEffects/Paralyze.cs index dd8627dfb..db0bb7a61 100755 --- a/EpicLoot/src/Magic/MagicItemEffects/Paralyze.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Paralyze.cs @@ -49,7 +49,10 @@ private static void Postfix(Character __instance) // check must happen here; the SE itself is applied on the target's owner via the RPC below. public static void OnDamaged(Character __instance, HitData hit, Character attacker) { + // IsValid() (not just a null check): the target can already be dead and destroyed by the time this + // postfix runs -- ZNetScene.Destroy nulls the ZDO immediately, so InvokeRPC below would NRE. if (hit == null || __instance == null || __instance.m_nview == null + || !__instance.m_nview.IsValid() || attacker != Player.m_localPlayer || hit.m_damage.EpicLootGetTotalDamage() <= 0.0) { diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/AdrenalineFrostWave.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/AdrenalineFrostWave.cs index 573955e95..aa2860739 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/AdrenalineFrostWave.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/AdrenalineFrostWave.cs @@ -1,32 +1,57 @@ using EpicLoot.src.Magic.MagicItemEffects.Helpers; using HarmonyLib; using JetBrains.Annotations; +using System.Collections.Generic; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { // On adrenaline activation, applies a frost wave to nearby enemies. The wave slows movement by 60% at application and eases back to normal over the duration, following vanilla's frost curve. public static class AdrenalineFrostWave { - private const float Radius = 10f; + // Enemy search radius, and the speed floor while chilled -- movement drops to this fraction at + // application and eases back to normal over the duration, following vanilla's frost curve. Both + // tunable in this effect's Config block in config/shardstones.json, under these key names. + public const float DefaultRadius = 10f; + public const float DefaultSpeedFloor = 0.4f; + + private const string RadiusKey = "Radius"; + private const string SpeedFloorKey = "SpeedFloor"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { RadiusKey, DefaultRadius }, + { SpeedFloorKey, DefaultSpeedFloor }, + }; + + private static float GetRadius() { + return Mathf.Max(0f, + EffectConfig.Get(MagicEffectType.AdrenalineFrostWave, RadiusKey, DefaultRadius)); + } + + // Clamped to 0..1: above 1 the "slow" would be a speed boost, below 0 it would invert movement. + private static float GetSpeedFloor() { + return EffectConfig.GetClamped(MagicEffectType.AdrenalineFrostWave, + SpeedFloorKey, DefaultSpeedFloor, 0f, 1f); + } + private const string RpcKey = "EL_FrostWave"; // Unity object name of the SE prototype -- NameHash() hashes this (GetStableHashCode), so it must be // identical on every client for the add/refresh lookup to line up. private const string SeName = "EL_FrostWave"; - // Speed floor while chilled: movement drops to 40% (a 60% slow) at application and eases back to - // normal over the duration, following vanilla's frost curve. - private const float SpeedFloor = 0.4f; - private const float DefaultDuration = 2f; + // Seed ttl for the prototype only; the per-cast duration comes from the shard value and is stamped + // on the instance, so this is not a balance knob and is not exposed in the effect Config. + private const float PrototypeSeedDuration = 2f; private const string NovaTemplateName = "EL_FrostWaveNova"; private const float NovaSpeed = 1.5f; private static SE_Frost _prototype; private static bool _prototypeMissingLogged; - // Tooltip: "Frost Wave: Slow Enemies within {1}m for {0}s" -- {1} surfaces the radius from the const. + // Tooltip: "Frost Wave: Slow Enemies within {1}m for {0}s" -- {1} is the configured radius, so the + // shown number follows a retune instead of the baked-in default. public static void RegisterDisplayValues() { MagicItem.RegisterDisplayValues(MagicEffectType.AdrenalineFrostWave, - value => new object[] { value, Radius }); + value => new object[] { value, GetRadius() }); } // Registers the chill RPC on every character so a remote-owned target can receive it. Mirrors @@ -52,7 +77,8 @@ public static void OnAdrenalineActivated(Player player) { FrostNovaFx.Spawn(NovaTemplateName, novaPosition, NovaSpeed); var center = player.transform.position; - var radiusSqr = Radius * Radius; + var radius = GetRadius(); + var radiusSqr = radius * radius; foreach (var character in Character.GetAllCharacters()) { if (character == null || character.IsPlayer() || character.IsTamed() || character.IsDead()) { @@ -84,9 +110,14 @@ private static void RPC_FrostWave(Character character, float duration) { return; } + // The speed floor is stamped per instance rather than left on the cached prototype, so a retune + // reaches chills applied from here on without waiting for a new game session. + var speedFloor = GetSpeedFloor(); + // Already chilled: extend to the longer of the two and restart the decay curve. if (character.m_seman.GetStatusEffect(prototype.NameHash()) is SE_Frost existing) { existing.m_ttl = Mathf.Max(duration, existing.GetRemaningTime()); + existing.m_minSpeedFactor = speedFloor; existing.ResetTime(); return; } @@ -95,6 +126,7 @@ private static void RPC_FrostWave(Character character, float duration) { // start effects, then we stamp this cast's duration onto the added instance. if (character.m_seman.AddStatusEffect(prototype) is SE_Frost added) { added.m_ttl = duration; + added.m_minSpeedFactor = speedFloor; added.ResetTime(); } } @@ -120,8 +152,8 @@ private static SE_Frost GetOrCreatePrototype() { var wave = ScriptableObject.CreateInstance(); Common.Utils.CopyFields(frost, wave, typeof(SE_Frost)); wave.name = SeName; - wave.m_ttl = DefaultDuration; - wave.m_minSpeedFactor = SpeedFloor; + wave.m_ttl = PrototypeSeedDuration; + wave.m_minSpeedFactor = GetSpeedFloor(); _prototype = wave; return _prototype; diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/AdrenalineIncreasesHealthRegen.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/AdrenalineIncreasesHealthRegen.cs index 9cbe38993..a87d47166 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/AdrenalineIncreasesHealthRegen.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/AdrenalineIncreasesHealthRegen.cs @@ -1,13 +1,21 @@ using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using System.Collections.Generic; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { // Provides a temporary health regeneration buff on adrenaline activation public static class AdrenalineIncreasesHealthRegen { - // Seconds of buff granted per 1 point of shard value. Overridable via the effect's Config block - // ("SecondsPerPercent", see ShardEffectDefinitions). + // Seconds of buff granted per 1 point of shard value -- what turns the single rarity ramp into both + // a regen percentage and a duration. Tunable as "SecondsPerPercent" in this effect's Config block in + // config/shardstones.json. public const float DefaultSecondsPerPercent = 0.5f; + private const string SecondsPerPercentKey = "SecondsPerPercent"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { SecondsPerPercentKey, DefaultSecondsPerPercent }, + }; + private const string BuffName = "EL_AdrenalineSurge"; private static readonly int BuffHash = BuffName.GetStableHashCode(); private static SE_AdrenalineSurge _buffPrototype; @@ -52,12 +60,11 @@ public static void OnAdrenalineActivated(Player player) { } } + // Floored just above zero: the derived value is the buff's ttl, and a ttl of 0 is "no timeout" to + // vanilla, which would make the surge permanent. private static float GetSecondsPerPercent() { - var cfg = MagicItemEffectDefinitions.GetEffectConfig(MagicEffectType.AdrenalineIncreasesHealthRegen); - if (cfg != null && cfg.TryGetValue("SecondsPerPercent", out var raw) && raw > 0f) { - return raw; - } - return DefaultSecondsPerPercent; + return Mathf.Max(0.001f, EffectConfig.Get(MagicEffectType.AdrenalineIncreasesHealthRegen, + SecondsPerPercentKey, DefaultSecondsPerPercent)); } private static SE_AdrenalineSurge GetOrCreatePrototype() { diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/AnchoredBlock.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/AnchoredBlock.cs index 681e4802f..c27d596be 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/AnchoredBlock.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/AnchoredBlock.cs @@ -17,7 +17,13 @@ public static void Apply(ItemDrop.ItemData __instance, ref float baseBlock) return; } - float penaltyBonusBlock = player.GetTotalActiveMagicEffectValue(MagicEffectType.AnchoredBlock, 1) * + float perPenaltyPoint = player.GetTotalActiveMagicEffectValue(MagicEffectType.AnchoredBlock, 1); + if (perPenaltyPoint == 0f) + { + return; + } + + float penaltyBonusBlock = perPenaltyPoint * (PenaltyScaling.MovementPenalty(player) * 100); // Movement Penalty returns .01f positive per -% movement speed. // -25% movement speed is .25f so we * 100 to get a round increment per // AnchoredBlock value are now decimals .25-1.25 diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/ArmorFromMovementPenalty.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/ArmorFromMovementPenalty.cs index c3d58dd7e..410e3851d 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/ArmorFromMovementPenalty.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/ArmorFromMovementPenalty.cs @@ -14,8 +14,15 @@ private static void Postfix(ItemDrop.ItemData __instance, ref float __result) { return; } - var bonus = player.GetTotalActiveMagicEffectValue(MagicEffectType.ArmorFromMovementPenalty, 0.01f) - * PenaltyScaling.MovementPenaltyFactor(player); + // GetArmor is hot -- every equipped piece, on every damage calculation and every HUD + // refresh -- and measuring the penalty runs the whole status-effect speed pipeline, so + // it stays behind the memoized effect lookup. + var pct = player.GetTotalActiveMagicEffectValue(MagicEffectType.ArmorFromMovementPenalty, 0.01f); + if (pct == 0f) { + return; + } + + var bonus = pct * PenaltyScaling.MovementPenaltyFactor(player); if (bonus != 0f) { __result *= 1f + bonus; } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/BloodDrinker.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/BloodDrinker.cs index 7a903d570..f7c6881e6 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/BloodDrinker.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/BloodDrinker.cs @@ -1,5 +1,7 @@ using EpicLoot.General; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; using HarmonyLib; +using System.Collections.Generic; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { @@ -7,20 +9,42 @@ namespace EpicLoot.MagicItemEffects.Shards { public static class BloodDrinker { // Max health removed, as a percent of the health pool, per 1 point of shard value. With the // shard's 3/6/9/12/15 values this yields -7.5%/-15%/-22.5%/-30%/-37.5% across Magic..Mythic. - private const float MaxHealthPercentPerValue = 2.5f; + // Tunable as "MaxHealthPercentPerValue" in this effect's Config block in config/shardstones.json. + public const float DefaultMaxHealthPercentPerValue = 2.5f; // Floor on the amount removed. A pure percentage is negligible on a low-food character (37.5% of // the vanilla 25 base pool is ~9), so the cost never drops below this. - private const float MinHealthReduction = 10f; + public const float DefaultMinHealthReduction = 10f; - // Absolute backstop so a degenerate pool can't be reduced to a zero/negative max health. + // Absolute backstop so a degenerate pool can't be reduced to a zero/negative max health. Not + // configurable: it exists to keep a misconfiguration from bricking the character, so exposing it + // would defeat the purpose. private const float MinResultingMaxHealth = 1f; - // Tooltip: "-{1}% Max Health, +{0}% Lifesteal" -- {1} is derived from the rolled value so the - // shown cost stays in sync with the code rather than a baked-in literal. + private const string MaxHealthPercentPerValueKey = "MaxHealthPercentPerValue"; + private const string MinHealthReductionKey = "MinHealthReduction"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { MaxHealthPercentPerValueKey, DefaultMaxHealthPercentPerValue }, + { MinHealthReductionKey, DefaultMinHealthReduction }, + }; + + // Both floored at zero: a negative cost would hand out max health instead of trading it away. + private static float GetMaxHealthPercentPerValue() { + return Mathf.Max(0f, EffectConfig.Get(MagicEffectType.BloodDrinker, + MaxHealthPercentPerValueKey, DefaultMaxHealthPercentPerValue)); + } + + private static float GetMinHealthReduction() { + return Mathf.Max(0f, EffectConfig.Get(MagicEffectType.BloodDrinker, + MinHealthReductionKey, DefaultMinHealthReduction)); + } + + // Tooltip: "-{1}% Max Health, +{0}% Lifesteal" -- {1} is derived from the rolled value and the + // configured rate, so the shown cost follows a retune instead of the baked-in default. public static void RegisterDisplayValues() { MagicItem.RegisterDisplayValues(MagicEffectType.BloodDrinker, - value => new object[] { value, value * MaxHealthPercentPerValue }); + value => new object[] { value, value * GetMaxHealthPercentPerValue() }); } private static void ApplyMaxHealthReduction(Player player, ref float hp) { @@ -28,14 +52,20 @@ private static void ApplyMaxHealthReduction(Player player, ref float hp) { return; } + // Read the memoized effect value before touching config: this hangs off the max-health + // pipeline, and most players carry no BloodDrinker shard at all. + var value = player.GetTotalActiveMagicEffectValue(MagicEffectType.BloodDrinker); + if (value <= 0f) { + return; + } + // Clamped so stacked sources can't reach a >=100% reduction. - var percent = Mathf.Clamp01(player.GetTotalActiveMagicEffectValue( - MagicEffectType.BloodDrinker, MaxHealthPercentPerValue * 0.01f)); + var percent = Mathf.Clamp01(value * GetMaxHealthPercentPerValue() * 0.01f); if (percent <= 0f) { return; } - var reduction = Mathf.Max(hp * percent, MinHealthReduction); + var reduction = Mathf.Max(hp * percent, GetMinHealthReduction()); hp = Mathf.Max(hp - reduction, MinResultingMaxHealth); } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/Bloodrage.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/Bloodrage.cs index c0e94bf92..0b2d32d50 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/Bloodrage.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/Bloodrage.cs @@ -1,5 +1,6 @@ using EpicLoot.General; using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using System.Collections.Generic; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { @@ -10,8 +11,18 @@ namespace EpicLoot.MagicItemEffects.Shards { // Mythic (5) tops out at +25% damage. The bonus is applied by SE_Bloodrage's own ModifyAttack // override, so vanilla drives it off the live stack count on every swing. public static class Bloodrage { + // How many hits taken the rage may stack to, and how long a stack survives without a refresh. + // Tunable as "MaxStacks" and "BuffDuration" in this effect's Config block in config/shardstones.json. public const int DefaultMaxStacks = 5; - private const float BuffDuration = 10f; // seconds the buff lasts / is refreshed to on each hit taken + public const float DefaultBuffDuration = 10f; + + private const string MaxStacksKey = "MaxStacks"; + private const string BuffDurationKey = "BuffDuration"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { MaxStacksKey, DefaultMaxStacks }, + { BuffDurationKey, DefaultBuffDuration }, + }; private const string BuffName = "EL_Bloodrage"; private static readonly int BuffHash = BuffName.GetStableHashCode(); @@ -52,6 +63,7 @@ public static void OnDamageTaken(Character __instance, HitData hit) { } var maxStacks = GetMaxStacks(); + var duration = GetBuffDuration(); var seMan = Player.m_localPlayer.GetSEMan(); // Re-proc while the buff is still up: add a stack (capped), restamp the per-stack bonus (the @@ -60,6 +72,7 @@ public static void OnDamageTaken(Character __instance, HitData hit) { existing.Stacks = Mathf.Min(existing.Stacks + 1, maxStacks); existing.MaxStacks = maxStacks; existing.DamagePerStack = perStack; + existing.m_ttl = duration; // restamped so a retuned duration reaches a buff already running existing.ResetTime(); return; } @@ -68,20 +81,20 @@ public static void OnDamageTaken(Character __instance, HitData hit) { added.Stacks = 1; added.MaxStacks = maxStacks; added.DamagePerStack = perStack; - added.m_ttl = BuffDuration; + added.m_ttl = duration; added.ResetTime(); } } - // Max stacks come from the Bloodrage magic effect's Config block ("MaxStacks", see - // ShardEffectDefinitions), defaulting to DefaultMaxStacks when unset. Clamped to at least 1 so a - // misconfiguration can't disable the buff. + // Clamped to at least 1 so a misconfiguration can't disable the buff outright. private static int GetMaxStacks() { - var cfg = MagicItemEffectDefinitions.GetEffectConfig(MagicEffectType.Bloodrage); - if (cfg != null && cfg.TryGetValue("MaxStacks", out var raw)) { - return Mathf.Max(1, Mathf.RoundToInt(raw)); - } - return DefaultMaxStacks; + return EffectConfig.GetIntAtLeast(MagicEffectType.Bloodrage, MaxStacksKey, DefaultMaxStacks, 1); + } + + // Floored just above zero: a ttl of 0 is "no timeout" to vanilla, which would make the rage permanent. + private static float GetBuffDuration() { + return Mathf.Max(0.1f, + EffectConfig.Get(MagicEffectType.Bloodrage, BuffDurationKey, DefaultBuffDuration)); } // Lazily builds the buff prototype. Runs on a hit taken, so the asset bundle is loaded. A null @@ -106,7 +119,7 @@ private static SE_Bloodrage GetOrCreatePrototype() { se.name = BuffName; se.m_name = "$mod_epicloot_se_bloodrage"; se.m_icon = icon; - se.m_ttl = BuffDuration; + se.m_ttl = GetBuffDuration(); // restamped on every proc by ApplyOrStack _buffPrototype = se; return _buffPrototype; } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/BonemassCorpseRot.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/BonemassCorpseRot.cs index d994ec76e..e8a741248 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/BonemassCorpseRot.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/BonemassCorpseRot.cs @@ -1,14 +1,36 @@ using EpicLoot.src.Magic.MagicItemEffects.Helpers; using HarmonyLib; using JetBrains.Annotations; +using System.Collections.Generic; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { // Detonates a poison cloud on enemies the local player kills public static class BonemassCorpseRot { - private const float Cooldown = 5f; - private const float CorpseRadius = 2f; - private const float PoisonPerTier = 20f; // 20 poison damage per point of shard value (5..25 -> 100..500) + // Seconds between detonations, the burst radius, and the poison dealt per point of shard value + // (20 -> 5..25 becomes 100..500). All tunable in this effect's Config block in + // config/shardstones.json, under these key names. + public const float DefaultCooldown = 5f; + public const float DefaultRadius = 2f; + public const float DefaultPoisonPerTier = 20f; + + private const string CooldownKey = "Cooldown"; + private const string RadiusKey = "Radius"; + private const string PoisonPerTierKey = "PoisonPerTier"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { CooldownKey, DefaultCooldown }, + { RadiusKey, DefaultRadius }, + { PoisonPerTierKey, DefaultPoisonPerTier }, + }; + + // Floored just above zero: a ttl of 0 is "no timeout" to vanilla, which would gate the shard + // permanently rather than removing the cooldown. + private static float GetCooldown() { + return Mathf.Max(0.1f, + EffectConfig.Get(MagicEffectType.CorpseRot, CooldownKey, DefaultCooldown)); + } + private const string ExplosionFx = "vfx_BombBlob_explode_poison"; private static bool _fxMissingLogged; @@ -43,8 +65,12 @@ private static void Postfix(Character __instance) { var center = __instance.GetCenterPoint(); SpawnExplosionFx(__instance.transform.position); - DamageInRadius.DamageEnemiesInRadius(player, center, CorpseRadius, - new HitData.DamageTypes { m_poison = value * PoisonPerTier }); + DamageInRadius.DamageEnemiesInRadius(player, center, + EffectConfig.Get(MagicEffectType.CorpseRot, RadiusKey, DefaultRadius), + new HitData.DamageTypes { + m_poison = value * EffectConfig.Get(MagicEffectType.CorpseRot, + PoisonPerTierKey, DefaultPoisonPerTier) + }); ShowCooldown(player); } } @@ -62,9 +88,12 @@ private static void SpawnExplosionFx(Vector3 position) { Object.Instantiate(prefab, position, Quaternion.identity); } + // The ttl is stamped here rather than at construction because the prototype is built once and + // cached, so a retuned cooldown would otherwise not take hold until the next game session. private static void ShowCooldown(Player player) { var indicator = GetOrCreateCooldownIndicator(); if (indicator != null) { + indicator.m_ttl = GetCooldown(); player.GetSEMan().AddStatusEffect(indicator, true); } } @@ -88,7 +117,7 @@ private static StatusEffect GetOrCreateCooldownIndicator() { se.name = CooldownName; se.m_name = "$mod_epicloot_se_corpserot"; se.m_icon = icon; - se.m_ttl = Cooldown; + se.m_ttl = GetCooldown(); // restamped on every proc by ShowCooldown se.m_cooldownIcon = true; _cooldownIndicator = se; return _cooldownIndicator; diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/BurdenedBlock.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/BurdenedBlock.cs index 36a5b1da8..8bd46a3aa 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/BurdenedBlock.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/BurdenedBlock.cs @@ -1,13 +1,42 @@ -using System; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; +using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { // Provides a bonus to block based on the player's carried weight - public class BurdenedBlock + public class BurdenedBlock { + // Carried weight the bonus starts paying out at, and how much further weight buys each additional + // step of it. Tunable as "WeightThreshold" and "WeightPerBonus" in this effect's Config block in + // config/shardstones.json. + public const float DefaultWeightThreshold = 300f; + public const float DefaultWeightPerBonus = 50f; + + private const string WeightThresholdKey = "WeightThreshold"; + private const string WeightPerBonusKey = "WeightPerBonus"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { WeightThresholdKey, DefaultWeightThreshold }, + { WeightPerBonusKey, DefaultWeightPerBonus }, + }; + + private static float GetWeightThreshold() + { + return Mathf.Max(0f, EffectConfig.Get(MagicEffectType.BurdenedBlock, + WeightThresholdKey, DefaultWeightThreshold)); + } + + // Floored at 1 because the step count divides by this: a configured 0 would make the bonus infinite. + private static float GetWeightPerBonus() + { + return Mathf.Max(1f, EffectConfig.Get(MagicEffectType.BurdenedBlock, + WeightPerBonusKey, DefaultWeightPerBonus)); + } + public static void Apply(ItemDrop.ItemData __instance, ref float baseBlock) { // Only the item's own wearer gets the bonus: GetBaseBlockPower also runs for blocking @@ -19,9 +48,17 @@ public static void Apply(ItemDrop.ItemData __instance, ref float baseBlock) return; } - float carriedWeight = player.GetInventory().GetTotalWeight(); + // Memoized effect value first: GetBaseBlockPower is a hot path, and the inventory weight walk + // and config lookups below are not free. float burdenedBlockBonus = player.GetTotalActiveMagicEffectValue(MagicEffectType.BurdenedBlock, 1f); - float burdenedBlockIncrement = Math.Max(0, (int)((carriedWeight - 300f) / 50f)); + if (burdenedBlockBonus <= 0f) + { + return; + } + + float carriedWeight = player.GetInventory().GetTotalWeight(); + float burdenedBlockIncrement = Math.Max(0, + (int)((carriedWeight - GetWeightThreshold()) / GetWeightPerBonus())); baseBlock += (burdenedBlockIncrement * burdenedBlockBonus); diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/CarryWeightForMovementPenalty.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/CarryWeightForMovementPenalty.cs index f9920f208..1f4175503 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/CarryWeightForMovementPenalty.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/CarryWeightForMovementPenalty.cs @@ -1,22 +1,20 @@ -using EpicLoot.src.Magic.MagicItemEffects.Helpers; -using HarmonyLib; -using JetBrains.Annotations; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; namespace EpicLoot.MagicItemEffects.Shards { // Provides a bonus to carry weight based on the player's movement penalty. public static class CarryWeightForMovementPenalty { - [HarmonyPatch(typeof(SEMan), nameof(SEMan.ModifyMaxCarryWeight))] - private static class ModifyMaxCarryWeight_Patch { - [UsedImplicitly] - private static void Postfix(SEMan __instance, float baseLimit, ref float limit) { - var player = Player.m_localPlayer; - if (__instance.m_character != player) { - return; - } - - limit += baseLimit * player.GetTotalActiveMagicEffectValue( - MagicEffectType.CarryWeightForMovementPenalty, 0.01f) * PenaltyScaling.MovementPenaltyFactor(player); + // ModifyMaxCarryWeight handler invoked by SharedSEManModifyMaxCarryWeightPatch. + public static void ModifyMaxCarryWeight(Player player, float baseLimit, ref float limit) { + var pct = player.GetTotalActiveMagicEffectValue( + MagicEffectType.CarryWeightForMovementPenalty, 0.01f); + if (pct == 0f) { + return; } + + // Measuring the penalty runs the whole status-effect speed pipeline, so it stays behind the + // memoized effect lookup: a player without this shard contributes 0 either way, and this is + // reached on every fixed tick through Player.UpdateStats -> IsEncumbered. + limit += baseLimit * pct * PenaltyScaling.MovementPenaltyFactor(player); } } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/ChanceDoubleDamage.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/ChanceDoubleDamage.cs index 99caca808..9d382df3e 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/ChanceDoubleDamage.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/ChanceDoubleDamage.cs @@ -1,11 +1,30 @@ using EpicLoot.General; using EpicLoot.src.Magic.MagicItemEffects.Helpers; using Jotunn.Managers; +using System.Collections.Generic; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { // Provides a chance to double the damage of an attack. public static class ChanceDoubleDamage { + // Multiplier applied on a successful proc; 2 = double damage. Kept separate from + // ChanceToCritOnHit's multiplier despite the matching default -- they are different shards and are + // meant to be tunable apart. Tunable as "DamageMultiplier" in this effect's Config block in + // config/shardstones.json. + public const float DefaultDamageMultiplier = 2f; + + private const string DamageMultiplierKey = "DamageMultiplier"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { DamageMultiplierKey, DefaultDamageMultiplier }, + }; + + // Floored at 1: a multiplier below 1 would make the proc a penalty. + private static float GetDamageMultiplier() { + return Mathf.Max(1f, EffectConfig.Get(MagicEffectType.ChanceDoubleDamage, + DamageMultiplierKey, DefaultDamageMultiplier)); + } + // Prefix handler invoked by CharacterDamageDispatch (attacker-side outgoing modifier). static GameObject effect = null; public static void ModifyOutgoingHit(HitData hit, Character attacker) { @@ -31,7 +50,7 @@ public static void ModifyOutgoingHit(HitData hit, Character attacker) { GameObject.Instantiate(effect, hit.m_point, Quaternion.identity); } DamageText.instance.ShowText(DamageText.TextType.Bonus, hit.m_point, $"+{Mathf.RoundToInt(hit.m_damage.EpicLootGetTotalDamage())}", true); - hit.m_damage.Modify(2f); + hit.m_damage.Modify(GetDamageMultiplier()); } } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/ChanceToCritOnHit.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/ChanceToCritOnHit.cs index 0de330bda..33324007c 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/ChanceToCritOnHit.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/ChanceToCritOnHit.cs @@ -1,17 +1,35 @@ -using Jotunn.Managers; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using Jotunn.Managers; +using System.Collections.Generic; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { // Provides a chance to crit on hit, applying a damage multiplier on crit. public static class ChanceToCritOnHit { - // Damage multiplier applied on a successful crit. Tunable; 2 = double damage on crit. This is distinct - // in intent from ChanceDoubleDamage (a Fortune-shard proc) even though the default multiplier matches. - private const float CritDamageMultiplier = 2f; + // Damage multiplier applied on a successful crit; 2 = double damage. This is distinct in intent from + // ChanceDoubleDamage (a Fortune-shard proc) even though the default matches, so the two carry + // separate Config blocks and can be retuned apart. Tunable as "DamageMultiplier" in this effect's + // Config block in config/shardstones.json. + public const float DefaultDamageMultiplier = 2f; + + private const string DamageMultiplierKey = "DamageMultiplier"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { DamageMultiplierKey, DefaultDamageMultiplier }, + }; + + // Floored at 1: a multiplier below 1 would make a crit weaker than an ordinary hit. + private static float GetDamageMultiplier() { + return Mathf.Max(1f, EffectConfig.Get(MagicEffectType.ChanceToCritOnHit, + DamageMultiplierKey, DefaultDamageMultiplier)); + } + static GameObject effect; - // Tooltip: "{0}% Chance to Crit for {1}x Damage" -- {1} surfaces the crit multiplier from the const. + // Tooltip: "{0}% Chance to Crit for {1}x Damage" -- {1} is the configured multiplier, so the shown + // number follows a retune instead of staying at the baked-in default. public static void RegisterDisplayValues() { MagicItem.RegisterDisplayValues(MagicEffectType.ChanceToCritOnHit, - value => new object[] { value, CritDamageMultiplier }); + value => new object[] { value, GetDamageMultiplier() }); } // Prefix handler invoked by CharacterDamageDispatch (attacker-side outgoing modifier). @@ -23,7 +41,7 @@ public static void ModifyOutgoingHit(HitData hit, Character attacker) { var chance = Player.m_localPlayer.GetTotalActiveMagicEffectValue( MagicEffectType.ChanceToCritOnHit, 0.01f); if (chance > 0f && Random.value < chance) { - hit.m_damage.Modify(CritDamageMultiplier); + hit.m_damage.Modify(GetDamageMultiplier()); if (effect == null) { effect = PrefabManager.Instance.GetPrefab("sfx_stonegolem_hurt"); } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/Conduit.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/Conduit.cs index 934f59725..efe3398ff 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/Conduit.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/Conduit.cs @@ -1,14 +1,32 @@ -namespace EpicLoot.MagicItemEffects.Shards { +using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using System.Collections.Generic; +using UnityEngine; + +namespace EpicLoot.MagicItemEffects.Shards { // Lightning damage done by the player restores eitr to the player, at damage thresholds. public static class Conduit { - // Lightning damage dealt per eitr trigger. Tunable; higher = a slower trickle. - private const float LightningDamagePerTrigger = 200f; + // Lightning damage dealt per eitr trigger; higher = a slower trickle. Tunable as + // "DamagePerTrigger" in this effect's Config block in config/shardstones.json. + public const float DefaultDamagePerTrigger = 200f; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { DamagePerTriggerKey, DefaultDamagePerTrigger }, + }; + + private const string DamagePerTriggerKey = "DamagePerTrigger"; + + // Floored at 1 because the payout divides by this: a configured 0 would turn one hit into an + // unbounded number of triggers. + private static float GetDamagePerTrigger() { + return Mathf.Max(1f, + EffectConfig.Get(MagicEffectType.Conduit, DamagePerTriggerKey, DefaultDamagePerTrigger)); + } - // Tooltip: "Restore {0} Eitr per {1} Lightning Damage Dealt" -- {1} is the LightningDamagePerTrigger - // const so the shown threshold stays in sync with the code rather than a baked-in literal. + // Tooltip: "Restore {0} Eitr per {1} Lightning Damage Dealt" -- {1} is the configured threshold, so + // the shown number follows a retune rather than staying at the baked-in default. public static void RegisterDisplayValues() { MagicItem.RegisterDisplayValues(MagicEffectType.Conduit, - value => new object[] { value, LightningDamagePerTrigger }); + value => new object[] { value, GetDamagePerTrigger() }); } // Cumulative lightning damage the local player has dealt with the effect active but not yet paid out @@ -28,13 +46,14 @@ public static void OnDamageDealt(Character __instance, HitData hit, Character at return; } + var damagePerTrigger = GetDamagePerTrigger(); _accumulatedLightningDamage += hit.m_damage.m_lightning; - if (_accumulatedLightningDamage < LightningDamagePerTrigger) { + if (_accumulatedLightningDamage < damagePerTrigger) { return; } - var triggers = (int)(_accumulatedLightningDamage / LightningDamagePerTrigger); - _accumulatedLightningDamage -= triggers * LightningDamagePerTrigger; + var triggers = (int)(_accumulatedLightningDamage / damagePerTrigger); + _accumulatedLightningDamage -= triggers * damagePerTrigger; player.AddEitr(triggers * eitrPerTrigger); } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/DamageBonusFromPlayerWeight.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/DamageBonusFromPlayerWeight.cs index cd5cc94cf..d45c34bb0 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/DamageBonusFromPlayerWeight.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/DamageBonusFromPlayerWeight.cs @@ -12,6 +12,10 @@ public static void ModifyWeaponDamage(ItemDrop.ItemData __instance, ref HitData. var pct = MagicEffectsHelper.GetTotalActiveMagicEffectValueForWeapon( player, __instance, MagicEffectType.DamageBonusFromPlayerWeight, 0.01f); + if (pct == 0f) { + return; + } + var bonus = pct * PenaltyScaling.WeightFactor(player); if (bonus != 0f) { __result.Modify(1f + bonus); diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/DamageIncreaseFromMovementPenalty.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/DamageIncreaseFromMovementPenalty.cs index 1e5ab956c..62f29da55 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/DamageIncreaseFromMovementPenalty.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/DamageIncreaseFromMovementPenalty.cs @@ -12,6 +12,10 @@ public static void ModifyWeaponDamage(ItemDrop.ItemData __instance, ref HitData. var pct = MagicEffectsHelper.GetTotalActiveMagicEffectValueForWeapon( player, __instance, MagicEffectType.DamageIncreaseFromMovementPenalty, 0.01f); + if (pct == 0f) { + return; + } + var bonus = pct * PenaltyScaling.MovementPenaltyFactor(player); if (bonus != 0f) { __result.Modify(1f + bonus); diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/EikthyrShockingCharge.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/EikthyrShockingCharge.cs index 064b862d1..15eb6c430 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/EikthyrShockingCharge.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/EikthyrShockingCharge.cs @@ -1,18 +1,49 @@ using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using System.Collections.Generic; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { // Provides a charge that builds on combat hits and discharges as a forward lightning shockwave when full. public static class EikthyrShockingCharge { - // Hits required to trigger a discharge. - private const int MaxCharges = 15; - // Portion of the banked combat damage delivered by the shockwave (tunable). - private const float DamageFraction = 0.3f; - // Cone geometry: reach straight ahead, and the full width of the cone at that reach. The half-width - // grows linearly from 0 at the player to (ConeMaxWidth / 2) at ConeLength. - private const float ConeLength = 4f; - private const float ConeMaxWidth = 4f; - private const float DischargeIgnoreWindow = 0.3f; + // All tunable in this effect's Config block in config/shardstones.json, under these key names. + // + // Hits required to trigger a discharge, and the portion of the banked combat damage the shockwave + // delivers. Cone geometry: reach straight ahead, and the full width of the cone at that reach -- + // the half-width grows linearly from 0 at the player to (ConeMaxWidth / 2) at ConeLength. The + // ignore window is how long after a discharge the shockwave's own hits are kept from rebuilding + // the charge. + public const int DefaultMaxCharges = 15; + public const float DefaultDamageFraction = 0.3f; + public const float DefaultConeLength = 4f; + public const float DefaultConeMaxWidth = 4f; + public const float DefaultDischargeIgnoreWindow = 0.3f; + + private const string MaxChargesKey = "MaxCharges"; + private const string DamageFractionKey = "DamageFraction"; + private const string ConeLengthKey = "ConeLength"; + private const string ConeMaxWidthKey = "ConeMaxWidth"; + private const string DischargeIgnoreWindowKey = "DischargeIgnoreWindow"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { MaxChargesKey, DefaultMaxCharges }, + { DamageFractionKey, DefaultDamageFraction }, + { ConeLengthKey, DefaultConeLength }, + { ConeMaxWidthKey, DefaultConeMaxWidth }, + { DischargeIgnoreWindowKey, DefaultDischargeIgnoreWindow }, + }; + + // Clamped to at least 1 so a misconfiguration can't discharge on every hit through a zero threshold. + private static int GetMaxCharges() { + return EffectConfig.GetIntAtLeast(MagicEffectType.ShockingCharge, + MaxChargesKey, DefaultMaxCharges, 1); + } + + // Floored above zero: the cone test divides by the reach. + private static float GetConeLength() { + return Mathf.Max(0.1f, + EffectConfig.Get(MagicEffectType.ShockingCharge, ConeLengthKey, DefaultConeLength)); + } + private const string ShockwaveFx = "fx_eikthyr_forwardshockwave"; private static int _charges; @@ -27,7 +58,7 @@ public static class EikthyrShockingCharge { private static bool _indicatorMissingLogged; // Live charge state, read by SE_ShockingChargeIndicator for its icon text and removal check. public static int CurrentCharges => _charges; - public static int MaxChargeCount => MaxCharges; + public static int MaxChargeCount => GetMaxCharges(); // Postfix handler invoked by CharacterDamageDispatch (on-hit reaction). public static void OnDamageDealt(Character __instance, HitData hit, Character attacker) { @@ -60,7 +91,7 @@ public static void OnDamageDealt(Character __instance, HitData hit, Character at _bankedDamage += contributed; - if (++_charges < MaxCharges) { + if (++_charges < GetMaxCharges()) { ShowIndicator(player); return; } @@ -68,8 +99,10 @@ public static void OnDamageDealt(Character __instance, HitData hit, Character at // Discharge: fire along this (final) attack's direction, spend the bank, and open the ignore // window before the shockwave's hits route back through this postfix. _charges = 0; - _ignoreUntil = Time.time + DischargeIgnoreWindow; - var shotDamage = _bankedDamage * DamageFraction; + _ignoreUntil = Time.time + EffectConfig.Get(MagicEffectType.ShockingCharge, + DischargeIgnoreWindowKey, DefaultDischargeIgnoreWindow); + var shotDamage = _bankedDamage * EffectConfig.Get(MagicEffectType.ShockingCharge, + DamageFractionKey, DefaultDamageFraction); _bankedDamage = 0f; FireShockwave(player, hit.m_dir, shotDamage); } @@ -110,7 +143,9 @@ private static void DamageEnemiesInCone(Player player, Vector3 origin, Vector3 d return; } - var halfMaxWidth = ConeMaxWidth * 0.5f; + var coneLength = GetConeLength(); + var halfMaxWidth = EffectConfig.Get(MagicEffectType.ShockingCharge, + ConeMaxWidthKey, DefaultConeMaxWidth) * 0.5f; foreach (var character in Character.GetAllCharacters()) { if (character == null || character.IsPlayer() || character.IsTamed() || character.IsDead()) { continue; @@ -124,12 +159,12 @@ private static void DamageEnemiesInCone(Player player, Vector3 origin, Vector3 d toTarget.y = 0f; var along = Vector3.Dot(toTarget, dir); - if (along <= 0f || along > ConeLength) { + if (along <= 0f || along > coneLength) { continue; } var perpendicular = toTarget - dir * along; - var halfWidth = (along / ConeLength) * halfMaxWidth; + var halfWidth = (along / coneLength) * halfMaxWidth; if (perpendicular.sqrMagnitude > halfWidth * halfWidth) { continue; } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/EitrImbueAttack.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/EitrImbueAttack.cs index b974171dc..4febb3cf0 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/EitrImbueAttack.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/EitrImbueAttack.cs @@ -1,8 +1,25 @@ -namespace EpicLoot.MagicItemEffects.Shards { +using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using System.Collections.Generic; +using UnityEngine; + +namespace EpicLoot.MagicItemEffects.Shards { // Provides a bonus to spirit damage based on the physical damage dealt by the player, at the cost of Eitr public static class EitrImbueAttack { - // Eitr paid per point of bonus spirit damage, conversion ratio - private const float EitrCostPerDamage = 1f; + // Eitr paid per point of bonus spirit damage -- the conversion ratio. Tunable as "EitrCostPerDamage" + // in this effect's Config block in config/shardstones.json. + public const float DefaultEitrCostPerDamage = 1f; + + private const string EitrCostPerDamageKey = "EitrCostPerDamage"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { EitrCostPerDamageKey, DefaultEitrCostPerDamage }, + }; + + // Floored at zero: a negative ratio would pay the player eitr for imbuing. + private static float GetEitrCostPerDamage() { + return Mathf.Max(0f, EffectConfig.Get(MagicEffectType.EitrImbueAttack, + EitrCostPerDamageKey, DefaultEitrCostPerDamage)); + } // Prefix handler invoked by CharacterDamageDispatch (attacker-side outgoing modifier). public static void ModifyOutgoingHit(HitData hit, Character attacker) { @@ -22,7 +39,7 @@ public static void ModifyOutgoingHit(HitData hit, Character attacker) { } // No bonus unless the pool can fully cover the cost. - float cost = bonus * EitrCostPerDamage; + float cost = bonus * GetEitrCostPerDamage(); if (player.GetEitr() < cost) { return; } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/ElderForestsAid.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/ElderForestsAid.cs index 674e56800..439f1cd24 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/ElderForestsAid.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/ElderForestsAid.cs @@ -1,12 +1,31 @@ -using UnityEngine; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using System.Collections.Generic; +using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { // When the player takes damage, immobilizes nearby enemies for a short duration. The radius and cooldown scale with the shard value. public static class ElderForestsAid { - private const float Cooldown = 30f; // Base cooldown - private const float BaseRadius = 6f; // ensnare radius before the shard value widens it - private const float RadiusPerTier = 0.15f; + // Both the radius and the cooldown scale with the shard value: radius is BaseRadius plus + // RadiusPerTier per point, cooldown is Cooldown plus CooldownPerValue per point -- so a stronger + // shard reaches further but fires less often. All tunable in this effect's Config block in + // config/shardstones.json, under these key names. + public const float DefaultCooldown = 30f; + public const float DefaultCooldownPerValue = 1.5f; + public const float DefaultBaseRadius = 6f; + public const float DefaultRadiusPerTier = 0.15f; + + private const string CooldownKey = "Cooldown"; + private const string CooldownPerValueKey = "CooldownPerValue"; + private const string BaseRadiusKey = "BaseRadius"; + private const string RadiusPerTierKey = "RadiusPerTier"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { CooldownKey, DefaultCooldown }, + { CooldownPerValueKey, DefaultCooldownPerValue }, + { BaseRadiusKey, DefaultBaseRadius }, + { RadiusPerTierKey, DefaultRadiusPerTier }, + }; private const string ImmobilizeSE = "ImmobilizedAshlands"; private const string HitFxPrefab = "fx_natureweapon_hit"; @@ -36,8 +55,17 @@ public static void OnDamageTaken(Character __instance, HitData hit) { return; } - Immobilize(player, BaseRadius + value * RadiusPerTier); - ShowCooldown(player, Cooldown + value * 1.5f); + var radius = EffectConfig.Get(MagicEffectType.ForestsAid, BaseRadiusKey, DefaultBaseRadius) + + value * EffectConfig.Get(MagicEffectType.ForestsAid, RadiusPerTierKey, DefaultRadiusPerTier); + // Cooldown floored just above zero: a ttl of 0 is "no timeout" to vanilla, which would gate the + // shard permanently rather than removing the cooldown. + var cooldown = Mathf.Max(0.1f, + EffectConfig.Get(MagicEffectType.ForestsAid, CooldownKey, DefaultCooldown) + + value * EffectConfig.Get(MagicEffectType.ForestsAid, + CooldownPerValueKey, DefaultCooldownPerValue)); + + Immobilize(player, radius); + ShowCooldown(player, cooldown); } private static void Immobilize(Player player, float radius) { @@ -103,7 +131,7 @@ private static StatusEffect GetOrCreateCooldownIndicator() { se.name = CooldownName; se.m_name = "$mod_epicloot_se_forestsaid"; se.m_icon = icon; - se.m_ttl = Cooldown; // overwritten per-proc by ShowCooldown with the value-scaled cooldown + se.m_ttl = DefaultCooldown; // overwritten per-proc by ShowCooldown with the value-scaled cooldown se.m_cooldownIcon = true; _cooldownIndicator = se; return _cooldownIndicator; diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/GainAdrenalineWhenApplyingPoison.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/GainAdrenalineWhenApplyingPoison.cs index 59a18c804..d42b674d1 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/GainAdrenalineWhenApplyingPoison.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/GainAdrenalineWhenApplyingPoison.cs @@ -1,12 +1,35 @@ -using JetBrains.Annotations; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using JetBrains.Annotations; using System.Collections.Generic; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { // Grants adrenaline every few seconds for each nearby enemy that the local player has poisoned. public static class GainAdrenalineWhenApplyingPoison { - internal const float TickInterval = 3f; // seconds between adrenaline pulses - private const float Radius = 30f; // how far a poisoned foe can be and still count + // Seconds between adrenaline pulses, and how far a poisoned foe can be and still count. Tunable as + // "TickInterval" and "Radius" in this effect's Config block in config/shardstones.json; + // PoisonAdrenalinePulse re-arms its InvokeRepeating when the interval changes. + public const float DefaultTickInterval = 3f; + public const float DefaultRadius = 30f; + + private const string TickIntervalKey = "TickInterval"; + private const string RadiusKey = "Radius"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { TickIntervalKey, DefaultTickInterval }, + { RadiusKey, DefaultRadius }, + }; + + // Floored well above zero: InvokeRepeating with a tiny period would pulse every frame. + internal static float GetTickInterval() { + return Mathf.Max(0.5f, EffectConfig.Get(MagicEffectType.GainAdrenalineWhenApplyingPoison, + TickIntervalKey, DefaultTickInterval)); + } + + private static float GetRadius() { + return Mathf.Max(0f, EffectConfig.Get(MagicEffectType.GainAdrenalineWhenApplyingPoison, + RadiusKey, DefaultRadius)); + } // Vanilla SE_Poison TTL defaults, used only when the prototype cannot be read from the ObjectDB. private const float DefaultBaseTtl = 2f; @@ -18,11 +41,11 @@ public static class GainAdrenalineWhenApplyingPoison { private static readonly Dictionary _poisonedUntil = new Dictionary(); private static readonly List _stale = new List(); - // Tooltip: "Gain {0} Adrenaline every {1}s per Poisoned Foe Nearby" -- {1}/{2} are the interval and - // radius consts so the shown numbers stay in sync with the code rather than baked-in literals. + // Tooltip: "Gain {0} Adrenaline every {1}s per Poisoned Foe Nearby" -- {1}/{2} are the configured + // interval and radius, so the shown numbers follow a retune instead of the baked-in defaults. public static void RegisterDisplayValues() { MagicItem.RegisterDisplayValues(MagicEffectType.GainAdrenalineWhenApplyingPoison, - value => new object[] { value, TickInterval, Radius }); + value => new object[] { value, GetTickInterval(), GetRadius() }); } internal static void ClearTracking() { @@ -60,7 +83,8 @@ internal static void Pulse(Player player, float value) { } var now = Time.time; - var radiusSqr = Radius * Radius; + var radius = GetRadius(); + var radiusSqr = radius * radius; var origin = player.transform.position; var count = 0; @@ -137,15 +161,30 @@ internal static void Create() { go.AddComponent(); } + // The period InvokeRepeating was last armed with. InvokeRepeating fixes its period at scheduling + // time, so a retuned TickInterval only takes hold once the invoke is cancelled and re-armed. + private float _scheduledInterval; + [UsedImplicitly] private void Awake() { instance = this; - InvokeRepeating(nameof(Pulse), GainAdrenalineWhenApplyingPoison.TickInterval, - GainAdrenalineWhenApplyingPoison.TickInterval); + Reschedule(); + } + + private void Reschedule() { + _scheduledInterval = GainAdrenalineWhenApplyingPoison.GetTickInterval(); + CancelInvoke(nameof(Pulse)); + InvokeRepeating(nameof(Pulse), _scheduledInterval, _scheduledInterval); } [UsedImplicitly] private void Pulse() { + // Cheapest place guaranteed to run after a config reload, and it costs one float compare on a + // call that already only happens every few seconds. + if (!Mathf.Approximately(_scheduledInterval, GainAdrenalineWhenApplyingPoison.GetTickInterval())) { + Reschedule(); + } + var player = Player.m_localPlayer; if (player == null || _trackedPlayer != player) { _trackedPlayer = player; diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/GainMaxCarryWeightFromRested.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/GainMaxCarryWeightFromRested.cs index 047b459dd..d506e0893 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/GainMaxCarryWeightFromRested.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/GainMaxCarryWeightFromRested.cs @@ -1,25 +1,25 @@ -using HarmonyLib; -using JetBrains.Annotations; - namespace EpicLoot.MagicItemEffects.Shards { // Provides a bonus to max carry weight based on the player's comfort level when rested. public static class GainMaxCarryWeightFromRested { - [HarmonyPatch(typeof(SEMan), nameof(SEMan.ModifyMaxCarryWeight))] - private static class ModifyMaxCarryWeight_Patch { - [UsedImplicitly] - private static void Postfix(SEMan __instance, ref float limit) { - var player = Player.m_localPlayer; - if (__instance.m_character != player || !__instance.HaveStatusEffect(SEMan.s_statusEffectRested)) { - return; - } + // ModifyMaxCarryWeight handler invoked by SharedSEManModifyMaxCarryWeightPatch. + public static void ModifyMaxCarryWeight(Player player, SEMan seman, ref float limit) { + var perComfort = player.GetTotalActiveMagicEffectValue( + MagicEffectType.GainMaxCarryWeightFromRested); + if (perComfort == 0f) { + return; + } - var comfortLevel = player.GetComfortLevel(); - if (comfortLevel <= 0) { - return; - } + // HaveStatusEffect walks the status effect list, so it stays behind the memoized lookup. + if (!seman.HaveStatusEffect(SEMan.s_statusEffectRested)) { + return; + } - limit += player.GetTotalActiveMagicEffectValue(MagicEffectType.GainMaxCarryWeightFromRested) * comfortLevel; + var comfortLevel = player.GetComfortLevel(); + if (comfortLevel <= 0) { + return; } + + limit += perComfort * comfortLevel; } } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/HealthGainPerXDamageDone.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/HealthGainPerXDamageDone.cs index 3d1cf28cb..5af485bdb 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/HealthGainPerXDamageDone.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/HealthGainPerXDamageDone.cs @@ -1,17 +1,33 @@ using EpicLoot.General; using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using System.Collections.Generic; +using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { // Provides a heal to the player for every X damage dealt, cumulative across hits public static class HealthGainPerXDamageDone { - // Threshold of damage dealt before the player is healed. - private const float DamagePerTrigger = 200f; + // Threshold of damage dealt before the player is healed. Tunable as "DamagePerTrigger" in this + // effect's Config block in config/shardstones.json. + public const float DefaultDamagePerTrigger = 200f; - // Tooltip: "Heal {0} per {1} Damage Dealt" -- {1} is the DamagePerTrigger const so the shown - // threshold stays in sync with the code rather than a baked-in literal. + private const string DamagePerTriggerKey = "DamagePerTrigger"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { DamagePerTriggerKey, DefaultDamagePerTrigger }, + }; + + // Floored at 1 because the payout divides by this: a configured 0 would turn a single hit into an + // unbounded number of triggers. + private static float GetDamagePerTrigger() { + return Mathf.Max(1f, EffectConfig.Get(MagicEffectType.HealthGainPerXDamageDone, + DamagePerTriggerKey, DefaultDamagePerTrigger)); + } + + // Tooltip: "Heal {0} per {1} Damage Dealt" -- {1} is the configured threshold, so the shown number + // follows a retune instead of staying at the baked-in default. public static void RegisterDisplayValues() { MagicItem.RegisterDisplayValues(MagicEffectType.HealthGainPerXDamageDone, - value => new object[] { value, DamagePerTrigger }); + value => new object[] { value, GetDamagePerTrigger() }); } // Cumulative damage the local player has dealt with the effect active but not yet paid out as a @@ -36,13 +52,14 @@ public static void OnDamageDealt(HitData hit, Character attacker) { return; } + var damagePerTrigger = GetDamagePerTrigger(); _accumulatedDamage += hit.m_damage.EpicLootGetTotalDamage(); - if (_accumulatedDamage < DamagePerTrigger) { + if (_accumulatedDamage < damagePerTrigger) { return; } - int triggers = (int)(_accumulatedDamage / DamagePerTrigger); - _accumulatedDamage -= triggers * DamagePerTrigger; + int triggers = (int)(_accumulatedDamage / damagePerTrigger); + _accumulatedDamage -= triggers * damagePerTrigger; player.Heal(triggers * healthPerTrigger); } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/HealthOnEitrUse.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/HealthOnEitrUse.cs index 1a8bb933d..30da4cf40 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/HealthOnEitrUse.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/HealthOnEitrUse.cs @@ -1,18 +1,34 @@ using EpicLoot.src.Magic.MagicItemEffects.Helpers; using HarmonyLib; using JetBrains.Annotations; +using System.Collections.Generic; +using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { // Provides a small heal over time when the player spends Eitr. The heal is triggered every time the player spends a threshold amount of Eitr. public static class HealthOnEitrUse { - // Threshold of Eitr spent before the player is healed. - private const float EitrPerTrigger = 100f; + // Threshold of Eitr spent before the player is healed. Tunable as "EitrPerTrigger" in this + // effect's Config block in config/shardstones.json. + public const float DefaultEitrPerTrigger = 100f; - // Tooltip: "Heal {0} per {1} Eitr Spent" -- {1} is the EitrPerTrigger const so the shown threshold - // stays in sync with the code rather than a baked-in literal. + private const string EitrPerTriggerKey = "EitrPerTrigger"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { EitrPerTriggerKey, DefaultEitrPerTrigger }, + }; + + // Floored at 1 because the payout divides by this: a configured 0 would turn a single cast into an + // unbounded number of triggers. + private static float GetEitrPerTrigger() { + return Mathf.Max(1f, + EffectConfig.Get(MagicEffectType.HealthOnEitrUse, EitrPerTriggerKey, DefaultEitrPerTrigger)); + } + + // Tooltip: "Heal {0} per {1} Eitr Spent" -- {1} is the configured threshold, so the shown number + // follows a retune instead of staying at the baked-in default. public static void RegisterDisplayValues() { MagicItem.RegisterDisplayValues(MagicEffectType.HealthOnEitrUse, - value => new object[] { value, EitrPerTrigger }); + value => new object[] { value, GetEitrPerTrigger() }); } // Eitr the local player has spent with the effect active but not yet paid out as a heal. Carries @@ -39,13 +55,14 @@ private static void Postfix(Player __instance, float v) { return; } + var eitrPerTrigger = GetEitrPerTrigger(); _accumulatedEitr += v; - if (_accumulatedEitr < EitrPerTrigger) { + if (_accumulatedEitr < eitrPerTrigger) { return; } - var triggers = (int)(_accumulatedEitr / EitrPerTrigger); - _accumulatedEitr -= triggers * EitrPerTrigger; + var triggers = (int)(_accumulatedEitr / eitrPerTrigger); + _accumulatedEitr -= triggers * eitrPerTrigger; __instance.Heal(triggers * healthPerTrigger); } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/IcyWeight.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/IcyWeight.cs index 48c562e47..21674b405 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/IcyWeight.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/IcyWeight.cs @@ -11,8 +11,12 @@ public static void ModifyWeaponDamage(ItemDrop.ItemData __instance, ref HitData. return; } - var fraction = player.GetTotalActiveMagicEffectValue(MagicEffectType.IcyWeight, 0.01f) - * PenaltyScaling.MovementPenaltyFactor(player); + var pct = player.GetTotalActiveMagicEffectValue(MagicEffectType.IcyWeight, 0.01f); + if (pct <= 0f) { + return; + } + + var fraction = pct * PenaltyScaling.MovementPenaltyFactor(player); if (fraction <= 0f) { return; } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/IncreaseXPGainFromMovementPenalty.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/IncreaseXPGainFromMovementPenalty.cs index 7c07d97fd..573fbf646 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/IncreaseXPGainFromMovementPenalty.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/IncreaseXPGainFromMovementPenalty.cs @@ -21,9 +21,13 @@ private static void Prefix(Skills __instance, ref float factor) { return; } - var bonus = player.GetTotalActiveMagicEffectValue( - MagicEffectType.IncreaseXPGainFromMovementPenalty, 0.01f) * PenaltyScaling.MovementPenaltyFactor(player); - factor *= 1f + bonus; + var pct = player.GetTotalActiveMagicEffectValue( + MagicEffectType.IncreaseXPGainFromMovementPenalty, 0.01f); + if (pct == 0f) { + return; + } + + factor *= 1f + pct * PenaltyScaling.MovementPenaltyFactor(player); } } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/Inspiration.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/Inspiration.cs index c82050fd4..2eafc4754 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/Inspiration.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/Inspiration.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using EpicLoot.MagicItemEffects.Helpers; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; using HarmonyLib; using JetBrains.Annotations; using UnityEngine; @@ -24,25 +25,49 @@ public static class Inspiration { // XP faucet. Exposed as Config so it can be retuned without a rebuild. public const float DefaultProcChance = 0.25f; + // Skills below this level are never chosen. Level 0-1 skills are usually ones the player has + // barely touched by accident, and vanilla's curve makes the first two levels nearly free anyway. + public const float DefaultMinSkillLevel = 2f; + // Weight = (100 - level)^2, so level 2 weighs 9604 and level 99 weighs 1. Squared rather than + // linear because a linear ramp still hands ~10% of procs to skills where the grant is a rounding + // error; the point of the effect is to pull up whatever is lagging. Set it to 0 for a flat pick. + public const float DefaultLevelWeightExponent = 2f; + // Hard stop on the level-walk loop. Nothing should get near this -- the only way to spin is a + // degenerate zero-cost level requirement. + public const int DefaultMaxLevelUpsPerProc = 20; + private const string ProcChanceKey = "ProcChance"; + private const string MinSkillLevelKey = "MinSkillLevel"; + private const string LevelWeightExponentKey = "LevelWeightExponent"; + private const string MaxLevelUpsPerProcKey = "MaxLevelUpsPerProc"; public static readonly Dictionary DefaultConfig = new Dictionary { { ProcChanceKey, DefaultProcChance }, + { MinSkillLevelKey, DefaultMinSkillLevel }, + { LevelWeightExponentKey, DefaultLevelWeightExponent }, + { MaxLevelUpsPerProcKey, DefaultMaxLevelUpsPerProc }, }; - // Skills below this level are never chosen. Level 0-1 skills are usually ones the player has - // barely touched by accident, and vanilla's curve makes the first two levels nearly free anyway. - private const float MinSkillLevel = 2f; - // Weight = (100 - level)^2, so level 2 weighs 9604 and level 99 weighs 1. Squared rather than - // linear because a linear ramp still hands ~10% of procs to skills where the grant is a rounding - // error; the point of the effect is to pull up whatever is lagging. - private const float LevelWeightExponent = 2f; - // Vanilla's Skills.c_MaxSkillLevel (private const there). + private static float GetMinSkillLevel() { + return EffectConfig.Get(MagicEffectType.Inspiration, MinSkillLevelKey, DefaultMinSkillLevel); + } + + // Floored at zero: a negative exponent would invert the weighting and feed the highest skills. + private static float GetLevelWeightExponent() { + return Mathf.Max(0f, EffectConfig.Get(MagicEffectType.Inspiration, + LevelWeightExponentKey, DefaultLevelWeightExponent)); + } + + // Floored at 1: this is a runaway-loop guard, and a value of 0 would grant nothing at all. + private static int GetMaxLevelUpsPerProc() { + return EffectConfig.GetIntAtLeast(MagicEffectType.Inspiration, + MaxLevelUpsPerProcKey, DefaultMaxLevelUpsPerProc, 1); + } + + // Vanilla's Skills.c_MaxSkillLevel (private const there). Not a balance knob -- it mirrors a + // vanilla constant, so it stays out of the effect Config. private const float MaxSkillLevel = 100f; - // Hard stop on the level-walk loop. Nothing should get near this -- the only way to spin is a - // degenerate zero-cost level requirement. - private const int MaxLevelUpsPerProc = 20; // The needed/perFactor round trip can land an ULP short of the boundary and silently fail to // level. The overshoot is discarded by vanilla anyway, so padding it costs nothing. private const float LevelEpsilon = 0.001f; @@ -101,7 +126,7 @@ private static Skills.Skill PickTargetSkill(Skills skills) { var candidates = skills.GetSkillList() .Where(s => s?.m_info != null && s.m_info.m_skill != SkillType.None - && s.m_level >= MinSkillLevel + && s.m_level >= GetMinSkillLevel() && s.m_level < MaxSkillLevel) .ToList(); @@ -110,7 +135,7 @@ private static Skills.Skill PickTargetSkill(Skills skills) { } return new WeightedRandomCollection(candidates, - s => Mathf.Pow(Mathf.Max(0.01f, MaxSkillLevel - s.m_level), LevelWeightExponent)).Roll(); + s => Mathf.Pow(Mathf.Max(0.01f, MaxSkillLevel - s.m_level), GetLevelWeightExponent())).Roll(); } // Hands the skill `points` raw accumulator points, spread over as many RaiseSkill calls as it @@ -138,7 +163,8 @@ private static void Grant(Skills skills, Skills.Skill skill, float points) { try { var guard = 0; // skill is a live reference, so m_level / m_accumulator are re-read fresh each pass. - while (remaining > 0f && skill.m_level < MaxSkillLevel && guard++ < MaxLevelUpsPerProc) { + var maxLevelUps = GetMaxLevelUpsPerProc(); + while (remaining > 0f && skill.m_level < MaxSkillLevel && guard++ < maxLevelUps) { var needed = Mathf.Max(0f, NextLevelRequirement(skill.m_level) - skill.m_accumulator); if (needed > remaining) { break; // can't reach the next boundary; fall through to the top-up below @@ -162,12 +188,10 @@ private static float NextLevelRequirement(float level) { return Mathf.Pow(Mathf.Floor(level + 1f), 1.5f) * 0.5f + 0.5f; } + // Clamped to 0..100: it is a percent, and a negative one would silently disable the effect. private static float GetProcChance() { - var cfg = MagicItemEffectDefinitions.GetEffectConfig(MagicEffectType.Inspiration); - if (cfg != null && cfg.TryGetValue(ProcChanceKey, out var raw)) { - return Mathf.Max(0f, raw); - } - return DefaultProcChance; + return EffectConfig.GetClamped(MagicEffectType.Inspiration, + ProcChanceKey, DefaultProcChance, 0f, 100f); } } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/KillsReduceNextBloodCost.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/KillsReduceNextBloodCost.cs index 36458f0a4..9624fd861 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/KillsReduceNextBloodCost.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/KillsReduceNextBloodCost.cs @@ -1,16 +1,38 @@ using EpicLoot.src.Magic.MagicItemEffects.Helpers; using HarmonyLib; using JetBrains.Annotations; +using System.Collections.Generic; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { // Provides a status effect on kill which reduces the health cost of the next blood attack. The reduction is banked and can be stacked up to a cap, and expires after a duration. public static class KillsReduceNextBloodCost { - // Cap on the banked reduction. 1 = kills can bank up to a fully-free next blood attack. Tunable. - private const float MaxReduction = 1f; + // Cap on the banked reduction (1 = kills can bank up to a fully-free next blood attack), and how + // many seconds the bank lasts before lapsing -- refreshed on every kill. Tunable as "MaxReduction" + // and "BuffDuration" in this effect's Config block in config/shardstones.json. + public const float DefaultMaxReduction = 1f; + public const float DefaultBuffDuration = 30f; - // Seconds the banked discount lasts before lapsing; refreshed on every kill. - private const float BuffDuration = 30f; + private const string MaxReductionKey = "MaxReduction"; + private const string BuffDurationKey = "BuffDuration"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { MaxReductionKey, DefaultMaxReduction }, + { BuffDurationKey, DefaultBuffDuration }, + }; + + // The bank is a fraction of the cost, so anything above 1 is meaningless and anything below 0 + // would turn kills into a surcharge. + private static float GetMaxReduction() { + return EffectConfig.GetClamped(MagicEffectType.KillsReduceNextBloodCost, + MaxReductionKey, DefaultMaxReduction, 0f, 1f); + } + + // Floored just above zero: a ttl of 0 is "no timeout" to vanilla, which would make the bank permanent. + private static float GetBuffDuration() { + return Mathf.Max(0.1f, EffectConfig.Get(MagicEffectType.KillsReduceNextBloodCost, + BuffDurationKey, DefaultBuffDuration)); + } private const string BuffName = "EL_BloodRite"; private static readonly int BuffHash = BuffName.GetStableHashCode(); @@ -37,7 +59,7 @@ private static void Postfix(Character __instance) { var perKill = Player.m_localPlayer.GetTotalActiveMagicEffectValue( MagicEffectType.KillsReduceNextBloodCost, 0.01f); if (perKill > 0f) { - _bankedReduction = Mathf.Min(MaxReduction, _bankedReduction + perKill); + _bankedReduction = Mathf.Min(GetMaxReduction(), _bankedReduction + perKill); ApplyOrRefreshBuff(Player.m_localPlayer); } } @@ -61,14 +83,20 @@ private static void Prefix(Character __instance, ref float hp) { // Shows the buff on the first banked kill, or just refreshes its countdown while it is already up. // The discount itself is read live from the bank, so there is no per-instance state to restamp. private static void ApplyOrRefreshBuff(Player player) { + var duration = GetBuffDuration(); var seMan = player.GetSEMan(); if (seMan.GetStatusEffect(BuffHash) is SE_BloodRite existing) { + // Stamped on the live instance too, so a retune shortens or extends a bank already running. + existing.m_ttl = duration; existing.ResetTime(); return; } var prototype = GetOrCreatePrototype(); if (prototype != null) { + // The prototype is built once and cached, so the ttl is restamped here rather than at + // construction -- otherwise a retuned duration would not take hold until the next session. + prototype.m_ttl = duration; seMan.AddStatusEffect(prototype); } } @@ -76,7 +104,7 @@ private static void ApplyOrRefreshBuff(Player player) { // Lazily builds the buff prototype. Runs on a kill, so the asset bundle is loaded. A null icon would // render as an invisible HUD entry (SEMan only surfaces effects with an icon), so if the sprite // lookup fails we log once and leave the prototype null -- the discount still works, it just has no - // icon, and with it the 30s expiry (owned by the buff) simply doesn't apply. + // icon, and with it the expiry (owned by the buff) simply doesn't apply. private static SE_BloodRite GetOrCreatePrototype() { if (_buffPrototype != null) { return _buffPrototype; @@ -97,7 +125,7 @@ private static SE_BloodRite GetOrCreatePrototype() { se.name = BuffName; se.m_name = "$mod_epicloot_se_bloodrite"; se.m_icon = icon; - se.m_ttl = BuffDuration; + se.m_ttl = GetBuffDuration(); // restamped on every apply/refresh by ApplyOrRefreshBuff _buffPrototype = se; return _buffPrototype; } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/Kindling.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/Kindling.cs index 3e1237999..f62462f5b 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/Kindling.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/Kindling.cs @@ -1,17 +1,34 @@ -using HarmonyLib; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using HarmonyLib; using JetBrains.Annotations; +using System.Collections.Generic; +using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { // Provides a stamina restoration effect based on fire damage taken, uses an accumulated threshold. public static class Kindling { - // Fire damage taken per stamina trigger. Tunable; higher = a slower trickle. - private const float FireDamagePerTrigger = 75f; + // Fire damage taken per stamina trigger; higher = a slower trickle. Tunable as "DamagePerTrigger" + // in this effect's Config block in config/shardstones.json. + public const float DefaultDamagePerTrigger = 75f; - // Tooltip: "Restore {0} Stamina per {1} Fire Damage Taken" -- {1} is the FireDamagePerTrigger const - // so the shown threshold stays in sync with the code rather than a baked-in literal. + private const string DamagePerTriggerKey = "DamagePerTrigger"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { DamagePerTriggerKey, DefaultDamagePerTrigger }, + }; + + // Floored at 1 because the payout divides by this: a configured 0 would turn a single burn tick + // into an unbounded number of triggers. + private static float GetDamagePerTrigger() { + return Mathf.Max(1f, + EffectConfig.Get(MagicEffectType.Kindling, DamagePerTriggerKey, DefaultDamagePerTrigger)); + } + + // Tooltip: "Restore {0} Stamina per {1} Fire Damage Taken" -- {1} is the configured threshold, so + // the shown number follows a retune instead of staying at the baked-in default. public static void RegisterDisplayValues() { MagicItem.RegisterDisplayValues(MagicEffectType.Kindling, - value => new object[] { value, FireDamagePerTrigger }); + value => new object[] { value, GetDamagePerTrigger() }); } // Cumulative fire damage the local player has taken with the effect active but not yet paid out as @@ -34,13 +51,14 @@ private static void Postfix(Character __instance, HitData hit) { return; } + var damagePerTrigger = GetDamagePerTrigger(); _accumulatedFireDamage += hit.m_damage.m_fire; - if (_accumulatedFireDamage < FireDamagePerTrigger) { + if (_accumulatedFireDamage < damagePerTrigger) { return; } - var triggers = (int)(_accumulatedFireDamage / FireDamagePerTrigger); - _accumulatedFireDamage -= triggers * FireDamagePerTrigger; + var triggers = (int)(_accumulatedFireDamage / damagePerTrigger); + _accumulatedFireDamage -= triggers * damagePerTrigger; player.AddStamina(triggers * staminaPerTrigger); } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/LuckWhileFishing.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/LuckWhileFishing.cs index 31ea54c8b..aa5fbb644 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/LuckWhileFishing.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/LuckWhileFishing.cs @@ -1,4 +1,5 @@ -using HarmonyLib; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using HarmonyLib; using JetBrains.Annotations; using System.Collections.Generic; using UnityEngine; @@ -20,8 +21,17 @@ public static class LuckWhileFishing // Config keys that are tunables rather than loot prefabs. Matched explicitly so a future tunable // that happens to collide with a prefab name can never be silently rolled as treasure. public const string TripleChanceKey = "TripleChance"; - - private static readonly HashSet ReservedConfigKeys = new HashSet { TripleChanceKey }; + public const string RollBaseKey = "RollBase"; + public const string RollPerValueKey = "RollPerValue"; + public const string RollCurveKey = "RollCurve"; + public const string FloorFractionKey = "FloorFraction"; + + // Every key in this set is skipped by ResolveTable. Anything NOT listed here is treated as a loot + // prefab name, so a new tunable that is left out would be looked up in the ObjectDB on every proc + // and, if a prefab happened to share its name, rolled as treasure. + private static readonly HashSet ReservedConfigKeys = new HashSet { + TripleChanceKey, RollBaseKey, RollPerValueKey, RollCurveKey, FloorFractionKey, + }; // Percent of successful multi-catch procs that yield +2 fish instead of +1. public const float DefaultTripleChance = 20f; @@ -32,19 +42,23 @@ public static class LuckWhileFishing // rollMax = RollBase + value * RollPerValue the range the roll can reach at all // roll = pow(random, RollCurve) * rollMax biased low, so each higher tier is rarer // floor = rollMax * FloorFraction climbs with rollMax, dropping cheap entries out - // Kept as consts rather than Config keys: every Config key renders as its own line in the - // detailed (Shift) tooltip, and the prefab list already fills it. - private const float RollBase = 40f; - private const float RollPerValue = 12f; - private const float RollCurve = 2f; - private const float FloorFraction = 0.05f; - - // Default Config block, registered in ShardEffectDefinitions.EffectConfigs. Prefab name -> value - // threshold, mirroring the Riches entry in magiceffects.json. An admin overrides the whole table - // by adding a "Type": "LuckWhileFishing" entry with its own Config to magiceffects.json. + public const float DefaultRollBase = 40f; + public const float DefaultRollPerValue = 12f; + public const float DefaultRollCurve = 2f; + public const float DefaultFloorFraction = 0.05f; + + // Default Config block, registered in ShardEffectDefinitions.EffectConfigs. The four roll-shape + // tunables above, plus prefab name -> value threshold, mirroring the Riches entry in + // magiceffects.json. The grid entry's own "Config" in shardstones.json overlays this per key, so + // retuning one number leaves the rest of the treasure table intact; setting an entry to 0 removes + // it from the table. public static readonly Dictionary DefaultConfig = new Dictionary { { TripleChanceKey, DefaultTripleChance }, + { RollBaseKey, DefaultRollBase }, + { RollPerValueKey, DefaultRollPerValue }, + { RollCurveKey, DefaultRollCurve }, + { FloorFractionKey, DefaultFloorFraction }, { "Flint", 5 }, { "Coins", 15 }, { "Amber", 40 }, @@ -126,9 +140,15 @@ public static bool TryRollTreasure(float value, out string prefabName, out int a return false; } - var rollMax = RollBase + value * RollPerValue; - var roll = Mathf.Pow(Random.value, RollCurve) * rollMax; - var floor = rollMax * FloorFraction; + // Curve floored above zero: pow(x, 0) is 1, which would pin every roll to the top of the range. + var rollMax = EffectConfig.Get(MagicEffectType.LuckWhileFishing, RollBaseKey, DefaultRollBase) + + value * EffectConfig.Get(MagicEffectType.LuckWhileFishing, + RollPerValueKey, DefaultRollPerValue); + var curve = Mathf.Max(0.01f, EffectConfig.Get(MagicEffectType.LuckWhileFishing, + RollCurveKey, DefaultRollCurve)); + var roll = Mathf.Pow(Random.value, curve) * rollMax; + var floor = rollMax * EffectConfig.Get(MagicEffectType.LuckWhileFishing, + FloorFractionKey, DefaultFloorFraction); // Table is ascending by threshold: walk up keeping the last affordable entry that the floor // still allows, and remember the cheapest allowed entry as the fallback. diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/LuckyLoot.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/LuckyLoot.cs index c79237a52..2bcb60e90 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/LuckyLoot.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/LuckyLoot.cs @@ -34,24 +34,47 @@ public static class LuckyLoot { public const float DefaultBonusRollsMin = 1f; public const float DefaultBonusRollsMax = 6f; + // Ceiling on the derived multiplier, so an admin who sets the chance to 50% doesn't get a 26x + // drop. The shipped ramp (2/4/6/8/10) tops out at 6x, well inside this. + public const int DefaultMaxMultiplier = 10; + // Vanilla's own per-entry cap (CharacterDrop.GenerateDropList clamps to this before we run). + // CharacterDrop.DropItems instantiates one networked GameObject *per unit* -- it does not stack -- + // so multiplying past vanilla's own ceiling is how one kill turns into four figures of ItemDrops. + // Raising it is the fastest way to make a single kill spawn enough objects to stall the server. + public const int DefaultMaxUnitsPerEntry = 100; + // Belt and braces for creatures (or mods) with many drop entries. + public const int DefaultMaxAddedUnitsPerDeath = 300; + private const string BonusRollsMinKey = "BonusRollsMin"; private const string BonusRollsMaxKey = "BonusRollsMax"; + private const string MaxMultiplierKey = "MaxMultiplier"; + private const string MaxUnitsPerEntryKey = "MaxUnitsPerEntry"; + private const string MaxAddedUnitsPerDeathKey = "MaxAddedUnitsPerDeath"; public static readonly Dictionary DefaultConfig = new Dictionary { { BonusRollsMinKey, DefaultBonusRollsMin }, { BonusRollsMaxKey, DefaultBonusRollsMax }, + { MaxMultiplierKey, DefaultMaxMultiplier }, + { MaxUnitsPerEntryKey, DefaultMaxUnitsPerEntry }, + { MaxAddedUnitsPerDeathKey, DefaultMaxAddedUnitsPerDeath }, }; - // Ceiling on the derived multiplier, so an admin who sets the chance to 50% doesn't get a 26x - // drop. The shipped ramp (2/4/6/8/10) tops out at 6x, well inside this. - private const int MaxMultiplier = 10; - // Vanilla's own per-entry cap (CharacterDrop.GenerateDropList clamps to this before we run). - // CharacterDrop.DropItems instantiates one networked GameObject *per unit* -- it does not stack -- - // so multiplying past vanilla's own ceiling is how one kill turns into four figures of ItemDrops. - private const int MaxUnitsPerEntry = 100; - // Belt and braces for creatures (or mods) with many drop entries. - private const int MaxAddedUnitsPerDeath = 300; + // All three floored at 1: a cap of zero would either delete the drop list or make the proc inert, + // neither of which is a sane tuning outcome. + private static int GetMaxMultiplier() { + return Mathf.Max(1, Mathf.RoundToInt(GetConfigValue(MaxMultiplierKey, DefaultMaxMultiplier))); + } + + private static int GetMaxUnitsPerEntry() { + return Mathf.Max(1, Mathf.RoundToInt(GetConfigValue(MaxUnitsPerEntryKey, DefaultMaxUnitsPerEntry))); + } + + private static int GetMaxAddedUnitsPerDeath() { + return Mathf.Max(1, + Mathf.RoundToInt(GetConfigValue(MaxAddedUnitsPerDeathKey, DefaultMaxAddedUnitsPerDeath))); + } + // Matches Riches. The shard owner has to be in the area for the kill to count. private const float PlayerScanRange = 100f; // The latch only has to survive GenerateDropList -> Ragdoll.Setup, which is the same call stack. @@ -82,7 +105,7 @@ public static void RegisterDisplayValues() { // with 1f and 2f and treats any slot that doesn't change as a constant, so a floor of 2 would // freeze {1} in the generic tooltip. private static int Multiplier(float value) { - return Mathf.Clamp(1 + Mathf.RoundToInt(value * 0.5f), 1, MaxMultiplier); + return Mathf.Clamp(1 + Mathf.RoundToInt(value * 0.5f), 1, GetMaxMultiplier()); } // The creature's owner runs this, and on a dedicated server that machine has no local player at @@ -163,7 +186,7 @@ private static void MultiplyDropList(CharacterDrop characterDrop, var added = 0; for (var i = 0; i < dropList.Count; i++) { - if (added >= MaxAddedUnitsPerDeath) { + if (added >= GetMaxAddedUnitsPerDeath()) { break; } @@ -173,7 +196,7 @@ private static void MultiplyDropList(CharacterDrop characterDrop, } var amount = dropList[i].Value; - var newAmount = Mathf.Min(amount * multiplier, MaxUnitsPerEntry); + var newAmount = Mathf.Min(amount * multiplier, GetMaxUnitsPerEntry()); if (newAmount > amount) { added += newAmount - amount; dropList[i] = new KeyValuePair(prefab, newAmount); diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/MeteorSummoner.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/MeteorSummoner.cs index 725323c24..9f07dcb96 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/MeteorSummoner.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/MeteorSummoner.cs @@ -1,4 +1,5 @@ using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using System.Collections.Generic; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { @@ -6,17 +7,42 @@ namespace EpicLoot.MagicItemEffects.Shards { // blockable damage times the shard's multiplier. public static class MeteorSummoner { private const string MeteorPrefab = "projectile_meteor"; - private const int MaxCharges = 25; // weapon hits required to charge the meteor - private const float SpawnHeight = 20f; // metres above the target the meteor launches from - private const float MinDistance = 5f; // min horizontal offset of the launch point from the target - private const float MaxDistance = 15f; // max horizontal offset of the launch point from the target - private const float ProjectileSpeed = 20f; // metres/second the meteor travels toward the target - private const float ExplosionRadius = 4f; // min AOE radius on impact so a moving target is still caught + + // All tunable in this effect's Config block in config/shardstones.json, under these key names. + public const int DefaultMaxCharges = 25; // weapon hits required to charge the meteor + public const float DefaultSpawnHeight = 20f; // metres above the target the meteor launches from + public const float DefaultMinDistance = 5f; // min horizontal offset of the launch point + public const float DefaultMaxDistance = 15f; // max horizontal offset of the launch point + public const float DefaultProjectileSpeed = 20f; // metres/second the meteor travels + public const float DefaultExplosionRadius = 4f; // min AOE radius so a moving target is still caught + + private const string MaxChargesKey = "MaxCharges"; + private const string SpawnHeightKey = "SpawnHeight"; + private const string MinDistanceKey = "MinDistance"; + private const string MaxDistanceKey = "MaxDistance"; + private const string ProjectileSpeedKey = "ProjectileSpeed"; + private const string ExplosionRadiusKey = "ExplosionRadius"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { MaxChargesKey, DefaultMaxCharges }, + { SpawnHeightKey, DefaultSpawnHeight }, + { MinDistanceKey, DefaultMinDistance }, + { MaxDistanceKey, DefaultMaxDistance }, + { ProjectileSpeedKey, DefaultProjectileSpeed }, + { ExplosionRadiusKey, DefaultExplosionRadius }, + }; + + // Clamped to at least 1 so a misconfiguration can't make the meteor unreachable or fire every hit + // through a zero/negative threshold. + private static int GetMaxCharges() { + return EffectConfig.GetIntAtLeast(MagicEffectType.MeteorSummoner, + MaxChargesKey, DefaultMaxCharges, 1); + } private static int _charges; private static bool _meteorMissingLogged; - // Charge HUD indicator (Yagluth trophy icon showing "n/25"). Built lazily on the first charging hit -- + // Charge HUD indicator (Yagluth trophy icon showing "n/max"). Built lazily on the first charging hit -- // see GetOrCreateIndicator -- so ObjectDB is loaded when the trophy is queried. Its live count is read // by SE_MeteorChargeIndicator through the accessors below. private const string IndicatorName = "EL_MeteorSummonerCharge"; @@ -25,13 +51,13 @@ public static class MeteorSummoner { private static bool _indicatorMissingLogged; public static int CurrentCharges => _charges; - public static int MaxChargeCount => MaxCharges; + public static int MaxChargeCount => GetMaxCharges(); - // Tooltip: "Every {1} weapon hits ... deals {0}x that hit's damage" -- {1} is the charge-count - // const so the shown number stays in sync with the code rather than a baked-in literal. + // Tooltip: "Every {1} weapon hits ... deals {0}x that hit's damage" -- {1} is the configured + // charge count, so the shown number follows a retune instead of the baked-in default. public static void RegisterDisplayValues() { MagicItem.RegisterDisplayValues(MagicEffectType.MeteorSummoner, - value => new object[] { value, (float)MaxCharges }); + value => new object[] { value, (float)GetMaxCharges() }); } // Postfix handler invoked by CharacterDamageDispatch (on-hit reaction). @@ -56,13 +82,13 @@ public static void OnDamageDealt(Character __instance, HitData hit, Character at return; } - if (_charges < MaxCharges) { + if (_charges < GetMaxCharges()) { ++_charges; } // A killing blow at full charge holds the charge rather than spending it, so the meteor always // gets a live target instead of falling on a corpse. - if (_charges < MaxCharges || __instance.IsDead()) { + if (_charges < GetMaxCharges() || __instance.IsDead()) { ShowIndicator(player); return; } @@ -73,6 +99,7 @@ public static void OnDamageDealt(Character __instance, HitData hit, Character at // Launches the meteor from a random point on a ring MinDistance..MaxDistance around the target, lifted // SpawnHeight up, and flies it straight into the target's centre at ProjectileSpeed carrying `fireDamage`. + // All four are read from the effect Config; see the key constants above. private static void SummonMeteor(Player player, Character target, float fireDamage) { var prefab = ZNetScene.instance?.GetPrefab(MeteorPrefab); if (prefab == null) { @@ -85,14 +112,23 @@ private static void SummonMeteor(Player player, Character target, float fireDama var targetPos = target.GetCenterPoint(); + var minDistance = EffectConfig.Get(MagicEffectType.MeteorSummoner, + MinDistanceKey, DefaultMinDistance); + // Held at or above the min so Random.Range can't be handed an inverted span. + var maxDistance = Mathf.Max(minDistance, EffectConfig.Get(MagicEffectType.MeteorSummoner, + MaxDistanceKey, DefaultMaxDistance)); + var angle = Random.Range(0f, Mathf.PI * 2f); - var horizontalDistance = Random.Range(MinDistance, MaxDistance); + var horizontalDistance = Random.Range(minDistance, maxDistance); var spawnPos = targetPos + new Vector3( Mathf.Cos(angle) * horizontalDistance, - SpawnHeight, + EffectConfig.Get(MagicEffectType.MeteorSummoner, SpawnHeightKey, DefaultSpawnHeight), Mathf.Sin(angle) * horizontalDistance); - var velocity = (targetPos - spawnPos).normalized * ProjectileSpeed; + // Floored above zero: a speed of 0 would leave the meteor hanging in the air forever. + var speed = Mathf.Max(0.1f, EffectConfig.Get(MagicEffectType.MeteorSummoner, + ProjectileSpeedKey, DefaultProjectileSpeed)); + var velocity = (targetPos - spawnPos).normalized * speed; var meteor = Object.Instantiate(prefab, spawnPos, Quaternion.LookRotation(velocity)); var projectile = meteor.GetComponent(); @@ -120,7 +156,8 @@ private static void SummonMeteor(Player player, Character target, float fireDama // moving target still gets caught when the meteor lands beside it instead of dead-on. projectile.m_damage = hitData.m_damage; projectile.m_onlySpawnedProjectilesDealDamage = false; - projectile.m_aoe = Mathf.Max(projectile.m_aoe, ExplosionRadius); + projectile.m_aoe = Mathf.Max(projectile.m_aoe, EffectConfig.Get( + MagicEffectType.MeteorSummoner, ExplosionRadiusKey, DefaultExplosionRadius)); } // Adds the charge HUD indicator to the player if it isn't already showing. AddStatusEffect clones the diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/ModerIcyRetribution.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/ModerIcyRetribution.cs index 88a359e93..591ff1ece 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/ModerIcyRetribution.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/ModerIcyRetribution.cs @@ -1,13 +1,27 @@ using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using System.Collections.Generic; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { // Provides a reactive frost nova when the player takes damage, with a scaling damage and cooldown based on rarity. public static class ModerIcyRetribution { - private const float NovaRadius = 8f; - private const float FrostPerTier = 8f; // frost damage per point of value (15..25 -> 120..200) - private const float BaseCooldown = 140f; // cooldown at Epic (the shard's rarity floor) - private const float CooldownPerRarity = 20f; // added per rarity above Epic + // All tunable in this effect's Config block in config/shardstones.json, under these key names. + public const float DefaultRadius = 8f; + public const float DefaultFrostPerTier = 8f; // frost damage per point of value (15..25 -> 120..200) + public const float DefaultBaseCooldown = 140f; // cooldown at Epic (the shard's rarity floor) + public const float DefaultCooldownPerRarity = 20f; // added per rarity above Epic + + private const string RadiusKey = "Radius"; + private const string FrostPerTierKey = "FrostPerTier"; + private const string BaseCooldownKey = "BaseCooldown"; + private const string CooldownPerRarityKey = "CooldownPerRarity"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { RadiusKey, DefaultRadius }, + { FrostPerTierKey, DefaultFrostPerTier }, + { BaseCooldownKey, DefaultBaseCooldown }, + { CooldownPerRarityKey, DefaultCooldownPerRarity }, + }; // Visual: our own trimmed copy of the fenring's ice nova, built and cached by FrostNovaFx so the // fenring's full-length nova is left untouched. Played at the helper's default speed. @@ -41,16 +55,25 @@ public static void OnDamageTaken(Character __instance, HitData hit) { Vector3 playerNovaPosition = player.transform.position; playerNovaPosition.y += 0.6f; FrostNovaFx.Spawn(NovaTemplateName, playerNovaPosition); - DamageInRadius.DamageEnemiesInRadius(player, player.GetCenterPoint(), NovaRadius, - new HitData.DamageTypes { m_frost = value * FrostPerTier }); + DamageInRadius.DamageEnemiesInRadius(player, player.GetCenterPoint(), + EffectConfig.Get(MagicEffectType.IcyRetribution, RadiusKey, DefaultRadius), + new HitData.DamageTypes { + m_frost = value * EffectConfig.Get(MagicEffectType.IcyRetribution, + FrostPerTierKey, DefaultFrostPerTier) + }); ShowCooldown(player, GetCooldown(player)); } - // Cooldown length scales with the highest rarity among the equipped IcyRetribution shards: 140s at - // Epic, +20s for each rarity above it (Legendary 160s, Mythic 180s). + // Cooldown length scales with the highest rarity among the equipped IcyRetribution shards: by default + // 140s at Epic, +20s for each rarity above it (Legendary 160s, Mythic 180s). Floored just above zero: + // a ttl of 0 is "no timeout" to vanilla, which would gate the shard permanently. private static float GetCooldown(Player player) { var stepsAboveEpic = Mathf.Max(0, (int)GetEffectRarity(player) - (int)ItemRarity.Epic); - return BaseCooldown + stepsAboveEpic * CooldownPerRarity; + var baseCooldown = EffectConfig.Get(MagicEffectType.IcyRetribution, + BaseCooldownKey, DefaultBaseCooldown); + var perRarity = EffectConfig.Get(MagicEffectType.IcyRetribution, + CooldownPerRarityKey, DefaultCooldownPerRarity); + return Mathf.Max(0.1f, baseCooldown + stepsAboveEpic * perRarity); } // Highest source rarity among the socketed IcyRetribution effects on the player's equipped magic @@ -109,7 +132,7 @@ private static StatusEffect GetOrCreateCooldownIndicator() { se.name = CooldownName; se.m_name = "$mod_epicloot_se_icyretribution"; se.m_icon = icon; - se.m_ttl = BaseCooldown; // overwritten per-proc by ShowCooldown with the rarity-scaled cooldown + se.m_ttl = DefaultBaseCooldown; // overwritten per-proc by ShowCooldown with the rarity-scaled cooldown se.m_cooldownIcon = true; _cooldownIndicator = se; return _cooldownIndicator; diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/NightCarryWeight.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/NightCarryWeight.cs index 48a24bdc8..5db4be63d 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/NightCarryWeight.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/NightCarryWeight.cs @@ -1,19 +1,15 @@ -using HarmonyLib; -using JetBrains.Annotations; - namespace EpicLoot.MagicItemEffects.Shards { // Provides a bonus to carry weight at night public static class NightCarryWeight { - [HarmonyPatch(typeof(SEMan), nameof(SEMan.ModifyMaxCarryWeight))] - private static class ModifyMaxCarryWeight_Patch { - [UsedImplicitly] - private static void Postfix(SEMan __instance, float baseLimit, ref float limit) { - var player = Player.m_localPlayer; - if (__instance.m_character != player || !EnvMan.IsNight()) { - return; - } + // ModifyMaxCarryWeight handler invoked by SharedSEManModifyMaxCarryWeightPatch. + public static void ModifyMaxCarryWeight(Player player, float baseLimit, ref float limit) { + var pct = player.GetTotalActiveMagicEffectValue(MagicEffectType.NightCarryWeight, 0.01f); + if (pct == 0f) { + return; + } - limit += baseLimit * player.GetTotalActiveMagicEffectValue(MagicEffectType.NightCarryWeight, 0.01f); + if (EnvMan.IsNight()) { + limit += baseLimit * pct; } } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/PerfectDodgeEffects.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/PerfectDodgeEffects.cs index d75e9cb1c..6cf61a393 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/PerfectDodgeEffects.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/PerfectDodgeEffects.cs @@ -1,6 +1,7 @@ using EpicLoot.src.Magic.MagicItemEffects.Helpers; using HarmonyLib; using JetBrains.Annotations; +using System.Collections.Generic; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards @@ -92,8 +93,18 @@ public static void OnPerfectDodge(Player player) // ModifyStaminaRegen override, so vanilla drives it every frame off the live stack count. public static class PerfectDodge { + // How many dodges the buff may stack to, and how long a stack survives without a refresh. Tunable as + // "MaxStacks" and "BuffDuration" in this effect's Config block in config/shardstones.json. public const int DefaultMaxStacks = 5; - private const float BuffDuration = 10f; // seconds the buff lasts / is refreshed to on each perfect dodge + public const float DefaultBuffDuration = 10f; + + private const string MaxStacksKey = "MaxStacks"; + private const string BuffDurationKey = "BuffDuration"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { MaxStacksKey, DefaultMaxStacks }, + { BuffDurationKey, DefaultBuffDuration }, + }; private const string BuffName = "EL_DodgeMomentum"; private static readonly int BuffHash = BuffName.GetStableHashCode(); @@ -124,6 +135,7 @@ public static void OnPerfectDodge(Player player) } var maxStacks = GetMaxStacks(); + var duration = GetBuffDuration(); var seMan = player.GetSEMan(); // Re-proc while the buff is still up: add a stack (capped), restamp the per-stack bonus (the shard @@ -133,6 +145,7 @@ public static void OnPerfectDodge(Player player) existing.Stacks = Mathf.Min(existing.Stacks + 1, maxStacks); existing.MaxStacks = maxStacks; existing.RegenPerStack = regenPerStack; + existing.m_ttl = duration; // restamped so a retuned duration reaches a buff already running existing.ResetTime(); return; } @@ -141,22 +154,22 @@ public static void OnPerfectDodge(Player player) added.Stacks = 1; added.MaxStacks = maxStacks; added.RegenPerStack = regenPerStack; - added.m_ttl = BuffDuration; + added.m_ttl = duration; added.ResetTime(); } } - // Max stacks come from the PerfectDodge magic effect's Config block ("MaxStacks", see - // ShardEffectDefinitions), defaulting to DefaultMaxStacks when unset. Clamped to at least 1 so a - // misconfiguration can't disable the buff. + // Clamped to at least 1 so a misconfiguration can't disable the buff outright. private static int GetMaxStacks() { - var cfg = MagicItemEffectDefinitions.GetEffectConfig(MagicEffectType.PerfectDodge); - if (cfg != null && cfg.TryGetValue("MaxStacks", out var raw)) - { - return Mathf.Max(1, Mathf.RoundToInt(raw)); - } - return DefaultMaxStacks; + return EffectConfig.GetIntAtLeast(MagicEffectType.PerfectDodge, MaxStacksKey, DefaultMaxStacks, 1); + } + + // Floored just above zero: a ttl of 0 is "no timeout" to vanilla, which would make the buff permanent. + private static float GetBuffDuration() + { + return Mathf.Max(0.1f, + EffectConfig.Get(MagicEffectType.PerfectDodge, BuffDurationKey, DefaultBuffDuration)); } // Lazily builds the buff prototype. Runs on a perfect dodge, so the asset bundle is loaded. A null @@ -185,7 +198,7 @@ private static SE_DodgeMomentum GetOrCreatePrototype() se.name = BuffName; se.m_name = "$mod_epicloot_se_dodgemomentum"; se.m_icon = icon; - se.m_ttl = BuffDuration; + se.m_ttl = GetBuffDuration(); // restamped on every proc by OnPerfectDodge _buffPrototype = se; return _buffPrototype; } @@ -218,7 +231,22 @@ private static void Postfix(Player __instance, ref float __result) // percents, hence the 0.01f. public static class PerfectDodgeGivesSpeed { - private const float BuffDuration = 1f; // seconds the speed buff lasts after a perfect dodge + // Seconds the speed buff lasts after a perfect dodge. Tunable as "BuffDuration" in this effect's + // Config block in config/shardstones.json. + public const float DefaultBuffDuration = 1f; + + private const string BuffDurationKey = "BuffDuration"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { BuffDurationKey, DefaultBuffDuration }, + }; + + // Floored just above zero: a ttl of 0 is "no timeout" to vanilla, which would make the burst permanent. + private static float GetBuffDuration() + { + return Mathf.Max(0.1f, EffectConfig.Get(MagicEffectType.PerfectDodgeGivesSpeed, + BuffDurationKey, DefaultBuffDuration)); + } private const string BuffName = "EL_DodgeAgility"; private static readonly int BuffHash = BuffName.GetStableHashCode(); @@ -243,15 +271,18 @@ public static void OnPerfectDodge(Player player) // Re-proc while the buff is still up: restamp the bonus (the shard set may have changed) and // refresh the countdown rather than letting the old, shorter timer run out. + var duration = GetBuffDuration(); if (seMan.GetStatusEffect(BuffHash) is SE_DodgeAgility existing) { existing.SpeedBonus = bonus; + existing.m_ttl = duration; // restamped so a retuned duration reaches a buff already running existing.ResetTime(); return; } if (seMan.AddStatusEffect(prototype) is SE_DodgeAgility added) { added.SpeedBonus = bonus; + added.m_ttl = duration; added.ResetTime(); } } @@ -282,7 +313,7 @@ private static SE_DodgeAgility GetOrCreatePrototype() se.name = BuffName; se.m_name = "$mod_epicloot_se_dodgeagility"; se.m_icon = icon; - se.m_ttl = BuffDuration; + se.m_ttl = GetBuffDuration(); // restamped on every proc by OnPerfectDodge _buffPrototype = se; return _buffPrototype; } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/QueenEverflow.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/QueenEverflow.cs index d304af175..ba450bb51 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/QueenEverflow.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/QueenEverflow.cs @@ -1,4 +1,5 @@ using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using System.Collections.Generic; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { @@ -9,8 +10,18 @@ namespace EpicLoot.MagicItemEffects.Shards { // Kill detection reads the target's health after the hit, which is exact when the player owns the enemy // (single-player, or enemies close to the host); against remote-owned enemies it is best-effort. public static class QueenEverflow { + // How many kills the buff may stack to, and how long a stack survives without a refresh. Tunable as + // "MaxStacks" and "BuffDuration" in this effect's Config block in config/shardstones.json. public const int DefaultMaxStacks = 10; - private const float BuffDuration = 10f; // standard duration the buff lasts / is refreshed to on each kill + public const float DefaultBuffDuration = 10f; + + private const string MaxStacksKey = "MaxStacks"; + private const string BuffDurationKey = "BuffDuration"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { MaxStacksKey, DefaultMaxStacks }, + { BuffDurationKey, DefaultBuffDuration }, + }; private const string BuffName = "EL_QueenEverflow"; private static readonly int BuffHash = BuffName.GetStableHashCode(); @@ -51,12 +62,14 @@ private static void ApplyOrStack(Player player, float regenPerStack) { } var maxStacks = GetMaxStacks(); + var duration = GetBuffDuration(); var seMan = player.GetSEMan(); if (seMan.GetStatusEffect(BuffHash) is SE_QueenEverflow existing) { existing.Stacks = Mathf.Min(existing.Stacks + 1, maxStacks); existing.MaxStacks = maxStacks; existing.RegenPerStack = regenPerStack; + existing.m_ttl = duration; // restamped so a retuned duration reaches a buff already running existing.ResetTime(); // refresh back to the standard duration return; } @@ -65,19 +78,20 @@ private static void ApplyOrStack(Player player, float regenPerStack) { added.Stacks = 1; added.MaxStacks = maxStacks; added.RegenPerStack = regenPerStack; - added.m_ttl = BuffDuration; + added.m_ttl = duration; added.ResetTime(); } } - // Max stacks come from the Everflow magic effect's Config block ("MaxStacks"), defaulting to - // DefaultMaxStacks when unset. Clamped to at least 1 so a misconfiguration can't disable the buff. + // Clamped to at least 1 so a misconfiguration can't disable the buff outright. private static int GetMaxStacks() { - var cfg = MagicItemEffectDefinitions.GetEffectConfig(MagicEffectType.Everflow); - if (cfg != null && cfg.TryGetValue("MaxStacks", out var raw)) { - return Mathf.Max(1, Mathf.RoundToInt(raw)); - } - return DefaultMaxStacks; + return EffectConfig.GetIntAtLeast(MagicEffectType.Everflow, MaxStacksKey, DefaultMaxStacks, 1); + } + + // Floored just above zero: a ttl of 0 is "no timeout" to vanilla, which would make the buff permanent. + private static float GetBuffDuration() { + return Mathf.Max(0.1f, + EffectConfig.Get(MagicEffectType.Everflow, BuffDurationKey, DefaultBuffDuration)); } // Lazily builds the buff prototype. Runs on a kill, so ObjectDB is loaded and the Queen (Seeker Queen) @@ -102,7 +116,7 @@ private static SE_QueenEverflow GetOrCreatePrototype() { se.name = BuffName; se.m_name = "$mod_epicloot_se_queeneverflow"; se.m_icon = icon; - se.m_ttl = BuffDuration; + se.m_ttl = GetBuffDuration(); // restamped on every apply/refresh by ApplyOrStack _buffPrototype = se; return _buffPrototype; } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/RunningOnEmpty.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/RunningOnEmpty.cs index 7e8e5b1ef..798c65c66 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/RunningOnEmpty.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/RunningOnEmpty.cs @@ -1,9 +1,26 @@ -using UnityEngine; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using System.Collections.Generic; +using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { // Burns a chunk of health to refill an amount of stamina, cooldown based public static class RunningOnEmpty { - private const float Cooldown = 30f; + // Seconds between charges. Tunable as "Cooldown" in this effect's Config block in + // config/shardstones.json. + public const float DefaultCooldown = 30f; + + private const string CooldownKey = "Cooldown"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { CooldownKey, DefaultCooldown }, + }; + + // Floored just above zero: a cooldown of 0 would give the indicator a zero ttl, which vanilla + // treats as "no timeout", leaving the shard permanently gated instead of permanently ready. + private static float GetCooldown() { + return Mathf.Max(0.1f, + EffectConfig.Get(MagicEffectType.RunningOnEmpty, CooldownKey, DefaultCooldown)); + } // Below this the charge isn't worth burning; firing anyway would spend the whole cooldown on nothing. private const float Epsilon = 0.01f; @@ -53,11 +70,14 @@ public static void TryRefill(Player player, float fraction) { ShowCooldown(player); } - // Adds the recharge indicator to the player; its lifetime (m_ttl = Cooldown) is the cooldown. - // Activation is gated on the effect's absence, so it's never already present here. + // Adds the recharge indicator to the player; its lifetime (m_ttl) is the cooldown. Activation is + // gated on the effect's absence, so it's never already present here. The ttl is stamped here + // rather than at construction because the prototype is built once and cached, so a retuned + // cooldown would otherwise not take hold until the next game session. private static void ShowCooldown(Player player) { var indicator = GetOrCreateCooldownIndicator(); if (indicator != null) { + indicator.m_ttl = GetCooldown(); player.GetSEMan().AddStatusEffect(indicator, true); } } @@ -85,7 +105,7 @@ private static StatusEffect GetOrCreateCooldownIndicator() { se.name = CooldownName; se.m_name = "$mod_epicloot_se_runningonempty"; se.m_icon = icon; - se.m_ttl = Cooldown; + se.m_ttl = GetCooldown(); // restamped on every proc by ShowCooldown se.m_cooldownIcon = true; _cooldownIndicator = se; return _cooldownIndicator; diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/StaminaIncreaseForMovementPenalty.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/StaminaIncreaseForMovementPenalty.cs index 31654ab3c..968b70b1d 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/StaminaIncreaseForMovementPenalty.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/StaminaIncreaseForMovementPenalty.cs @@ -15,8 +15,14 @@ public static void Apply(Player player, ref float stamina) return; } - stamina += stamina * player.GetTotalActiveMagicEffectValue( - MagicEffectType.StaminaIncreaseForMovementPenalty, 0.01f) * PenaltyScaling.MovementPenaltyFactor(player); + var pct = player.GetTotalActiveMagicEffectValue( + MagicEffectType.StaminaIncreaseForMovementPenalty, 0.01f); + if (pct == 0f) + { + return; + } + + stamina += stamina * pct * PenaltyScaling.MovementPenaltyFactor(player); } } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/StaminaRegenBonusFromPlayerWeight.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/StaminaRegenBonusFromPlayerWeight.cs index 95d87dd82..97d302daf 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/StaminaRegenBonusFromPlayerWeight.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/StaminaRegenBonusFromPlayerWeight.cs @@ -9,8 +9,15 @@ public static void Apply(SEMan seman, ref float staminaMultiplier) { return; } - staminaMultiplier += player.GetTotalActiveMagicEffectValue( - MagicEffectType.StaminaRegenBonusFromPlayerWeight, 0.01f) * PenaltyScaling.WeightFactor(player); + var pct = player.GetTotalActiveMagicEffectValue( + MagicEffectType.StaminaRegenBonusFromPlayerWeight, 0.01f); + if (pct == 0f) { + return; + } + + // WeightFactor calls GetMaxCarryWeight, which re-enters the whole ModifyMaxCarryWeight + // handler chain. ModifyStaminaRegen runs every tick, so it stays behind the memoized lookup. + staminaMultiplier += pct * PenaltyScaling.WeightFactor(player); } } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/StormFury.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/StormFury.cs index d85437489..5758097a6 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/StormFury.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/StormFury.cs @@ -1,23 +1,39 @@ -using HarmonyLib; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using HarmonyLib; using JetBrains.Annotations; +using System.Collections.Generic; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { // Storm based adrenaline pulse. Grants a flat amount of adrenaline every 10 seconds while in a storm and suppresses adrenaline decay public static class StormFury { - internal const float TickInterval = 10f; // seconds between adrenaline pulses + // Seconds between adrenaline pulses. Tunable as "TickInterval" in this effect's Config block in + // config/shardstones.json; StormFuryPulse re-arms its InvokeRepeating when this changes. + public const float DefaultTickInterval = 10f; + + private const string TickIntervalKey = "TickInterval"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { TickIntervalKey, DefaultTickInterval }, + }; + + // Floored well above zero: InvokeRepeating with a tiny period would pulse every frame. + internal static float GetTickInterval() { + return Mathf.Max(0.5f, + EffectConfig.Get(MagicEffectType.StormFury, TickIntervalKey, DefaultTickInterval)); + } // What m_adrenalineDegenTimer is held at while suppression is active. Anything above a frame's dt // stops the degen branch; keeping it small also caps the leftover grace once the storm ends (or the // shard comes off) at ~1 second. private const float DegenPin = 1f; - // Tooltip: "+{0} Adrenaline every {1}s in Storms, No Adrenaline Decay" -- {1} is the interval const - // so the shown number stays in sync with the code rather than a baked-in literal. + // Tooltip: "+{0} Adrenaline every {1}s in Storms, No Adrenaline Decay" -- {1} is the configured + // interval, so the shown number follows a retune instead of staying at the baked-in default. public static void RegisterDisplayValues() { MagicItem.RegisterDisplayValues(MagicEffectType.StormFury, - value => new object[] { value, TickInterval }); + value => new object[] { value, GetTickInterval() }); } // One payout tick. Called by StormFuryPulse only after it has confirmed a storm and that the local @@ -74,14 +90,30 @@ internal static void Create() { go.AddComponent(); } + // The period InvokeRepeating was last armed with. InvokeRepeating fixes its period at scheduling + // time, so a retuned TickInterval only takes hold once the invoke is cancelled and re-armed. + private float _scheduledInterval; + [UsedImplicitly] private void Awake() { instance = this; - InvokeRepeating(nameof(Pulse), StormFury.TickInterval, StormFury.TickInterval); + Reschedule(); + } + + private void Reschedule() { + _scheduledInterval = StormFury.GetTickInterval(); + CancelInvoke(nameof(Pulse)); + InvokeRepeating(nameof(Pulse), _scheduledInterval, _scheduledInterval); } [UsedImplicitly] private void Pulse() { + // Cheapest place guaranteed to run after a config reload, and it costs one float compare on a + // call that already only happens every few seconds. + if (!Mathf.Approximately(_scheduledInterval, StormFury.GetTickInterval())) { + Reschedule(); + } + var player = Player.m_localPlayer; if (player == null || !StormRider.IsStorm()) { return; diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/StrikeCausesLightning.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/StrikeCausesLightning.cs index 59b58f88e..b76340a1d 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/StrikeCausesLightning.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/StrikeCausesLightning.cs @@ -1,10 +1,22 @@ using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using System.Collections.Generic; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { // Provides a chance for the player to cause lightning strikes on enemies they hit public static class StrikeCausesLightning { - private const float DamagePerValue = 6f; // lightning damage per shard-value point + // Lightning damage per shard-value point, and the blast radius of the strike. Both tunable in this + // effect's Config block in config/shardstones.json, under these key names. + public const float DefaultDamagePerValue = 6f; + public const float DefaultRadius = 1f; + + private const string DamagePerValueKey = "DamagePerValue"; + private const string RadiusKey = "Radius"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { DamagePerValueKey, DefaultDamagePerValue }, + { RadiusKey, DefaultRadius }, + }; // Visual: our networked, damage-free clone of the vanilla lightning-rod AOE. Built once and registered // into ZNetScene on every client each world load (RegisterVisualPrefab), then instantiated at each proc; @@ -92,7 +104,12 @@ public static void OnDamageDealt(Character __instance, HitData hit, Character at // Specifically set the damage source to null which will become ZDOID.NONE, because we do not want to chain these strikes, each lightning strike requires a new proc from the player. // If we set the source to the player, then the lightning strike will be considered a player attack and can chain to other enemies in range, which is not what we want. - DamageInRadius.DamageEnemiesInRadius(null, __instance.transform.position, 1f, new HitData.DamageTypes { m_lightning = value * DamagePerValue }); + DamageInRadius.DamageEnemiesInRadius(null, __instance.transform.position, + EffectConfig.Get(MagicEffectType.StrikeCausesLightning, RadiusKey, DefaultRadius), + new HitData.DamageTypes { + m_lightning = value * EffectConfig.Get(MagicEffectType.StrikeCausesLightning, + DamagePerValueKey, DefaultDamagePerValue) + }); SpawnVisual(__instance.transform.position); } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/SummonBatWhenActivatingAdrenaline.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/SummonBatWhenActivatingAdrenaline.cs index d2c44dbd8..2723a373f 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/SummonBatWhenActivatingAdrenaline.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/SummonBatWhenActivatingAdrenaline.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using System.Collections.Generic; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { @@ -6,9 +7,30 @@ namespace EpicLoot.MagicItemEffects.Shards { public static class SummonBatWhenActivatingAdrenaline { private const string BatSourcePrefab = "Bat"; // vanilla creature we clone private const string TamedBatPrefab = "EL_TamedBat"; // our registered player-faction/tamed clone - private const float BatLifetime = 30f; // seconds a summoned bat lives (TimedDestruction) - private const float SummonCooldown = 10f; - private const float SpawnRadius = 2f; // ring radius bats spawn on around the player + + // All tunable in this effect's Config block in config/shardstones.json, under these key names. + // The summon count is the shard value plus SummonCountOffset, and at most ConcurrentPerSummon times + // that many bats may be alive at once. BatLifetime seeds the cached clone's TimedDestruction, which + // is baked into the registered prefab, so a change to it lands on the next game session. + public const float DefaultBatLifetime = 30f; // seconds a summoned bat lives (TimedDestruction) + public const float DefaultCooldown = 10f; // seconds between summons + public const float DefaultSpawnRadius = 2f; // ring radius bats spawn on around the player + public const float DefaultSummonCountOffset = -1f; // added to the rounded shard value + public const float DefaultConcurrentPerSummon = 2f; // live-bat cap as a multiple of the summon count + + private const string BatLifetimeKey = "BatLifetime"; + private const string CooldownKey = "Cooldown"; + private const string SpawnRadiusKey = "SpawnRadius"; + private const string SummonCountOffsetKey = "SummonCountOffset"; + private const string ConcurrentPerSummonKey = "ConcurrentPerSummon"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { BatLifetimeKey, DefaultBatLifetime }, + { CooldownKey, DefaultCooldown }, + { SpawnRadiusKey, DefaultSpawnRadius }, + { SummonCountOffsetKey, DefaultSummonCountOffset }, + { ConcurrentPerSummonKey, DefaultConcurrentPerSummon }, + }; private static float _lastSummonTime = -999f; @@ -25,7 +47,8 @@ public static class SummonBatWhenActivatingAdrenaline { // Called by SharedPlayerAddAdrenalinePatch, which owns the Player.AddAdrenaline patch and the // fill/pop detection (including the local-player and no-adrenaline-source guards). public static void OnAdrenalineActivated(Player player) { - if (Time.time - _lastSummonTime < SummonCooldown) { + if (Time.time - _lastSummonTime < EffectConfig.Get( + MagicEffectType.SummonBatWhenActivatingAdrenaline, CooldownKey, DefaultCooldown)) { return; } @@ -86,7 +109,8 @@ private static GameObject GetOrBuildTemplate(ZNetScene zns) { } var timed = template.GetComponent() ?? template.AddComponent(); - timed.m_timeout = BatLifetime; + timed.m_timeout = EffectConfig.Get(MagicEffectType.SummonBatWhenActivatingAdrenaline, + BatLifetimeKey, DefaultBatLifetime); timed.m_triggerOnAwake = true; _batTemplate = template; @@ -109,8 +133,13 @@ private static void SummonBats(Player player, float value) { return; } - var summonCount = Mathf.Max(1, Mathf.RoundToInt(value) - 1); - var maxBats = summonCount * 2; + var summonCount = Mathf.Max(1, Mathf.RoundToInt(value + EffectConfig.Get( + MagicEffectType.SummonBatWhenActivatingAdrenaline, + SummonCountOffsetKey, DefaultSummonCountOffset))); + // At least one summon's worth of headroom, or a proc could never place a bat. + var maxBats = Mathf.Max(summonCount, Mathf.RoundToInt(summonCount * EffectConfig.Get( + MagicEffectType.SummonBatWhenActivatingAdrenaline, + ConcurrentPerSummonKey, DefaultConcurrentPerSummon))); // Drop bats that have already despawned/been destroyed before counting toward the cap. _activeBats.RemoveAll(bat => bat == null); @@ -118,7 +147,9 @@ private static void SummonBats(Player player, float value) { var basePos = player.transform.position + Vector3.up; for (var i = 0; i < summonCount; i++) { var angle = Mathf.PI * 2f * i / summonCount; - var spawnPos = basePos + new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * SpawnRadius; + var spawnPos = basePos + new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) + * EffectConfig.Get(MagicEffectType.SummonBatWhenActivatingAdrenaline, + SpawnRadiusKey, DefaultSpawnRadius); var bat = Object.Instantiate(prefab, spawnPos, Quaternion.identity); // Faction/tamed/lifetime are baked into the prefab; only the runtime follow target is per-instance. diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/Trailblazer.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/Trailblazer.cs index 3a57d676b..90ab4a3ec 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/Trailblazer.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/Trailblazer.cs @@ -1,4 +1,5 @@ -using HarmonyLib; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using HarmonyLib; using JetBrains.Annotations; using System.Collections.Generic; using UnityEngine; @@ -6,12 +7,37 @@ namespace EpicLoot.MagicItemEffects.Shards { // Lays a trail of burning behind the player while moving, damage scaled by the effect value public static class Trailblazer { - // Tuning knobs (placeholders; balance later). - private const float MinMoveSpeed = 1.5f; // horizontal speed required to lay a trail - private const float SpawnInterval = 0.35f; // seconds between dropping trail patches - private const float PatchRadius = 2.5f; // burn radius of each patch - private const float PatchLifetime = 3f; // how long a patch lingers - private const float PatchTickInterval = 0.5f; // seconds between burn ticks within a patch + // Tuning knobs, all read from this effect's Config block in config/shardstones.json under these key + // names. PatchLifetime additionally seeds the cached VFX template's particle duration and + // TimedDestruction timeout; those are baked into the registered prefab, so a lifetime raised past the + // default makes the burn outlast its visual until the next game session. Lowering it is exact, + // because TrailblazerFire.Init stamps the live value on each spawned patch. + public const float DefaultMinMoveSpeed = 1.5f; // horizontal speed required to lay a trail + public const float DefaultSpawnInterval = 0.35f; // seconds between dropping trail patches + public const float DefaultPatchRadius = 2.5f; // burn radius of each patch + public const float DefaultPatchLifetime = 3f; // how long a patch lingers + public const float DefaultPatchTickInterval = 0.5f; // seconds between burn ticks within a patch + + private const string MinMoveSpeedKey = "MinMoveSpeed"; + private const string SpawnIntervalKey = "SpawnInterval"; + private const string PatchRadiusKey = "PatchRadius"; + private const string PatchLifetimeKey = "PatchLifetime"; + private const string PatchTickIntervalKey = "PatchTickInterval"; + + public static readonly Dictionary DefaultConfig = new Dictionary { + { MinMoveSpeedKey, DefaultMinMoveSpeed }, + { SpawnIntervalKey, DefaultSpawnInterval }, + { PatchRadiusKey, DefaultPatchRadius }, + { PatchLifetimeKey, DefaultPatchLifetime }, + { PatchTickIntervalKey, DefaultPatchTickInterval }, + }; + + // Floored just above zero: a lifetime of 0 would destroy each patch before it could tick. + private static float GetPatchLifetime() { + return Mathf.Max(0.1f, + EffectConfig.Get(MagicEffectType.Trailblazer, PatchLifetimeKey, DefaultPatchLifetime)); + } + private const string VfxSourcePrefab = "vfx_FireAddFuel"; // vanilla prefab we clone private const string VfxClonePrefab = "EL_TrailblazerFire"; // our registered clone private static GameObject _vfxContainer; // disabled parent that keeps the template from Awaking @@ -82,8 +108,9 @@ private static GameObject GetOrBuildTemplate(ZNetScene zns) { emission.rateOverTime = 2f; var main = firePs.main; - if (main.duration < PatchLifetime) { - main.duration = PatchLifetime; + var lifetime = GetPatchLifetime(); + if (main.duration < lifetime) { + main.duration = lifetime; } } else { EpicLoot.LogWarning($"Trailblazer: '{VfxSourcePrefab}' has no 'fire' particle system; skipping emission tweak."); @@ -95,7 +122,7 @@ private static GameObject GetOrBuildTemplate(ZNetScene zns) { // The vanilla vfx already tears itself down with a TimedDestruction; align it with the patch // lifetime so the visual and the burn expire together. var timed = template.GetComponent() ?? template.AddComponent(); - timed.m_timeout = PatchLifetime; + timed.m_timeout = GetPatchLifetime(); timed.m_triggerOnAwake = true; _vfxTemplate = template; @@ -121,14 +148,17 @@ private static void Postfix(Player __instance) { // Only lay a trail while actually moving along the ground. var velocity = __instance.GetVelocity(); velocity.y = 0f; - if (velocity.magnitude < MinMoveSpeed || !__instance.IsOnGround()) { + if (velocity.magnitude < EffectConfig.Get(MagicEffectType.Trailblazer, + MinMoveSpeedKey, DefaultMinMoveSpeed) || !__instance.IsOnGround()) { return; } if (_spawnTimer > 0f) { return; } - _spawnTimer = SpawnInterval; + // Floored above zero so a misconfiguration cannot drop a patch every frame. + _spawnTimer = Mathf.Max(0.05f, EffectConfig.Get(MagicEffectType.Trailblazer, + SpawnIntervalKey, DefaultSpawnInterval)); var prefab = ZNetScene.instance != null ? ZNetScene.instance.GetPrefab(VfxClonePrefab) : null; if (prefab == null) { @@ -140,8 +170,12 @@ private static void Postfix(Player __instance) { } var go = Object.Instantiate(prefab, __instance.transform.position, Quaternion.identity); - go.GetComponent() - .Init(__instance, tickDamage, PatchRadius, PatchLifetime, PatchTickInterval); + go.GetComponent().Init(__instance, tickDamage, + EffectConfig.Get(MagicEffectType.Trailblazer, PatchRadiusKey, DefaultPatchRadius), + GetPatchLifetime(), + // Floored above zero: a tick interval of 0 would burn every frame. + Mathf.Max(0.05f, EffectConfig.Get(MagicEffectType.Trailblazer, + PatchTickIntervalKey, DefaultPatchTickInterval))); } } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/TravelLight.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/TravelLight.cs index a02f0b67f..efe9372ea 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/TravelLight.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/TravelLight.cs @@ -1,21 +1,44 @@ -using HarmonyLib; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using HarmonyLib; using JetBrains.Annotations; +using System.Collections.Generic; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { // Provides a bonus to move speed at the cost of carry weight public static class TravelLight { - // Max carry weight removed per 1 point of shard value (i.e. per 1% of move speed gained). - private const float CarryWeightPerValue = 10f; + // Max carry weight removed per 1 point of shard value (i.e. per 1% of move speed gained), and the + // backstop that stops stacked sources pushing the carry cap to zero or below. Tunable as + // "CarryWeightPerValue" and "MinResultingCarryWeight" in this effect's Config block in + // config/shardstones.json. + public const float DefaultCarryWeightPerValue = 10f; + public const float DefaultMinResultingCarryWeight = 50f; - // Backstop so stacked sources can't push the carry cap to zero or below. - private const float MinResultingCarryWeight = 50f; + private const string CarryWeightPerValueKey = "CarryWeightPerValue"; + private const string MinResultingCarryWeightKey = "MinResultingCarryWeight"; - // Tooltip: "+{0}% Move Speed, -{1} Carry Weight" -- {1} is derived from the rolled value so the - // shown cost stays in sync with the code rather than a baked-in literal. + public static readonly Dictionary DefaultConfig = new Dictionary { + { CarryWeightPerValueKey, DefaultCarryWeightPerValue }, + { MinResultingCarryWeightKey, DefaultMinResultingCarryWeight }, + }; + + // Floored at zero: a negative rate would hand out carry weight rather than trade it away. + private static float GetCarryWeightPerValue() { + return Mathf.Max(0f, EffectConfig.Get(MagicEffectType.TravelLight, + CarryWeightPerValueKey, DefaultCarryWeightPerValue)); + } + + // Floored at 1: a cap of zero would leave the player permanently encumbered. + private static float GetMinResultingCarryWeight() { + return Mathf.Max(1f, EffectConfig.Get(MagicEffectType.TravelLight, + MinResultingCarryWeightKey, DefaultMinResultingCarryWeight)); + } + + // Tooltip: "+{0}% Move Speed, -{1} Carry Weight" -- {1} is derived from the rolled value and the + // configured rate, so the shown cost follows a retune instead of the baked-in default. public static void RegisterDisplayValues() { MagicItem.RegisterDisplayValues(MagicEffectType.TravelLight, - value => new object[] { value, value * CarryWeightPerValue }); + value => new object[] { value, value * GetCarryWeightPerValue() }); } [HarmonyPatch(typeof(SEMan), nameof(SEMan.ApplyStatusEffectSpeedMods))] @@ -34,21 +57,19 @@ private static void Postfix(SEMan __instance, ref float speed) { } } - [HarmonyPatch(typeof(SEMan), nameof(SEMan.ModifyMaxCarryWeight))] - private static class ModifyMaxCarryWeight_Patch { - [UsedImplicitly] - private static void Postfix(SEMan __instance, ref float limit) { - var player = Player.m_localPlayer; - if (__instance.m_character != player) { - return; - } - - var reduction = player.GetTotalActiveMagicEffectValue( - MagicEffectType.TravelLight, CarryWeightPerValue); - if (reduction > 0f) { - limit = Mathf.Max(limit - reduction, MinResultingCarryWeight); - } + // ModifyMaxCarryWeight handler invoked by SharedSEManModifyMaxCarryWeightPatch, which runs it + // last: the MinResultingCarryWeight clamp measures against the running total, so it has to see + // every bonus the other handlers added. + public static void ModifyMaxCarryWeight(Player player, ref float limit) { + // Read the memoized effect value before touching config: this hangs off GetMaxCarryWeight, + // which vanilla reaches at 50Hz via UpdateStats -> IsEncumbered, and most players carry no + // TravelLight shard at all. The config lookups only happen once that check passes. + var value = player.GetTotalActiveMagicEffectValue(MagicEffectType.TravelLight); + if (value <= 0f) { + return; } + + limit = Mathf.Max(limit - value * GetCarryWeightPerValue(), GetMinResultingCarryWeight()); } } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Slow.cs b/EpicLoot/src/Magic/MagicItemEffects/Slow.cs index ee381db50..304a7c6b0 100755 --- a/EpicLoot/src/Magic/MagicItemEffects/Slow.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Slow.cs @@ -9,14 +9,50 @@ public sealed class Slow : MonoBehaviour { public const string RPCKey = "epic loot slow"; - public float Multiplier; + // Slow values are summed across every equipped magic item (including socketed shards), so a heavily + // enchanted setup can total 100% or more. Unclamped that yields a multiplier of zero or negative, + // which freezes the target outright and leaves its speeds at 0/NaN/negative once the slow expires. + // Clamping to a floor keeps the target moving and keeps the restored speeds sane. + // + // Deliberately a const and not a config value: the multiplier travels over the RPC and is re-clamped + // on receipt, so every client has to agree on the bound or they would simulate different speeds. + public const float MinMultiplier = 0.1f; + + public static float ClampMultiplier(float multiplier) => Mathf.Clamp(multiplier, MinMultiplier, 1f); + + public float Multiplier = 1f; public float TimeToLive; private Character _character; + private bool _applied; + private float _acceleration; + private float _runSpeed; + private float _flyFastSpeed; + private float _swimSpeed; public void Start() { _character = GetComponent(); + if (_character == null) + { + Destroy(this); + return; + } + + // Clamp here as well as at the call site: RPC_Slow takes the multiplier off the wire, so an out + // of range value can arrive from a mismatched or modified client. + Multiplier = ClampMultiplier(Multiplier); + + // Snapshot the originals and restore them verbatim instead of dividing the multiplier back out. + // Division only approximately recovers the starting value, so repeated slows drift the + // character's speeds, and it has no answer at all if Multiplier is ever zero. Vanilla treats + // these four fields as prefab constants and never writes them at runtime, so nothing else is + // competing for them. + _acceleration = _character.m_acceleration; + _runSpeed = _character.m_runSpeed; + _flyFastSpeed = _character.m_flyFastSpeed; + _swimSpeed = _character.m_swimSpeed; + _applied = true; _character.m_acceleration *= Multiplier; _character.m_runSpeed *= Multiplier; @@ -33,13 +69,26 @@ public void FixedUpdate() return; } - _character.m_acceleration /= Multiplier; - _character.m_runSpeed /= Multiplier; - _character.m_flyFastSpeed /= Multiplier; - _character.m_swimSpeed /= Multiplier; - + // OnDestroy performs the restore, so it runs exactly once however the component goes away. Destroy(this); } + + public void OnDestroy() + { + // _applied is false when Start never ran (destroyed the same frame it was added), in which case + // the speeds were never scaled and there is nothing to put back. + if (!_applied || _character == null) + { + return; + } + + _applied = false; + + _character.m_acceleration = _acceleration; + _character.m_runSpeed = _runSpeed; + _character.m_flyFastSpeed = _flyFastSpeed; + _character.m_swimSpeed = _swimSpeed; + } } [HarmonyPatch(typeof(Character), nameof(Character.Awake))] @@ -53,6 +102,17 @@ private static void Postfix(Character __instance) private static void RPC_Slow(Character character, float multiplier) { + if (character == null) + { + return; + } + + multiplier = Slow.ClampMultiplier(multiplier); + if (Mathf.Approximately(multiplier, 1f)) + { + return; + } + if (!character.TryGetComponent(out Slow slow)) { slow = character.gameObject.AddComponent(); @@ -72,8 +132,13 @@ public static class ApplySlow_Character_Damage_Patch // local player also owned the target. public static void OnDamageDealt(Character __instance, HitData hit, Character attacker) { - if (__instance == null || __instance.m_nview == null || attacker != Player.m_localPlayer - || __instance.IsBoss()) + // IsValid() (not just a null check) is required: Character.Damage routes RPC_Damage synchronously + // when the local client owns the target, so the target can already have died and been destroyed by + // the time this postfix runs. ZNetScene.Destroy nulls the ZDO immediately while the ZNetView + // component itself only compares null at the end of the frame -- InvokeRPC would then dereference + // the null m_zdo. + if (__instance == null || __instance.m_nview == null || !__instance.m_nview.IsValid() + || attacker != Player.m_localPlayer || __instance.IsBoss()) { return; } @@ -83,7 +148,8 @@ public static void OnDamageDealt(Character __instance, HitData hit, Character at return; } - float slowMultiplier = 1 - effectValue; + // Clamp before sending so the value on the wire is already the one every receiver will use. + float slowMultiplier = Slow.ClampMultiplier(1 - effectValue); if (!Mathf.Approximately(slowMultiplier, 1)) { __instance.m_nview.InvokeRPC(ZRoutedRpc.Everybody, Slow.RPCKey, slowMultiplier); diff --git a/EpicLoot/src/Magic/PlayerExtensions.cs b/EpicLoot/src/Magic/PlayerExtensions.cs index d492135df..89e949c04 100644 --- a/EpicLoot/src/Magic/PlayerExtensions.cs +++ b/EpicLoot/src/Magic/PlayerExtensions.cs @@ -79,11 +79,17 @@ public static HashSet GetEquippedSets(this Player player) public static float GetTotalActiveMagicEffectValue(this Player player, string effectType, float scale = 1.0f, ItemDrop.ItemData ignoreThisItem = null) { - float totalValue = scale * (EquipmentEffectCache.Get(player, effectType, () => + // TryGetValue/Store rather than the Func overload: this runs several times per fixed tick from + // GetMaxCarryWeight, ModifyStaminaRegen and GetArmor, and the closure that overload's delegate + // captures would be allocated on every one of those calls even though nearly all of them hit. + if (!EquipmentEffectCache.TryGetValue(player, effectType, out float? cached)) { List allEffects = player.GetAllActiveMagicEffects(effectType); - return allEffects.Count > 0 ? allEffects.Select(x => x.EffectValue).Sum() : null; - }) ?? 0); + cached = allEffects.Count > 0 ? allEffects.Select(x => x.EffectValue).Sum() : (float?)null; + EquipmentEffectCache.Store(player, effectType, cached); + } + + float totalValue = scale * (cached ?? 0); if (ignoreThisItem != null && player.IsItemEquiped(ignoreThisItem) && ignoreThisItem.IsMagic(out MagicItem magicItem)) { diff --git a/EpicLoot/src/Patching/FilePatching.cs b/EpicLoot/src/Patching/FilePatching.cs index d2def2f3d..a7f28fba2 100755 --- a/EpicLoot/src/Patching/FilePatching.cs +++ b/EpicLoot/src/Patching/FilePatching.cs @@ -73,7 +73,12 @@ public static class FilePatching ]; public static MultiValueDictionary PatchesPerFile = new MultiValueDictionary(); - public static void ReloadAndApplyAllPatches() + /// + /// Rebuilds every patched config file on disk. Returns the target names (no extension) whose + /// files were rewritten, so the caller can pull exactly those back into the live config -- + /// the per-file watchers report the same writes, but only asynchronously. + /// + public static List ReloadAndApplyAllPatches() { // Remember which targets HAD patches: a file whose last patch was just deleted must be // rebuilt from the embedded default, or it keeps the stale patched output forever. @@ -82,6 +87,8 @@ public static void ReloadAndApplyAllPatches() LoadAllPatches(); ApplyAllPatches(); + List rewritten = new List(Keys); + foreach (string target in previousTargets) { if (PatchesPerFile.GetValues(target, true).Count == 0) @@ -89,8 +96,12 @@ public static void ReloadAndApplyAllPatches() EpicLoot.LogForce($"All patches targeting '{target}' were removed; restoring the unpatched default."); string baseCfgFile = Path.Combine(ELConfig.GetOverhaulDirectoryPath(), $"{target}.json"); ELConfig.CreateBaseConfigurations(baseCfgFile, $"{target}.json"); + // Not in Keys any more (its patches are gone), so it cannot already be in the list. + rewritten.Add(target); } } + + return rewritten; } private static IEnumerable Keys => PatchesPerFile.Keys; diff --git a/EpicLoot/src/Patching/PatchReloadDebouncer.cs b/EpicLoot/src/Patching/PatchReloadDebouncer.cs new file mode 100644 index 000000000..d15d9c3d0 --- /dev/null +++ b/EpicLoot/src/Patching/PatchReloadDebouncer.cs @@ -0,0 +1,59 @@ +using EpicLoot.Config; +using JetBrains.Annotations; +using System; +using UnityEngine; + +namespace EpicLoot.Patching +{ + /// + /// Collapses the burst of file events one patch edit produces into a single rebuild. + /// + /// A save is rarely one event: editors write, flush and touch metadata separately, atomic savers + /// write a temp file and rename it, and saving several patches at once raises a set per file. + /// Rebuilding on each of them re-reads every patch and rewrites every target config -- expensive, + /// and it makes the game hitch mid-edit. + /// + /// Trailing edge, deliberately: the first event of a save often arrives while the file is still + /// empty or half written, so acting on it and ignoring the rest (a leading-edge debounce) would + /// parse a truncated patch and then never look again. + /// + internal class PatchReloadDebouncer : MonoBehaviour + { + /// How long the patches folder must be quiet before the rebuild runs. + private const float QuietPeriodSeconds = 0.5f; + + private static PatchReloadDebouncer _instance; + + /// Requests a rebuild, pushing back any rebuild already scheduled. + internal static void Schedule() + { + // Unity's null check, not C#'s: the host object is destroyed with the rest of the scene + // objects if the game ever tears down DontDestroyOnLoad, and the stale reference then + // compares equal to null and is replaced. + if (_instance == null) + { + GameObject host = new GameObject("EL_PatchReloadDebouncer"); + DontDestroyOnLoad(host); + _instance = host.AddComponent(); + } + + _instance.CancelInvoke(nameof(Run)); + _instance.Invoke(nameof(Run), QuietPeriodSeconds); + } + + [UsedImplicitly] + private void Run() + { + try + { + ELConfig.RunPatchHotReload(); + } + catch (Exception e) + { + // This runs off Unity's Invoke, so an escaping exception is reported against the + // timer with no indication that a patch reload was what failed. + EpicLoot.LogErrorForce($"Rebuilding configs from the patch files failed.\n{e}"); + } + } + } +} diff --git a/EpicLoot/src/ShardStones/Shards.cs b/EpicLoot/src/ShardStones/Shards.cs index 404b7da68..99e3e4d75 100644 --- a/EpicLoot/src/ShardStones/Shards.cs +++ b/EpicLoot/src/ShardStones/Shards.cs @@ -111,6 +111,22 @@ public enum ShardSlotCategory { public class ShardEffectDefinition { public string EffectType; public Dictionary ValuesPerRarity = new Dictionary(); + + // Per-effect tunables, the same shape and semantics as the "Config" block on a + // magiceffects.json entry: a flat string -> float bag read at runtime through EffectConfig + // (backed by MagicItemEffectDefinitions.GetEffectConfig) and rendered one key per line in the + // Shift-detail tooltip. This is where a shard effect's cooldowns, charge counts, durations, + // radii and caps live, so they can be retuned without rebuilding the DLL. + // + // Left empty on purpose: Newtonsoft APPENDS to a pre-initialized collection, and an absent key + // has to fall through to the effect class's code default rather than merge with it. + // ShardEffectDefinitions.BuildDefinition owns that merge -- code defaults first, these keys + // overlaid on top -- which is what lets a partial block tune one knob without blanking the rest. + // + // An effect assigned to several slots is authored once per slot, so the same effect type can + // carry more than one Config. First occurrence wins, exactly as ValuesPerRarity already does; + // a disagreeing later copy is warned about in ShardEffectDefinitions.CollectShardEffects. + public Dictionary Config = new Dictionary(); } public class ShardDefinition { @@ -140,9 +156,21 @@ public float GetValue(ShardSlotCategory category, ItemRarity rarity) { } } - // Root of config/shardstones.json: the full shard effect/rarity grid keyed by color. + // Tunables shared by several shard effects rather than owned by one, so they have no single + // ShardEffectDefinition to hang off (the movement-penalty reference feeds seven shards, the + // blood-block self-damage share feeds two). Read through EffectConfig.Global, but resolved once + // per load into static fields on the classes that use them -- several sit on 50Hz vanilla methods + // where a per-read dictionary lookup would not be free. Empty by default for the Newtonsoft reason + // on ShardEffectDefinition.Config above. + public class ShardGlobalConfig { + public Dictionary Values = new Dictionary(); + } + + // Root of config/shardstones.json: the full shard effect/rarity grid keyed by color, plus the + // cross-effect tunables that belong to no single shard. public class ShardStonesConfig { public Dictionary Shards = new Dictionary(); + public ShardGlobalConfig Global = new ShardGlobalConfig(); } public static class Shards { @@ -157,8 +185,12 @@ public static class Shards { private static Dictionary _definitions = new Dictionary(); + // Cross-effect tunables from the "Global" block, kept whole so GetCFG can round-trip them to + // clients. The live values are pushed into static fields by EffectConfig.ApplyGlobalConfig. + private static ShardGlobalConfig _globalConfig = new ShardGlobalConfig(); + // Config setup hook (SychronizeConfig). Backfills defaults so downstream - // lookups never hit a null Rarities/TypeEffects. + // lookups never hit a null Rarities/TypeEffects/Config. public static void InitializeShardDefinitions(ShardStonesConfig config) { _definitions = config?.Shards ?? new Dictionary(); foreach (var def in _definitions.Values) { @@ -173,11 +205,32 @@ public static void InitializeShardDefinitions(ShardStonesConfig config) { if (def.TypeEffects == null) { def.TypeEffects = new Dictionary(); } + + // An explicit "Config": null in the file would otherwise reach BuildDefinition's merge. + if (def.UniformEffect != null && def.UniformEffect.Config == null) { + def.UniformEffect.Config = new Dictionary(); + } + foreach (var effect in def.TypeEffects.Values) { + if (effect != null && effect.Config == null) { + effect.Config = new Dictionary(); + } + } } + + _globalConfig = config?.Global ?? new ShardGlobalConfig(); + global::EpicLoot.src.Magic.MagicItemEffects.Helpers.EffectConfig.ApplyGlobalConfig(_globalConfig); + + // The synthesized MagicItemEffectDefinitions are built from this grid, so they go stale the + // moment it changes. OnSetupMagicItemEffectDefinitions only fires when magiceffects.json + // reloads -- a different file -- so a live edit here, or a server pushing its copy of + // shardstones.json, has to rebuild them from this side. Same dual-trigger reason + // ShardStoneConversions.Merge runs from both its own Initialize and the other file's event. + // RegisterShardEffectDefinitions replaces its own previous output, so re-running is safe. + global::EpicLoot.Magic.MagicItemEffects.Helpers.ShardEffectDefinitions.RegisterShardEffectDefinitions(); } public static ShardStonesConfig GetCFG() { - return new ShardStonesConfig { Shards = _definitions }; + return new ShardStonesConfig { Shards = _definitions, Global = _globalConfig }; } // Brings a shard's MagicItem in line with its identity. Color and rarity both come from diff --git a/EpicLoot/src/Tempering/TemperMan.cs b/EpicLoot/src/Tempering/TemperMan.cs index ec0dd48fa..c2bb89e1c 100644 --- a/EpicLoot/src/Tempering/TemperMan.cs +++ b/EpicLoot/src/Tempering/TemperMan.cs @@ -1,12 +1,15 @@ using System.Collections.Generic; -using System.Text; -using HarmonyLib; +using EpicLoot.Adventure; +using EpicLoot.Crafting; namespace EpicLoot; public static class TemperMan { - public static Dictionary costMap = + // The requirements panel is a fixed-height area with no ScrollRect, so only this many rows render. + public const int MaxRequirementRows = 4; + + private static readonly Dictionary DefaultCostMap = new Dictionary() { [ItemRarity.Magic] = [ @@ -46,6 +49,63 @@ public static class TemperMan ], }; + public static Dictionary costMap = + new Dictionary(DefaultCostMap); + + // Prefabs only resolve once ObjectDB exists, long after the config loads, so an unknown prefab + // can only be detected on the UI/affordability path - which runs on every selection change. + // Warn once per rarity+prefab so a bad config doesn't flood the log. + private static readonly HashSet WarnedInvalidPrefabs = new HashSet(); + + /// + /// Rebuilds the temper cost table from adventuredata.json's Tempering block. A missing block, or a + /// rarity absent from it, keeps that rarity's hardcoded default. A rarity present with an empty + /// list is taken literally: tempering it is free. + /// + public static void ApplyConfig(TemperingConfig config) + { + WarnedInvalidPrefabs.Clear(); + costMap = new Dictionary(DefaultCostMap); + + if (config?.CostsByRarity == null || config.CostsByRarity.Count == 0) + { + return; + } + + foreach (KeyValuePair> entry in config.CostsByRarity) + { + ItemRarity rarity = entry.Key; + if (entry.Value == null) + { + continue; + } + + List requirements = new List(); + foreach (ItemAmountConfig cost in entry.Value) + { + if (cost == null || string.IsNullOrWhiteSpace(cost.Item)) + { + EpicLoot.LogWarning($"Tempering: skipping cost entry with no Item name for rarity {rarity}."); + continue; + } + if (cost.Amount <= 0) + { + EpicLoot.LogWarning($"Tempering: skipping cost entry '{cost.Item}' for rarity {rarity}, Amount must be greater than 0."); + continue; + } + requirements.Add(new TemperRequirement(cost.Item, cost.Amount)); + } + + if (requirements.Count > MaxRequirementRows) + { + EpicLoot.LogWarning($"Tempering: rarity {rarity} has {requirements.Count} cost entries, " + + $"but the temper panel only displays {MaxRequirementRows}. All of them are still required and consumed."); + } + + costMap[rarity] = requirements.ToArray(); + } + } + public static TemperRequirement[] GetRequirements(ItemRarity rarity) { if (costMap.TryGetValue(rarity, out TemperRequirement[] requirements)) @@ -56,4 +116,33 @@ public static TemperRequirement[] GetRequirements(ItemRarity rarity) new TemperRequirement("Coins", 10) ]; } -} \ No newline at end of file + + /// + /// The requirements that actually apply right now: everything from + /// whose prefab resolves against ObjectDB. An unresolvable prefab is warned about and skipped, so a + /// single bad name costs that one ingredient rather than blocking tempering entirely. Every caller + /// must use this, or the affordability check, the consume call and the displayed list can disagree. + /// + public static TemperRequirement[] GetResolvedRequirements(ItemRarity rarity) + { + TemperRequirement[] requirements = GetRequirements(rarity); + List resolved = new List(requirements.Length); + + foreach (TemperRequirement requirement in requirements) + { + if (requirement.isValid) + { + resolved.Add(requirement); + continue; + } + + if (WarnedInvalidPrefabs.Add($"{rarity}:{requirement.prefab}")) + { + EpicLoot.LogWarning($"Tempering: cost item '{requirement.prefab}' for rarity {rarity} " + + "could not be found, skipping that requirement."); + } + } + + return resolved.ToArray(); + } +} diff --git a/EpicLoot/src/Tempering/TemperPanel.cs b/EpicLoot/src/Tempering/TemperPanel.cs index 5cc685466..fc9691eee 100644 --- a/EpicLoot/src/Tempering/TemperPanel.cs +++ b/EpicLoot/src/Tempering/TemperPanel.cs @@ -410,12 +410,10 @@ private bool HaveRequirements(Player player) { return false; } - TemperRequirement[] requirements = TemperMan.GetRequirements(selectedItemElement._magicItem.Rarity); + // Resolved, so unresolvable prefabs are already warned about and dropped upstream. + TemperRequirement[] requirements = TemperMan.GetResolvedRequirements(selectedItemElement._magicItem.Rarity); for (int i = 0; i < requirements.Length; ++i) { TemperRequirement requirement = requirements[i]; - // A missing prefab renders as an empty row in the list; treat it as unaffordable here - // so a broken cost config can't make tempering free. - if (!requirement.isValid) return false; int playerItemCount = player.GetInventory().CountItems(requirement.item.m_itemData.m_shared.m_name); if (playerItemCount < requirement.amount) return false; } @@ -430,7 +428,8 @@ private bool ConsumeRequirements(Player player) { return false; } - TemperRequirement[] requirements = TemperMan.GetRequirements(selectedItemElement._magicItem.Rarity); + // Must be the resolved set: ToPieceRequirement() would hand ConsumeResources a null m_resItem. + TemperRequirement[] requirements = TemperMan.GetResolvedRequirements(selectedItemElement._magicItem.Rarity); Piece.Requirement[] pieceRequirements = requirements .Select(r => r.ToPieceRequirement()) .ToArray(); @@ -567,7 +566,7 @@ public void FillRequirementList() { RequirementElement.elements.Clear(); if (!selectedItemElement) { - for (int i = 0; i < 4; ++i) { + for (int i = 0; i < TemperMan.MaxRequirementRows; ++i) { GameObject instance = UnityEngine.Object.Instantiate(requirementListPrefab, requirementListRoot); instance.SetActive(true); if (instance.TryGetComponent(out RequirementElement element)) { @@ -575,10 +574,13 @@ public void FillRequirementList() { } } } else { - TemperRequirement[] requirements = TemperMan.GetRequirements(selectedItemElement._magicItem.Rarity); - for (int i = 0; i < requirements.Length; ++i) { + TemperRequirement[] requirements = TemperMan.GetResolvedRequirements(selectedItemElement._magicItem.Rarity); + // Display only: this list has no ScrollRect, so extra rows would spill outside the panel. + // HaveRequirements/ConsumeRequirements still walk the full array, so a long cost list is + // charged in full even though only the first few rows are shown. + int displayCount = Mathf.Min(requirements.Length, TemperMan.MaxRequirementRows); + for (int i = 0; i < displayCount; ++i) { TemperRequirement requirement = requirements[i]; - if (!requirement.isValid) continue; GameObject instance = UnityEngine.Object.Instantiate(requirementListPrefab, requirementListRoot); instance.SetActive(true); if (instance.TryGetComponent(out RequirementElement element)) { @@ -586,8 +588,8 @@ public void FillRequirementList() { } } - if (RequirementElement.elements.Count < 4) { - int difference = 4 - RequirementElement.elements.Count; + if (RequirementElement.elements.Count < TemperMan.MaxRequirementRows) { + int difference = TemperMan.MaxRequirementRows - RequirementElement.elements.Count; for (int i = 0; i < difference; ++i) { GameObject instance = UnityEngine.Object.Instantiate(requirementListPrefab, requirementListRoot); instance.SetActive(true); diff --git a/EpicLoot/src/Tempering/TemperRequirement.cs b/EpicLoot/src/Tempering/TemperRequirement.cs index 0a5c4a091..42e415949 100644 --- a/EpicLoot/src/Tempering/TemperRequirement.cs +++ b/EpicLoot/src/Tempering/TemperRequirement.cs @@ -29,6 +29,7 @@ public ItemDrop item return null; } } + public readonly string prefab = prefab; public readonly int amount = amount; public Piece.Requirement ToPieceRequirement() => new Piece.Requirement() diff --git a/EpicLootAPI/EpicLootAPI/EpicLootAPI.csproj b/EpicLootAPI/EpicLootAPI/EpicLootAPI.csproj index 49443a1e7..c4500be93 100644 --- a/EpicLootAPI/EpicLootAPI/EpicLootAPI.csproj +++ b/EpicLootAPI/EpicLootAPI/EpicLootAPI.csproj @@ -1,50 +1,43 @@  - - + - Debug - AnyCPU - {4358610B-F3F4-4843-B7AF-98B7BC60DCDE} Library - Properties + net48 EpicLootAPI EpicLootAPI - v4.8 + 10.0 512 - 10 + true + true enable + + false + + false + false + - - AnyCPU + + + DEBUG;TRACE + false true full - false - bin\Debug\ - DEBUG;TRACE - prompt 4 - true - - AnyCPU - pdbonly - true - bin\Release\ + + TRACE - prompt + true + pdbonly 4 - true + - - - - - - - - \ No newline at end of file + diff --git a/EquipmentAndQuickSlots/EquipmentAndQuickSlots.cs b/EquipmentAndQuickSlots/EquipmentAndQuickSlots.cs index 287dac51e..9a291acdd 100644 --- a/EquipmentAndQuickSlots/EquipmentAndQuickSlots.cs +++ b/EquipmentAndQuickSlots/EquipmentAndQuickSlots.cs @@ -1,5 +1,6 @@ using System.Reflection; using BepInEx; +using Common; using HarmonyLib; using UnityEngine; @@ -15,7 +16,7 @@ namespace EquipmentAndQuickSlots { [BepInIncompatibility("com.bruce.valheim.comfyquickslots")] public class EquipmentAndQuickSlots : BaseUnityPlugin { public const string PluginId = "randyknapp.mods.equipmentandquickslots"; - public const string Version = "3.0.0"; + public const string Version = "3.0.1"; public static Sprite PaperdollMale; public static Sprite PaperdollFemale; @@ -77,16 +78,25 @@ private static void FixQuickSlotPositionForAuga() { private static void LoadAssets() { var assetBundle = LoadAssetBundle("eaqs"); + if (assetBundle == null) { + // Only the paperdoll art lives in this bundle, so returning here costs the paperdoll + // but leaves the slots themselves working, rather than aborting the rest of Awake. + LogError("Failed to load the 'eaqs' asset bundle. The paperdoll will not be shown."); + return; + } + PaperdollMale = assetBundle.LoadAsset("PaperdollMale"); PaperdollFemale = assetBundle.LoadAsset("PaperdollFemale"); Paperdolls = assetBundle.LoadAsset("Paperdolls"); } + // The assembly is named explicitly rather than taken from Assembly.GetCallingAssembly(). When + // another mod hooks Awake, MonoMod recompiles it as a dynamic method (DMD<...::Awake>), and the + // "calling assembly" is then that dynamic assembly, not this one. The resource lookup misses, + // LoadFromStream(null) throws "ArgumentNullException: stream", and the mod fails to load — + // intermittently, since it depends on which other mods are present. public static AssetBundle LoadAssetBundle(string filename) { - var assembly = Assembly.GetCallingAssembly(); - var assetBundle = AssetBundle.LoadFromStream(assembly.GetManifestResourceStream($"{assembly.GetName().Name}.{filename}")); - - return assetBundle; + return AssetBundleLoader.LoadFromResources(filename, typeof(EquipmentAndQuickSlots).Assembly); } public static void Log(string message) { @@ -105,5 +115,11 @@ public static void LogWarning(string message) { public static void LogError(string message) { _instance.Logger.LogError(message); } + + // Ungated as well, for the rare one-time notice that explains why a setting has no effect + // (another mod has taken something over); those are worth more than the line they cost. + public static void LogInfo(string message) { + _instance.Logger.LogInfo(message); + } } } diff --git a/EquipmentAndQuickSlots/Package/CHANGELOG.md b/EquipmentAndQuickSlots/Package/CHANGELOG.md index b669b7902..9d2e46b6e 100644 --- a/EquipmentAndQuickSlots/Package/CHANGELOG.md +++ b/EquipmentAndQuickSlots/Package/CHANGELOG.md @@ -1,3 +1,12 @@ +**3.0.1** +* Drag-to-unequip works again, with the unequip progress bar +* **Compatibility with mods that make the player inventory scrollable** (Valheim Plus with its + `[Inventory]` section enabled). +* **BetterUI compatibility.** + * Once BetterUI has taken the bar over, a one-time line in the log says so, rather than leaving + you to wonder why `Quick Slots Anchor` and `Quick Slots Position` appear to do nothing. +* The readme now documents which mods are compatible and which are refused outright. + **3.0.0** * **Complete rewrite of the slot system.** * All configs that impact balance or functionality now are server synced (if the mod is installed on the server). diff --git a/EquipmentAndQuickSlots/Package/README.md b/EquipmentAndQuickSlots/Package/README.md index f5b617d0e..bd70623c6 100644 --- a/EquipmentAndQuickSlots/Package/README.md +++ b/EquipmentAndQuickSlots/Package/README.md @@ -37,6 +37,16 @@ copy `EquipmentAndQuickSlots.dll` into the `BepInEx/plugins` folder. * `eaqs_restorebackup` (cheat) — restores the automatic slot backup into free slots * `breakequipment`, `dropall` (cheat) — testing helpers +### Compatibility + +* **BetterUI** works out of the box. While BetterUI's HUD editing is enabled (its default), BetterUI + positions the quick slot bar: move it with BetterUI's HUD edit key (F7 by default) and BetterUI + remembers where you put it. `Quick Slots Anchor` / `Quick Slots Position` in this mod's config only + decide where the bar starts. If the bar sits somewhere odd after updating from 3.0.0, press F7 and + drag it back, or reset BetterUI's `uiData` setting to `none`. +* Other slot mods — AzuExtendedPlayerInventory, ExtendedPlayerInventory, ExtraSlots, ComfyQuickSlots — + are declared incompatible; BepInEx will not load this mod next to them. + ### Notes The following things WILL NOT BE ADDED OR CONSIDERED. diff --git a/EquipmentAndQuickSlots/Package/manifest.json b/EquipmentAndQuickSlots/Package/manifest.json index 61eadb922..01e08768a 100644 --- a/EquipmentAndQuickSlots/Package/manifest.json +++ b/EquipmentAndQuickSlots/Package/manifest.json @@ -1,6 +1,6 @@ { "name": "EquipmentAndQuickSlots", - "version_number": "3.0.0", + "version_number": "3.0.1", "website_url": "https://github.com/RandyKnapp/ValheimMods/tree/main/EquipmentAndQuickSlots", "description": "Give equipped items their own dedicated inventory slots, plus up to six hotkeyable quick slots.", "dependencies": [ diff --git a/EquipmentAndQuickSlots/Properties/AssemblyInfo.cs b/EquipmentAndQuickSlots/Properties/AssemblyInfo.cs index e7bd9e21e..af476cac1 100644 --- a/EquipmentAndQuickSlots/Properties/AssemblyInfo.cs +++ b/EquipmentAndQuickSlots/Properties/AssemblyInfo.cs @@ -31,5 +31,5 @@ // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("3.0.0")] -[assembly: AssemblyFileVersion("3.0.0")] +[assembly: AssemblyVersion("3.0.1")] +[assembly: AssemblyFileVersion("3.0.1")] diff --git a/EquipmentAndQuickSlots/src/Compatibility/BetterUICompat.cs b/EquipmentAndQuickSlots/src/Compatibility/BetterUICompat.cs new file mode 100644 index 000000000..81d801a2c --- /dev/null +++ b/EquipmentAndQuickSlots/src/Compatibility/BetterUICompat.cs @@ -0,0 +1,43 @@ +using BepInEx.Bootstrap; +using Common; + +namespace EquipmentAndQuickSlots { + // BetterUI (MK_BetterUI) needs no hooks of its own here. This records what it does to this + // mod's UI, so the code that has to survive it is written knowingly, and it puts a name to the + // one behavior worth telling the player about. + // + // - Quick slot bar: BetterUI's Hud.Awake postfix looks up hudroot/QuickSlotsHotkeyBar (the + // name QuickSlotsHotBar gives its clone of the vanilla hotbar), destroys the bar's + // ConfigPositionedElement, and from then on positions and scales the bar from its own saved + // HUD layout, edited in-game with its HUD edit key (F7 by default). Its starting point is + // wherever the bar is at that moment — which is why QuickSlotsHotBar positions the bar the + // instant it creates it instead of leaving that to the component's first Update. + // - Inventory panel: BetterUI re-applies anchors and a scale to the vanilla "Player" panel at + // Hud.Awake. The scale is built with the two-argument Vector3 constructor, so its z is 0; + // EquipmentPanel therefore never converts world positions back into the panel's local space. + // - Grid cells: BetterUI's InventoryGrid.UpdateGui postfix walks m_elements by grid position + // to recolor durability bars and draw quality stars. Slot cells are real grid elements, so + // that works on them unchanged. Inactive cells are parked under an inactive holder rather + // than merely deactivated, so nothing walking the grid can bring them back into view. + internal static class BetterUICompat { + public const string BetterUIGUID = "MK_BetterUI"; + + public static bool IsLoaded => Chainloader.PluginInfos.ContainsKey(BetterUIGUID); + + private static bool _handoffNoted; + + // Called from the hotbar controller, which only runs once a player exists — long after + // Hud.Awake, so BetterUI's deferred Destroy of the positioner has gone through by then. + // Once it has, this mod's position settings no longer place the bar; say so once, so a + // player wondering why "Quick Slots Position" does nothing finds the answer in the log. + internal static void NoteQuickBarHandoff(HotkeyBar quickBar) { + if (_handoffNoted || quickBar == null) + return; + + _handoffNoted = true; + + if (IsLoaded && quickBar.GetComponent() == null) + EquipmentAndQuickSlots.LogInfo("BetterUI's HUD editor is positioning the quick slot bar; 'Quick Slots Anchor' and 'Quick Slots Position' only set where it starts. Move the bar with BetterUI's HUD edit key (F7 by default)."); + } + } +} diff --git a/EquipmentAndQuickSlots/src/EquipmentPanel.cs b/EquipmentAndQuickSlots/src/EquipmentPanel.cs index 568cd9068..df752c5af 100644 --- a/EquipmentAndQuickSlots/src/EquipmentPanel.cs +++ b/EquipmentAndQuickSlots/src/EquipmentPanel.cs @@ -13,7 +13,7 @@ namespace EquipmentAndQuickSlots { // Utility/Trinket half a row lower on the right) on their own background, with the quick slots // on a separate background row below it. The grid elements for the hidden slot rows already // exist (the player grid renders the full-height inventory); this class only shrinks the - // visible grid and physically relocates those elements. All vanilla behavior — drag/drop, + // visible grid and physically relocates those elements out of it. All vanilla behavior — drag/drop, // tooltips, gamepad selection, other mods' icon overlays — keeps working because these are // real InventoryGrid elements. public static class EquipmentPanel { @@ -75,6 +75,8 @@ public static class EquipmentPanel { private static RectTransform equipmentBackground; private static RectTransform quickBackground; private static RectTransform customBackground; + private static RectTransform slotRoot; + private static RectTransform hiddenRoot; private static GameObject paperdoll; private static RectTransform[] paperdollImages; @@ -306,6 +308,108 @@ private static void SyncBackground(RectTransform background, bool visible, Vecto } } + // The slot cells are born under the grid root, because they are real InventoryGrid + // elements. That is not somewhere they can stay: mods that make the player inventory + // scrollable (Valheim Plus with its [Inventory] section enabled) add a RectMask2D and a + // ScrollRect to the player grid. The mask clips every descendant graphic, erasing the part + // of the panel that falls outside the grid rect — the quick and API rows — and making + // those cells unclickable with it, while the panel backgrounds (children of the player + // panel, not of the grid) keep drawing. The ScrollRect then slides the grid root, and the + // cells with it, out from under those backgrounds. + // + // So the cells get their own root, a sibling of the grid rather than a descendant. It + // mirrors the grid root's resting rect, which keeps every layout table in this file and in + // AugaPanel in the grid-root space they were written in. + private static RectTransform EnsureSlotRoot(InventoryGrid grid) { + RectTransform gridRoot = grid.m_gridRoot; + if (!gridRoot || !gridRoot.parent || !InventoryGui.instance.m_player) + return null; + + if (!slotRoot) { + slotRoot = new GameObject("EaqsSlotRoot", typeof(RectTransform)).GetComponent(); + slotRoot.SetParent(InventoryGui.instance.m_player, worldPositionStays: false); + slotRoot.localScale = Vector3.one; + slotRoot.localRotation = Quaternion.identity; + // Over the panel backgrounds, which sit early in the panel's sibling order + slotRoot.SetAsLastSibling(); + } + + // Corner anchors, so the rect is described entirely by pivot and size no matter how + // the panel or the grid are anchored. + slotRoot.anchorMin = Vector2.zero; + slotRoot.anchorMax = Vector2.zero; + slotRoot.pivot = gridRoot.pivot; + slotRoot.sizeDelta = gridRoot.rect.size; + + // anchoredPosition contributes linearly to localPosition, so subtracting it back out + // gives the grid root's pivot with any scroll offset removed — the placement the cell + // positions were measured against, and where they have to stay however far somebody + // else scrolls the grid. It is carried into the panel's local space by walking the + // transforms in between rather than through world space: converting a world point back + // into a parent's local space divides by that parent's scale, which is undefined when a + // component of it is zero — and BetterUI's HUD editor writes exactly that onto the + // player panel (see BetterUICompat). + Vector3 resting = gridRoot.localPosition - (Vector3)gridRoot.anchoredPosition; + if (TryTransformToPanelSpace(gridRoot.parent, slotRoot.parent, ref resting)) + slotRoot.localPosition = resting; + else + slotRoot.position = gridRoot.parent.TransformPoint(resting); + + return slotRoot; + } + + // Carries a point given in `from`'s local space up through the local transforms of `from` + // and its ancestors until it is expressed in `panel`'s local space. False when `panel` is + // not an ancestor of `from`; the point is left untouched then. + private static bool TryTransformToPanelSpace(Transform from, Transform panel, ref Vector3 point) { + Vector3 p = point; + for (Transform t = from; t != null; t = t.parent) { + if (t == panel) { + point = p; + return true; + } + + p = t.localPosition + t.localRotation * Vector3.Scale(t.localScale, p); + } + + return false; + } + + // Cells of inactive slots — reserved API capacity, quick slots past the configured count, + // switched-off equipment cells — must not show, but they cannot be destroyed either: + // vanilla InventoryGrid.UpdateGui indexes m_elements by grid position and touches every + // element every frame. Deactivating them where they stand has not been enough on its own: + // with BetterUI installed, players have seen the whole hidden region drawn as a block of + // empty cells under the inventory. Parked under a holder that is itself inactive, a cell + // stays invisible and unclickable whatever its own active flag ends up as. The holder sits + // far off-screen because vanilla's hover scan (GetHoveredElement) tests every element's + // rect, active or not, and returns the first hit — a hidden cell overlapping a panel cell + // would otherwise take that cell's tooltip. + private static RectTransform EnsureHiddenRoot() { + if (hiddenRoot || !InventoryGui.instance.m_player) + return hiddenRoot; + + hiddenRoot = new GameObject("EaqsHiddenSlotRoot", typeof(RectTransform)).GetComponent(); + hiddenRoot.SetParent(InventoryGui.instance.m_player, worldPositionStays: false); + hiddenRoot.localScale = Vector3.one; + hiddenRoot.localRotation = Quaternion.identity; + hiddenRoot.anchorMin = Vector2.zero; + hiddenRoot.anchorMax = Vector2.zero; + hiddenRoot.anchoredPosition = new Vector2(-100000f, 0f); + hiddenRoot.gameObject.SetActive(false); + + return hiddenRoot; + } + + private static void ParkHiddenCell(GameObject go, RectTransform holder) { + if (!go) + return; + + go.SetActive(false); + if (holder && go.transform.parent != holder) + go.transform.SetParent(holder, worldPositionStays: false); + } + // Runs from InventoryGrid.UpdateGui on the player grid: shrink the visible grid, relocate // slot elements, label them, tint unfit targets while dragging. internal static void UpdateInventorySlots() { @@ -315,6 +419,11 @@ internal static void UpdateInventorySlots() { int startIndex = InventorySizeVisible; ItemDrop.ItemData dragItem = InventoryGui.instance.m_dragItem; + RectTransform cellRoot = EnsureSlotRoot(grid); + RectTransform hiddenCellRoot = EnsureHiddenRoot(); + // Without a slot root of our own the cells stay where vanilla put them, and that is + // also where a cell has to go back to when its slot is switched on again. + Transform activeParent = cellRoot ? cellRoot : grid.m_gridRoot; for (int i = 0; i < Math.Min(slots.Length, grid.m_elements.Count - startIndex); ++i) { InventoryGrid.Element element = grid.m_elements[startIndex + i]; @@ -324,9 +433,18 @@ internal static void UpdateInventorySlots() { if (!go) continue; - go.SetActive(slot.IsActive); - if (!slot.IsActive) + if (!slot.IsActive) { + ParkHiddenCell(go, hiddenCellRoot); continue; + } + + go.SetActive(true); + + // Vanilla rebuilds the elements under the grid root whenever the inventory + // dimensions change; re-adopt them when it does. This also brings a cell back + // from the hidden holder once its slot is active again. + if (activeParent && go.transform.parent != activeParent) + go.transform.SetParent(activeParent, worldPositionStays: false); go.GetComponent().anchoredPosition = EquipmentAndQuickSlots.HasAuga ? AugaPanel.GetSlotPosition(slot) : GetSlotPosition(slot); SetSlotLabel(go.transform.Find("binding"), slot); @@ -334,7 +452,7 @@ internal static void UpdateInventorySlots() { } for (int i = startIndex + slots.Length; i < grid.m_elements.Count; i++) - grid.m_elements[i]?.m_go?.SetActive(false); + ParkHiddenCell(grid.m_elements[i]?.m_go, hiddenCellRoot); } private static bool DragItemFits(Slot slot, ItemDrop.ItemData dragItem) { @@ -413,6 +531,8 @@ private static void ClearPanel() { equipmentBackground = null; quickBackground = null; customBackground = null; + slotRoot = null; + hiddenRoot = null; paperdoll = null; paperdollImages = null; _dragPosition = null; diff --git a/EquipmentAndQuickSlots/src/InventoryPatches.cs b/EquipmentAndQuickSlots/src/InventoryPatches.cs index e96c3f6e7..29285ce5f 100644 --- a/EquipmentAndQuickSlots/src/InventoryPatches.cs +++ b/EquipmentAndQuickSlots/src/InventoryPatches.cs @@ -262,12 +262,22 @@ private static bool PassDropItem(string source, InventoryGrid grid, Inventory fr // them, so anything that must outlive the move is queued from the postfix. [HarmonyPatch(typeof(InventoryGui), nameof(InventoryGui.OnSelectedItem))] public static class InventoryGui_OnSelectedItem_DragRules { - private static ItemDrop.ItemData _equipAfterDrop; - private static ItemDrop.ItemData _unequipAfterDrop; + // The cell the dragged item lands in when the drop has to be followed by a queued + // equip or unequip — a position, not the dragged ItemData. Vanilla's move goes through + // Inventory.AddItem(item, amount, x, y), which puts a Clone() of the dragged item into + // the target cell and removes the original from its inventory, so after the drop the + // object that was dragged is in no inventory at all and the item in the cell is a + // different one. Queuing on the original found nothing to act on: vanilla re-equipped + // the clone, no unequip was ever queued, and the sweep snapped the still-worn clone + // straight back into the paperdoll — drag-to-unequip was a silent no-op. + private static Vector2i? _equipAfterDropPos; + private static Vector2i? _unequipAfterDropPos; + private static ItemDrop.ItemData.SharedData _droppedShared; public static bool Prefix(InventoryGui __instance, InventoryGrid grid, Vector2i pos) { - _equipAfterDrop = null; - _unequipAfterDrop = null; + _equipAfterDropPos = null; + _unequipAfterDropPos = null; + _droppedShared = null; Player player = Player.m_localPlayer; if (player == null || player.IsTeleporting() || !__instance.m_dragGo || __instance.m_dragItem == null || __instance.m_dragInventory == null) @@ -292,7 +302,8 @@ public static bool Prefix(InventoryGui __instance, InventoryGrid grid, Vector2i // From a container: vanilla moves it into the cell first, the postfix queues // the equip; ItemBelongs keeps it in the cell while the equip is pending. - _equipAfterDrop = dragItem; + _equipAfterDropPos = pos; + _droppedShared = dragItem.m_shared; } if (sourceSlot != null && sourceSlot.IsEquipmentSlot && dragItemEquipped) { @@ -313,29 +324,44 @@ public static bool Prefix(InventoryGui __instance, InventoryGrid grid, Vector2i // unequip, and the sweep leaves it alone while that is pending. Into a // container vanilla unequips on its own — an item can't stay worn outside the // inventory. - if (targetIsPlayerGrid && (targetSlot == null || !targetSlot.IsEquipmentSlot) && targetItem == null) - _unequipAfterDrop = dragItem; + if (targetIsPlayerGrid && (targetSlot == null || !targetSlot.IsEquipmentSlot) && targetItem == null) { + _unequipAfterDropPos = pos; + _droppedShared = dragItem.m_shared; + } } return PassDropItem("InventoryGui.OnSelectedItem", grid, __instance.m_dragInventory, dragItem, pos); } public static void Postfix() { - ItemDrop.ItemData equip = _equipAfterDrop; - ItemDrop.ItemData unequip = _unequipAfterDrop; - _equipAfterDrop = null; - _unequipAfterDrop = null; + Vector2i? equipPos = _equipAfterDropPos; + Vector2i? unequipPos = _unequipAfterDropPos; + ItemDrop.ItemData.SharedData droppedShared = _droppedShared; + _equipAfterDropPos = null; + _unequipAfterDropPos = null; + _droppedShared = null; Player player = Player.m_localPlayer; if (player == null || PlayerInventory == null) return; - if (equip != null && PlayerInventory.ContainsItem(equip)) + // Whatever now sits in the target cell is the dropped item (or vanilla's clone of + // it). A refused drop leaves an empty target cell empty, or its previous occupant + // in place — the shared-data check keeps the queue off anything that isn't the + // dragged item, and QueueEquip/QueueUnequip no-op on an item already in the state + // asked for. + if (equipPos is Vector2i ep && LandedItem(ep, droppedShared) is ItemDrop.ItemData equip) QueueEquip(player, equip); - if (unequip != null && PlayerInventory.ContainsItem(unequip) && (GetItemSlot(unequip) is not Slot landed || !landed.IsEquipmentSlot)) + if (unequipPos is Vector2i up && LandedItem(up, droppedShared) is ItemDrop.ItemData unequip + && (GetItemSlot(unequip) is not Slot landed || !landed.IsEquipmentSlot)) QueueUnequip(player, unequip); } + + private static ItemDrop.ItemData LandedItem(Vector2i pos, ItemDrop.ItemData.SharedData shared) { + ItemDrop.ItemData item = PlayerInventory.GetItemAt(pos.x, pos.y); + return item != null && item.m_shared == shared ? item : null; + } } [HarmonyPatch(typeof(InventoryGrid), nameof(InventoryGrid.DropItem))] diff --git a/EquipmentAndQuickSlots/src/QuickSlotsHotBar.cs b/EquipmentAndQuickSlots/src/QuickSlotsHotBar.cs index 9bde4aa14..cb7bc0bfc 100644 --- a/EquipmentAndQuickSlots/src/QuickSlotsHotBar.cs +++ b/EquipmentAndQuickSlots/src/QuickSlotsHotBar.cs @@ -73,6 +73,13 @@ private static void Postfix(Hud __instance) { var positioned = clone.gameObject.AddComponent(); positioned.AnchorConfig = ValConfig.QuickSlotsAnchor; positioned.PositionConfig = ValConfig.QuickSlotsPosition; + // AddComponent ran Awake before the configs were assigned, so nothing has placed the + // bar yet. Place it now rather than leaving it to the component's first Update: + // BetterUI's own Hud.Awake postfix (which runs right after this one) removes this + // component so its HUD editor can own the bar's position, and it takes the bar's + // position at that moment as the starting point — see BetterUICompat. 2.x placed + // the bar here as well. + positioned.EnsureCorrectPosition(); bars.Add(vanillaBar.GetComponent()); bars.Add(_quickBar); @@ -132,6 +139,8 @@ private static void Postfix() { } } + BetterUICompat.NoteQuickBarHandoff(_quickBar); + if (ZInput.IsGamepadActive() && IsHotkeyBarsActive() && player.TakeInput()) { bool joyHotbarLeft = ZInput.GetButtonDown("JoyHotbarLeft") && !ZInput.GetButton("JoyAltKeys"); bool joyHotbarRight = ZInput.GetButtonDown("JoyHotbarRight") && !ZInput.GetButton("JoyAltKeys"); diff --git a/EquipmentAndQuickSlotsAPI/EquipmentAndQuickSlotsAPI/EquipmentAndQuickSlotsAPI.csproj b/EquipmentAndQuickSlotsAPI/EquipmentAndQuickSlotsAPI/EquipmentAndQuickSlotsAPI.csproj index ee75ec229..8fcd6d4c1 100644 --- a/EquipmentAndQuickSlotsAPI/EquipmentAndQuickSlotsAPI/EquipmentAndQuickSlotsAPI.csproj +++ b/EquipmentAndQuickSlotsAPI/EquipmentAndQuickSlotsAPI/EquipmentAndQuickSlotsAPI.csproj @@ -1,6 +1,13 @@ - + + + + false + + Library net472 diff --git a/ImprovedBuildHud/ImprovedBuildHud.csproj b/ImprovedBuildHud/ImprovedBuildHud.csproj index 9f62f04a2..5249a6fa5 100644 --- a/ImprovedBuildHud/ImprovedBuildHud.csproj +++ b/ImprovedBuildHud/ImprovedBuildHud.csproj @@ -1,47 +1,39 @@  - - + - Debug - AnyCPU - {5681A194-79CD-45FC-B47B-F5F27E96FC6D} Library - Properties + net472 ImprovedBuildHud ImprovedBuildHud - v4.7.2 10.0 512 true + + false + + false + false + - + + + DEBUG;TRACE + false true full - false - bin\Debug\ - DEBUG;TRACE - prompt 4 - - pdbonly - true - bin\Release\ + + TRACE - prompt + true + pdbonly 4 - - - - - - - - - - + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/')) @@ -82,4 +74,4 @@ - \ No newline at end of file + diff --git a/ItsJustWood/ItsJustWood.csproj b/ItsJustWood/ItsJustWood.csproj index f3319a18e..76d69a231 100644 --- a/ItsJustWood/ItsJustWood.csproj +++ b/ItsJustWood/ItsJustWood.csproj @@ -1,50 +1,42 @@  - - + - Debug - AnyCPU - {BB4DFA8B-72B0-4A9B-B9A0-5AF5595AA309} Library - Properties + net472 ItsJustWood ItsJustWood - v4.7.2 10.0 512 true + + false + + false + false + - + + + DEBUG;TRACE + false true full - false - bin\Debug\ - DEBUG;TRACE - prompt 4 true - - pdbonly - true - bin\Release\ + + TRACE - prompt + true + pdbonly 4 - - - - - - - - - - + - + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/')) @@ -85,4 +77,4 @@ - \ No newline at end of file + diff --git a/Jam/Jam.cs b/Jam/Jam.cs index 1d2bd9820..ca409de54 100644 --- a/Jam/Jam.cs +++ b/Jam/Jam.cs @@ -1,6 +1,7 @@ using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; +using Common; using HarmonyLib; using JetBrains.Annotations; using Jotunn.Configs; @@ -453,12 +454,14 @@ public string GetExtendedDescription(string description, bool synchronizedSettin return description + (synchronizedSetting ? " [Synced with Server]" : " [Not Synced with Server]"); } + // The assembly is named explicitly rather than taken from Assembly.GetCallingAssembly(). When + // another mod hooks Awake, MonoMod recompiles it as a dynamic method (DMD<...::Awake>), and the + // "calling assembly" is then that dynamic assembly, not this one. The resource lookup misses, + // LoadFromStream(null) throws "ArgumentNullException: stream", and the mod fails to load — + // intermittently, since it depends on which other mods are present. public static AssetBundle LoadAssetBundle(string filename) { - Assembly assembly = Assembly.GetCallingAssembly(); - AssetBundle assetBundle = AssetBundle.LoadFromStream(assembly.GetManifestResourceStream($"{assembly.GetName().Name}.{filename}")); - - return assetBundle; + return AssetBundleLoader.LoadFromResources(filename, typeof(Jam).Assembly); } private static void LoadItem(AssetBundle assetBundle, string assetName, string recipe, int stationLevel) diff --git a/Jam/Jam.csproj b/Jam/Jam.csproj index bf390fc7c..54a2a1b83 100644 --- a/Jam/Jam.csproj +++ b/Jam/Jam.csproj @@ -1,60 +1,53 @@  - - + - Debug - AnyCPU - {6C679B7E-9F39-4DF2-9F8A-717E68FBF0E3} Library - Properties + net48 Jam Jam - v4.8 10.0 512 true true + + false + + false + false + - + + + DEBUG;TRACE + false true full - false - bin\Debug\ - DEBUG;TRACE - prompt 4 - true - - pdbonly - true - bin\Release\ + + TRACE - prompt + true + pdbonly 4 + - - - + + jamassets - - - + - - - - This project references NuGet package(s) that are missing on this computer. Enable NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105.The missing file is {0}. - - + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/')) @@ -95,4 +88,4 @@ - \ No newline at end of file + diff --git a/MinimalStatusEffects/MinimalStatusEffects.csproj b/MinimalStatusEffects/MinimalStatusEffects.csproj index 43e4d4a8b..d382e653c 100644 --- a/MinimalStatusEffects/MinimalStatusEffects.csproj +++ b/MinimalStatusEffects/MinimalStatusEffects.csproj @@ -1,48 +1,41 @@  - - + - Debug - AnyCPU - {70B6ED26-2AFE-4E7A-A40F-CF187F14FCA0} Library - Properties + net472 MinimalStatusEffects MinimalStatusEffects - v4.7.2 10.0 512 true + + false + + false + false + - + + + DEBUG;TRACE + false true full - false - bin\Debug\ - DEBUG;TRACE - prompt 4 - - pdbonly - true - bin\Release\ + + TRACE - prompt + true + pdbonly 4 - - - - - - - - - + - + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/')) @@ -83,4 +76,4 @@ - \ No newline at end of file + diff --git a/Paths.props b/Paths.props index 9bfc33bc6..6f4c11abd 100644 --- a/Paths.props +++ b/Paths.props @@ -1,4 +1,4 @@ - + @@ -18,9 +18,11 @@ - $(GamePath)\valheim_Data\Managed\ - $(GamePath)\BepInEx\ - $(SolutionDir)ValheimUnity\ + $(GamePath)\valheim_Data\Managed\ + + $(GamePath)\BepInEx\ + $(SolutionDir)ValheimUnity\ @@ -29,14 +31,14 @@ $(SolutionDir)Libs\AdventureBackpacksAPI.dll - - $(GamePathManaged)publicized_assemblies\assembly_guiutils_publicized.dll + + $(GamePathManaged)assembly_guiutils.dll - - $(GamePathManaged)publicized_assemblies\assembly_utils_publicized.dll + + $(GamePathManaged)assembly_utils.dll - - $(GamePathManaged)publicized_assemblies\assembly_valheim_publicized.dll + + $(GamePathManaged)assembly_valheim.dll $(BepinexPath)core\BepInEx.dll @@ -52,8 +54,10 @@ $(SolutionDir)Packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll - - $(GamePathManaged)publicized_assemblies\Splatform_publicized.dll + + + $(GamePathManaged)Splatform.dll diff --git a/StationsAreContainers/StationsAreContainers.csproj b/StationsAreContainers/StationsAreContainers.csproj index ebf0dad79..474edcb7b 100644 --- a/StationsAreContainers/StationsAreContainers.csproj +++ b/StationsAreContainers/StationsAreContainers.csproj @@ -1,60 +1,40 @@  - - + - Debug - AnyCPU - {1BC8AD7D-949C-41E5-A721-CB6CEB68871B} Library - Properties + net48 StationsAreContainers StationsAreContainers - v4.8 10.0 512 - true true - + true + + false + + false + false + - + + + DEBUG;TRACE + false true full - false - bin\Debug\ - DEBUG;TRACE - prompt 4 - false - - pdbonly - true - bin\Release\ + + TRACE - prompt + true + pdbonly 4 - false - - - - - - - - - - - - This project references NuGet package(s) that are missing on this computer. Enable NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105.The missing file is {0}. - - - - - - - + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/')) @@ -95,4 +75,4 @@ - \ No newline at end of file + diff --git a/ValheimMods.sln b/ValheimMods.sln index b85c34a5c..0817b9327 100755 --- a/ValheimMods.sln +++ b/ValheimMods.sln @@ -74,11 +74,11 @@ Global GlobalSection(SharedMSBuildProjectFiles) = preSolution Common\Common.projitems*{08ed5bc9-9e0c-4343-90a5-92497be6e216}*SharedItemsImports = 5 Common\Common.projitems*{09bcfe16-0d69-481c-98c6-1090d78e3127}*SharedItemsImports = 13 - Common\Common.projitems*{0ddde55c-5daf-4b53-b5e2-83b5c44c4682}*SharedItemsImports = 4 - Common\Common.projitems*{6c679b7e-9f39-4df2-9f8a-717e68fbf0e3}*SharedItemsImports = 4 - Common\Common.projitems*{70b6ed26-2afe-4e7a-a40f-cf187f14fca0}*SharedItemsImports = 4 + Common\Common.projitems*{0ddde55c-5daf-4b53-b5e2-83b5c44c4682}*SharedItemsImports = 5 + Common\Common.projitems*{6c679b7e-9f39-4df2-9f8a-717e68fbf0e3}*SharedItemsImports = 5 + Common\Common.projitems*{70b6ed26-2afe-4e7a-a40f-cf187f14fca0}*SharedItemsImports = 5 Common\Common.projitems*{963cf4f8-ba02-49fc-8884-97e781e6bc18}*SharedItemsImports = 5 - Common\Common.projitems*{bb4dfa8b-72b0-4a9b-b9a0-5af5595aa309}*SharedItemsImports = 4 - Common\Common.projitems*{cbed8160-747f-4022-98ce-61a8ee201652}*SharedItemsImports = 4 + Common\Common.projitems*{bb4dfa8b-72b0-4a9b-b9a0-5af5595aa309}*SharedItemsImports = 5 + Common\Common.projitems*{cbed8160-747f-4022-98ce-61a8ee201652}*SharedItemsImports = 5 EndGlobalSection EndGlobal