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
+ net472AdvancedPortalsAdvancedPortals
- v4.7.210.0512true
+ true
+
+ false
+
+ false
+ false
+
-
+
+
+ DEBUG;TRACE
+ falsetruefull
- false
- bin\Debug\
- DEBUG;TRACE
- prompt4
- true
-
- pdbonly
- true
- bin\Release\
+
+ TRACE
- prompt
+ true
+ pdbonly4
- 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
+ net472ConsoleHelperConsoleHelper
- v4.7.210.0512true
+
+ false
+
+ false
+ false
+
-
+
+
+ DEBUG;TRACE
+ falsetruefull
- false
- bin\Debug\
- DEBUG;TRACE
- prompt4true
-
- pdbonly
- true
- bin\Release\
+
+ TRACE
- prompt
+ true
+ pdbonly4
-
-
-
-
-
-
\ 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
+ net472DvergerColorImprovedDvergerCirclet
- v4.7.210.0512true
+ true
+
+ false
+
+ false
+ false
+
-
+
+
+ DEBUG;TRACE
+ falsetruefull
- false
- bin\Debug\
- DEBUG;TRACE
- prompt4
- true
-
- pdbonly
- true
- bin\Release\
+
+ TRACE
- prompt
+ true
+ pdbonly4
- 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
+
+ Librarynet481
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
+ net48EpicLootAPIEpicLootAPI
- v4.8
+ 10.0512
- 10
+ true
+ trueenable
+
+ false
+
+ false
+ false
+
-
- AnyCPU
+
+
+ DEBUG;TRACE
+ falsetruefull
- false
- bin\Debug\
- DEBUG;TRACE
- prompt4
- true
-
- AnyCPU
- pdbonly
- true
- bin\Release\
+
+ TRACE
- prompt
+ true
+ pdbonly4
- 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
+
+
Librarynet472
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
+ net472ImprovedBuildHudImprovedBuildHud
- v4.7.210.0512true
+
+ false
+
+ false
+ false
+
-
+
+
+ DEBUG;TRACE
+ falsetruefull
- false
- bin\Debug\
- DEBUG;TRACE
- prompt4
-
- pdbonly
- true
- bin\Release\
+
+ TRACE
- prompt
+ true
+ pdbonly4
-
-
-
-
-
-
-
-
-
-
+
$([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
+ net472ItsJustWoodItsJustWood
- v4.7.210.0512true
+
+ false
+
+ false
+ false
+
-
+
+
+ DEBUG;TRACE
+ falsetruefull
- false
- bin\Debug\
- DEBUG;TRACE
- prompt4true
-
- pdbonly
- true
- bin\Release\
+
+ TRACE
- prompt
+ true
+ pdbonly4
-
-
-
-
-
-
-
-
-
-
+
-
+
$([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
+ net48JamJam
- v4.810.0512truetrue
+
+ false
+
+ false
+ false
+
-
+
+
+ DEBUG;TRACE
+ falsetruefull
- false
- bin\Debug\
- DEBUG;TRACE
- prompt4
- true
-
- pdbonly
- true
- bin\Release\
+
+ TRACE
- prompt
+ true
+ pdbonly4
+
-
-
-
+
+
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
+ net472MinimalStatusEffectsMinimalStatusEffects
- v4.7.210.0512true
+
+ false
+
+ false
+ false
+
-
+
+
+ DEBUG;TRACE
+ falsetruefull
- false
- bin\Debug\
- DEBUG;TRACE
- prompt4
-
- pdbonly
- true
- bin\Release\
+
+ TRACE
- prompt
+ true
+ pdbonly4
-
-
-
-
-
-
-
-
-
+
-
+
$([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
+ net48StationsAreContainersStationsAreContainers
- v4.810.0512
- truetrue
-
+ true
+
+ false
+
+ false
+ false
+
-
+
+
+ DEBUG;TRACE
+ falsetruefull
- false
- bin\Debug\
- DEBUG;TRACE
- prompt4
- false
-
- pdbonly
- true
- bin\Release\
+
+ TRACE
- prompt
+ true
+ pdbonly4
- 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