From 2229ec52df30e5a08836e94274668a0a5de380d0 Mon Sep 17 00:00:00 2001 From: MidnightsFX Date: Fri, 11 Sep 2026 09:40:33 -0700 Subject: [PATCH 01/20] terminal reversion after hotfix --- AdvancedPortals/AdvancedPortals.cs | 58 ++++----- EpicLoot/EpicLoot.cs | 43 ++++--- EpicLoot/Package/CHANGELOG.md | 9 ++ EpicLoot/src/Config/ConfigVersionManager.cs | 11 +- EpicLoot/src/Config/ConfigVersionState.cs | 2 +- EpicLoot/src/Config/ELConfig.cs | 120 +++++++++++------- EpicLoot/src/Magic/AutoAddEnchantableItems.cs | 12 +- EpicLoot/src/Patching/FilePatching.cs | 3 +- EpicLoot/src/Terminal/Command.cs | 37 +----- .../src/Terminal/Commands/MagicMaterials.cs | 25 +++- .../EquipmentAndQuickSlots.cs | 8 +- EquipmentAndQuickSlots/Package/CHANGELOG.md | 5 + EquipmentAndQuickSlots/Package/manifest.json | 2 +- .../src/InventoryPatches.cs | 39 +++++- EquipmentAndQuickSlots/src/Migration.cs | 52 +++++++- EquipmentAndQuickSlots/src/Slots.cs | 46 +++++-- EquipmentAndQuickSlots/src/Terminal_Patch.cs | 23 +--- 17 files changed, 314 insertions(+), 181 deletions(-) diff --git a/AdvancedPortals/AdvancedPortals.cs b/AdvancedPortals/AdvancedPortals.cs index d1c882be4..fcaee5fa3 100644 --- a/AdvancedPortals/AdvancedPortals.cs +++ b/AdvancedPortals/AdvancedPortals.cs @@ -99,45 +99,41 @@ private void OnDestroy() Config.Save(); } + private FileSystemWatcher _configWatcher; + private void SetupWatcher() { + // The reload is driven by ConfigFileReloader watching the file's timestamp; the events + // below only ask it to look sooner than its own poll would. See that class for why the + // events on their own are not enough on a Linux server, and why a connected client does + // not reload. + // + // Nothing to apply by hand once it does: Config.Reload raises SettingChanged for every + // entry whose value actually changed, and PieceLoader and Portals both act on that + // themselves. + ConfigFileReloader.Begin(Config, ConfigFileFullPath); + FileSystemWatcher watcher = new(BepInEx.Paths.ConfigPath, ConfigFileName); - watcher.Changed += ReadConfigValues; - watcher.Created += ReadConfigValues; - watcher.Renamed += ReadConfigValues; - watcher.IncludeSubdirectories = true; + watcher.Changed += OnConfigFileEvent; + watcher.Created += OnConfigFileEvent; + watcher.Renamed += OnConfigFileEvent; + // FileName included so an editor that saves by writing a temp file and renaming it over + // the config still reports; LastWrite alone only covers writes made in place. + watcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size; watcher.SynchronizingObject = ThreadingHelper.SynchronizingObject; watcher.EnableRaisingEvents = true; + // Not IncludeSubdirectories: the file only ever lives directly in BepInEx/config, and + // recursing made Mono register an inotify watch for every subdirectory under it - every + // other mod's config folder included - for a filter that can only ever match at the top. + // + // Held in a field so it can be disposed or replaced later. Mono's watcher backends keep + // every instance rooted until Dispose, so this is not what keeps events flowing. + _configWatcher = watcher; } - private DateTime _lastReloadTime; - private const long RELOAD_DELAY = 10000000; // One second - - private void ReadConfigValues(object sender, FileSystemEventArgs e) + private void OnConfigFileEvent(object sender, FileSystemEventArgs e) { - DateTime now = DateTime.Now; - long time = now.Ticks - _lastReloadTime.Ticks; - if (!File.Exists(ConfigFileFullPath) || time < RELOAD_DELAY) return; - - // A connected client must not reload: Jotunn has already replaced these values with the - // server's, and a reload would clobber them with whatever this machine happens to have on disk. - // The main menu (no ZNet yet) and a singleplayer or host session both still reload. - if (ZNet.instance != null && !ZNet.instance.IsServer()) return; - - try - { - APLogger.LogInfo("Attempting to reload configuration..."); - Config.Reload(); - } - catch - { - APLogger.LogWarning($"There was an issue loading {ConfigFileName}"); - return; - } - - // Nothing to apply by hand: Config.Reload raises SettingChanged for every entry whose value - // actually changed, and PieceLoader and Portals both act on that themselves. - _lastReloadTime = now; + ConfigFileReloader.CheckSoon(); } } } diff --git a/EpicLoot/EpicLoot.cs b/EpicLoot/EpicLoot.cs index 252ab1f70..5df55b1c6 100644 --- a/EpicLoot/EpicLoot.cs +++ b/EpicLoot/EpicLoot.cs @@ -1162,32 +1162,33 @@ public static void SetWorldLuckFactor(float luckFactor) { _instance._worldLuckFactor = luckFactor; } + private FileSystemWatcher _configWatcher; + private void SetupWatcher() { + // The reload is driven by ConfigFileReloader watching the file's timestamp; the events below + // only ask it to look sooner than its own poll would. See that class for why the events on + // their own are not enough on a Linux server, and why a connected client does not reload. + ConfigFileReloader.Begin(Config, ConfigFileFullPath); + FileSystemWatcher watcher = new(BepInEx.Paths.ConfigPath, ConfigFileName); - watcher.Changed += ReadConfigValues; - watcher.Created += ReadConfigValues; - watcher.Renamed += ReadConfigValues; - watcher.IncludeSubdirectories = true; + watcher.Changed += OnConfigFileEvent; + watcher.Created += OnConfigFileEvent; + watcher.Renamed += OnConfigFileEvent; + // FileName included so an editor that saves by writing a temp file and renaming it over the + // config still reports; LastWrite alone only covers writes made in place. + watcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size; watcher.SynchronizingObject = ThreadingHelper.SynchronizingObject; watcher.EnableRaisingEvents = true; + // Not IncludeSubdirectories: the file only ever lives directly in BepInEx/config, and + // recursing made Mono register an inotify watch for every subdirectory under it - every + // other mod's config folder included - for a filter that can only ever match at the top. + // + // Held in a field so it can be disposed or replaced later. Mono's watcher backends keep every + // instance rooted until Dispose, so this is not what keeps events flowing. + _configWatcher = watcher; } - private DateTime _lastReloadTime; - private const long RELOAD_DELAY = 10000000; // One second - - private void ReadConfigValues(object sender, FileSystemEventArgs e) { - var now = DateTime.Now; - var time = now.Ticks - _lastReloadTime.Ticks; - if (!File.Exists(ConfigFileFullPath) || time < RELOAD_DELAY) return; - - try { - Log("Attempting to reload configuration..."); - Config.Reload(); - } catch { - Log($"There was an issue loading {ConfigFileName}"); - return; - } - - _lastReloadTime = now; + private void OnConfigFileEvent(object sender, FileSystemEventArgs e) { + ConfigFileReloader.CheckSoon(); } } diff --git a/EpicLoot/Package/CHANGELOG.md b/EpicLoot/Package/CHANGELOG.md index 091ca18b0..57dfb479c 100644 --- a/EpicLoot/Package/CHANGELOG.md +++ b/EpicLoot/Package/CHANGELOG.md @@ -1,3 +1,12 @@ +**0.14.3** + +* Improves config synchronization refresh from disk on dedicated Linux servers, now also for the baseconfig json files (magiceffects, loottables, ...): an edit is applied once the file stops changing, and is picked up even when the server never sees a file event (NFS, SFTP, container mounts) +* A config reloaded on a player-hosted game is now pushed to the connected players, not only from a dedicated server +* A client connected to a server no longer applies edits to its own baseconfig files until it disconnects, at which point its own files are restored +* Config reloads and server config pushes now log at the default log level +* Fixed magicmats spawning broken items (reminder magicmats is a cheat command) +* Delays main menu welcome message and config prompt until after cutscenes have played + **0.14.2** * Fixes biome explored not being updated on Deep North diff --git a/EpicLoot/src/Config/ConfigVersionManager.cs b/EpicLoot/src/Config/ConfigVersionManager.cs index 53133950f..18fa4785d 100644 --- a/EpicLoot/src/Config/ConfigVersionManager.cs +++ b/EpicLoot/src/Config/ConfigVersionManager.cs @@ -52,8 +52,8 @@ public static class ConfigVersionManager /// Refreshes every baseconfig file the player has not edited, and collects the ones they have. /// /// Must run *before* ELConfig.InitializeConfig so the refreshed contents are what gets - /// deserialized. Writing afterwards would rely on the per-file FileSystemWatcher firing part way - /// through Awake, which is both asynchronous and unnecessary. + /// deserialized, and before the reload scheduler takes its baseline of each file there, so these + /// writes are never mistaken for edits. /// public static void RefreshUnmodifiedConfigs() { @@ -297,9 +297,9 @@ public static void DeclineOutdatedConfigs() } /// - /// Backs up every outdated config to a timestamped folder, then rewrites it from the embedded - /// default. Writing each file trips its FileSystemWatcher, so the in-memory config reloads - /// without a restart (the same mechanism FilePatching.LoadPatchedJSON relies on). + /// Backs up every outdated config to a timestamped folder, rewrites it from the embedded default, + /// and re-reads the rewritten files so the in-memory config follows without a restart. The reload + /// scheduler would get there on its next poll; the player just asked for it, so it is done now. /// public static void BackupAndResetOutdatedConfigs() { @@ -345,6 +345,7 @@ public static void BackupAndResetOutdatedConfigs() } _state.Save(); + ELConfig.ReloadBaseConfigsFromDisk(updated.Select(name => $"{name}.json").ToList()); EpicLoot.LogForce($"Updated {updated.Count} Epic Loot config file(s) to version {EpicLoot.Version}. " + $"The previous files were backed up to {backupDir}"); } diff --git a/EpicLoot/src/Config/ConfigVersionState.cs b/EpicLoot/src/Config/ConfigVersionState.cs index 45d8cc5bd..eb1046f64 100644 --- a/EpicLoot/src/Config/ConfigVersionState.cs +++ b/EpicLoot/src/Config/ConfigVersionState.cs @@ -52,7 +52,7 @@ public class ConfigVersionState public Dictionary Files = new Dictionary(); // Lives outside baseconfig/ so it can never be confused for a config the player should edit, and - // so it can never trip the per-file FileSystemWatchers registered in ELConfig.SychronizeConfig. + // so writing it can never wake the baseconfig watcher ELConfig.SetupBaseConfigFileWatch installs. public static string FilePath => Path.Combine(Paths.ConfigPath, "EpicLoot", "configstate.json"); public static ConfigVersionState Load() diff --git a/EpicLoot/src/Config/ELConfig.cs b/EpicLoot/src/Config/ELConfig.cs index 7a7e2f32a..b1315e9cf 100755 --- a/EpicLoot/src/Config/ELConfig.cs +++ b/EpicLoot/src/Config/ELConfig.cs @@ -803,6 +803,7 @@ public static void InitializeConfig() { EnchantingUpgradesRPC, EnchantingTableUpgrades.GetCFG); SychronizeConfig("itemsorter.json", AutoAddEnchantableItems.InitializeConfig, AutoSorterConfigurationRPC, AutoAddEnchantableItems.GetCFG); + SetupBaseConfigFileWatch(GetOverhaulDirectoryPath()); SetupPatchConfigFileWatch(FilePatching.PatchesDirPath); ItemManager.OnItemsRegistered += InitializeRecipeOnReady; @@ -890,8 +891,8 @@ ZPackage SendInitialConfig() { // Setup the initial synchronization for network connection SynchronizationManager.Instance.AddInitialSynchronization(targetRPC, SendInitialConfig); - // 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). + // Reads the file back into the live config. Shared by the reload scheduler and by the + // hot-reload pass, which cannot wait for the poll (see ReloadBaseConfigsFromDisk). bool ReloadFromDisk() { if (!File.Exists(baseCfgLocation)) { return false; @@ -903,20 +904,25 @@ bool ReloadFromDisk() { throw new InvalidDataException("file deserialized to null"); } - EpicLoot.Log($"Config file {baseCfgLocation} has been modified, updating config."); setupMethod(contents); } catch (Exception ex) { EpicLoot.LogWarningForce($"Config file {baseCfgLocation} is invalid and config will not be updated." + ex); return false; } - if (GUIManager.IsHeadless()) { + // Forced: at the default log level this is the only evidence an edit was picked up. + // IsServer rather than IsHeadless: a player hosting the game is a server too, and its + // peers were otherwise left on the definitions they were handed at connect. + if (ZNet.instance != null && ZNet.instance.IsServer() && ZNet.instance.m_peers.Count > 0) { + List peers = ZNet.instance.m_peers; try { - targetRPC.SendPackage(ZNet.instance.m_peers, SendConfig(JsonConvert.SerializeObject(getConfig()))); - } catch { - // TODO check - EpicLoot.LogError($"Error while server syncing {filename} configs"); + targetRPC.SendPackage(peers, SendConfig(JsonConvert.SerializeObject(getConfig()))); + EpicLoot.LogForce($"Reloaded {filename} from disk and pushed it to {peers.Count} connected peer(s)."); + } catch (Exception ex) { + EpicLoot.LogErrorForce($"Reloaded {filename} from disk but pushing it to peers failed: {ex}"); } + } else { + EpicLoot.LogForce($"Reloaded {filename} from disk."); } return true; @@ -924,34 +930,16 @@ bool ReloadFromDisk() { // Registered in call order, so the load-order dependencies InitializeConfig encodes // (biomedata before adventuredata before iteminfo, shardstones before shardstoneconversions) - // still hold on a hot reload. Fourteen independent watchers fire in whatever order the OS - // delivers them. + // hold on a hot reload too: the reload scheduler applies changed files in this same order. 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: - // LastWrite alone never reports create/delete/rename actions, which left the - // Created/Deleted/Renamed handlers dead and missed editors that save via - // write-temp-then-rename. - FileSystemWatcher fsw = new FileSystemWatcher(ELConfig.GetOverhaulDirectoryPath()); - fsw.Created += new FileSystemEventHandler(FileModified); - fsw.Changed += new FileSystemEventHandler(FileModified); - fsw.Renamed += new RenamedEventHandler(FileModified); - fsw.Deleted += new FileSystemEventHandler(FileModified); - fsw.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName; - fsw.SynchronizingObject = ThreadingHelper.SynchronizingObject; - fsw.EnableRaisingEvents = true; - fsw.Filter = filename; + // Registered after the initial read (and after LoadPatchedJSON's write above), so the file as + // it stands now is the baseline and startup writes are never mistaken for edits. The scheduler + // polls the file's timestamp and size; the watcher SetupBaseConfigFileWatch installs only asks + // it to look sooner. See ConfigFileReloader for why events on their own are not enough on a + // server, and why a connected client does not reload. + ConfigFileReloader.Watch(baseCfgLocation, ReloadFromDisk); } public static void StartupProcessModifiedLocalizations() { @@ -1002,24 +990,27 @@ public static void StartupProcessModifiedLocalizations() { /// an empty collection reloads none. /// internal static void ReloadBaseConfigsFromDisk(ICollection fileNames) { + string baseConfigDir = GetOverhaulDirectoryPath(); foreach ((string fileName, Func reloadFromDisk) in BaseConfigReloaders) { if (fileNames != null && !fileNames.Contains(fileName)) { continue; } reloadFromDisk(); + // What is on disk is now what is in memory, so the scheduler must not apply it a second + // time. Recorded even when the read failed, matching the scheduler's own once-per-edit rule. + ConfigFileReloader.MarkApplied(Path.Combine(baseConfigDir, fileName)); } } /// /// 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. + /// The reload has to be driven from here rather than left to the reload scheduler. That polls + /// the files every couple of seconds, so the auto-add pass below would otherwise run against the + /// pre-patch config still in memory and write 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(); @@ -1049,10 +1040,9 @@ internal static void RunPatchHotReload() { return; } + // The auto-add pass merges onto the live config, writes the result back out and re-reads the + // files it rewrote itself, so nothing here waits on the scheduler. 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) { @@ -1084,11 +1074,48 @@ private static void IngestPatchFilesFromDisk(object s, FileSystemEventArgs e) { PatchReloadDebouncer.Schedule(); } + private static FileSystemWatcher _baseConfigWatcher; + + /// + /// One watcher for the whole baseconfig folder. It reloads nothing itself: an event only asks the + /// reload scheduler to check the files now instead of on its next poll, so what happens to a file + /// is decided by its timestamp and size on the main thread, not by which of an editor's burst of + /// events happened to arrive first. + /// + public static void SetupBaseConfigFileWatch(string path) { + if (_baseConfigWatcher != null) { + _baseConfigWatcher.EnableRaisingEvents = false; + _baseConfigWatcher.Dispose(); + _baseConfigWatcher = null; + } + + // Filter set through the constructor: Mono's Windows backend copies it to the native watcher + // only when EnableRaisingEvents is switched on, so a filter assigned afterwards never applies. + FileSystemWatcher watcher = new FileSystemWatcher(path, "*.json"); + watcher.Created += OnBaseConfigFileEvent; + watcher.Changed += OnBaseConfigFileEvent; + watcher.Renamed += OnBaseConfigFileEvent; + watcher.Deleted += OnBaseConfigFileEvent; + // FileName included so an editor that saves by writing a temp file and renaming it over the + // config still reports; LastWrite alone only covers writes made in place. + watcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size; + // baseconfig-backup/ is a sibling of this folder, not a child, so there is nothing below it. + watcher.IncludeSubdirectories = false; + // No SynchronizingObject: the handler only sets a flag the scheduler reads on the main thread. + watcher.EnableRaisingEvents = true; + _baseConfigWatcher = watcher; + } + + private static void OnBaseConfigFileEvent(object sender, FileSystemEventArgs e) { + ConfigFileReloader.CheckSoon(); + } + 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. + // event. The field is what lets the previous one be disposed; on this Mono runtime the watcher + // backends hold every instance statically until Dispose, so it is not what keeps events flowing. if (_patchWatcher != null) { _patchWatcher.EnableRaisingEvents = false; _patchWatcher.Dispose(); @@ -1169,7 +1196,7 @@ private static IEnumerator OnClientRecieveEnchantingUpgradesConfigs(long sender, } private static IEnumerator OnClientRecieveAutoSorterConfigs(long sender, ZPackage package) { - return ApplyClientConfig("autosorter config", package, AutoAddEnchantableItems.InitializeConfig); + return ApplyClientConfig("itemsorter.json", package, AutoAddEnchantableItems.InitializeConfig); } private static IEnumerator OnClientRecieveShardStonesConfigs(long sender, ZPackage package) { @@ -1191,6 +1218,11 @@ private static IEnumerator ApplyClientConfig(string name, ZPackage package, A EpicLoot.LogErrorForce($"Server-pushed {name} could not be parsed; keeping the currently loaded config."); } else { initialize(parsed); + // Memory now holds the server's copy, not the local file: forget the file's applied stamp + // so the scheduler restores the local copy on its first check after disconnecting. + ConfigFileReloader.MarkDirty(Path.Combine(GetOverhaulDirectoryPath(), name)); + // Forced: at the default log level this is the only sign on a client that a push arrived. + EpicLoot.LogForce($"Applied server-pushed {name}."); } yield return null; } diff --git a/EpicLoot/src/Magic/AutoAddEnchantableItems.cs b/EpicLoot/src/Magic/AutoAddEnchantableItems.cs index 3fd065ba3..a4e880a66 100644 --- a/EpicLoot/src/Magic/AutoAddEnchantableItems.cs +++ b/EpicLoot/src/Magic/AutoAddEnchantableItems.cs @@ -142,7 +142,7 @@ public static void CheckAndAddAllEnchantableItems(bool deregister = true) .Select(x => x.m_itemData.m_dropPrefab.name).ToList(); AddRemoveItemsFromLootLists(magicMats, foundByCategory, newConfig); - // Write out the new config, which will trigger a reload of the config + // Write out the new config; CheckAndAddAllEnchantableItems re-reads every rewritten file once all are written. try { string contents = JsonConvert.SerializeObject(new ItemInfoConfig() { ItemInfo = newConfig }, Formatting.Indented); @@ -158,6 +158,12 @@ public static void CheckAndAddAllEnchantableItems(bool deregister = true) EpicLoot.LogError($"Failed to auto-add items to iteminfo.json: {e.Message}"); return; } + + // The files above were written from the merged result; put that result into memory now + // rather than on the reload scheduler's next poll. Load-bearing for iteminfo: it is only + // ever assigned in memory by re-reading it. The scheduler remains the backstop when the + // write above failed and returned early. + ELConfig.ReloadBaseConfigsFromDisk(RewrittenConfigFiles); } private static void AddRemoveItemsFromLootLists(List magicMats, @@ -276,7 +282,7 @@ private static void AddRemoveItemsFromLootLists(List magicMats, } EpicLoot.Log($"Finished Validating loottable."); - // Write out the new config, which will trigger a reload of the config + // Write out the new config; CheckAndAddAllEnchantableItems re-reads every rewritten file once all are written. try { LootConfig newLootConfig = new LootConfig() @@ -359,7 +365,7 @@ private static void AddRemoveItemsFromVendor(List newConfig) AdventureDataConfig AdventureDataConfigReplacement = AdventureDataManager.Config; AdventureDataConfigReplacement.Gamble.GambleCosts = newGambleItems; - // Write out the new config, which will trigger a reload of the config + // Write out the new config; CheckAndAddAllEnchantableItems re-reads every rewritten file once all are written. EpicLoot.Log("Writing config."); try { diff --git a/EpicLoot/src/Patching/FilePatching.cs b/EpicLoot/src/Patching/FilePatching.cs index 4ec8b7e8c..a5650cdc6 100755 --- a/EpicLoot/src/Patching/FilePatching.cs +++ b/EpicLoot/src/Patching/FilePatching.cs @@ -409,7 +409,8 @@ internal static void LoadPatchedJSON(string filename) ELConfig.GetDefaultEmbeddedFileLocation($"{filename}.json")); JObject baseJson = JObject.Parse(embeddedConfig); string patchedString = BuildPatchedConfig(filename, baseJson); - // Writing the file triggers the config file watcher, which reloads the config in-game. + // At startup SychronizeConfig reads the file right after this; on a patch hot reload + // RunPatchHotReload re-reads it directly rather than waiting on the reload scheduler. File.WriteAllText(baseCfgFile, patchedString); EpicLoot.Log($"Loaded and applied patches for {filename}.json"); diff --git a/EpicLoot/src/Terminal/Command.cs b/EpicLoot/src/Terminal/Command.cs index 6b12763a5..273e3fff2 100644 --- a/EpicLoot/src/Terminal/Command.cs +++ b/EpicLoot/src/Terminal/Command.cs @@ -22,11 +22,11 @@ public Command( bool onlyAdmin = false, bool hideFromHelp = false, params string[] alternates) - // Epic Loot commands are never cheats and never sit behind devcommands, so they run without - // `devcommands` enabled, do not trip the cheat-confirmation prompt or flag the profile as - // cheated, and stay usable from the chat window (Chat.isAllowedCommand rejects both flags). - // The gate is admin instead - see RequireAdmin. - : base(command, description, RequireAdmin(command, action), isCheat: false, isNetwork, onlyServer, isSecret, allowInDevBuild, hideBehindDevCommands: false, optionsFetcher, alwaysRefreshTabOptions || options != null, remoteCommand, onlyAdmin) + // Every Epic Loot command is a cheat: it needs `devcommands` and the cheat confirmation, and flags + // the profile as cheated. Vanilla only enables cheats on the machine running the world + // (Terminal.IsCheatsEnabled requires ZNet.IsServer), so a client of a dedicated server needs a mod + // that grants them to the server's admins, such as Server Devcommands. + : base(command, description, action, isCheat: true, isNetwork, onlyServer, isSecret, allowInDevBuild, hideBehindDevCommands: false, optionsFetcher, alwaysRefreshTabOptions || options != null, remoteCommand, onlyAdmin) { this.options = options; this.hideFromHelp = hideFromHelp; @@ -43,33 +43,6 @@ public Command( TerminalManager._commands[command] = this; } - /// - /// Wraps a command body in the admin check. Every Epic Loot command spawns items, rewrites - /// adventure state or dumps diagnostics, so the gate is the world's admin list rather than - /// devcommands: a solo player or host always passes, a client only when its user id is on - /// the server's adminlist.txt (which the server syncs to every client, so the check reads the same - /// list on both sides). - /// - /// - /// Vanilla's own onlyAdmin constructor flag cannot do this. Nothing in the game ever reads - /// ConsoleCommand.OnlyAdmin, and only the ConsoleEventFailable overload folds it into - /// OnlyServer - the ConsoleEvent overload used here drops it, so passing it is a - /// no-op. Folding it into OnlyServer would be wrong anyway: that rejects the command outright - /// on any client of a dedicated server, admin or not. The check therefore lives in the action, which - /// also means it re-evaluates per invocation rather than being frozen at registration time, when - /// there is no ZNet yet. - /// - private static Terminal.ConsoleEvent RequireAdmin(string command, Terminal.ConsoleEvent action) => args => - { - if (ZNet.instance == null || !ZNet.instance.LocalPlayerIsAdminOrHost()) - { - args.Context?.AddString($"'{command}' requires admin."); - return; - } - - action(args); - }; - /// /// Options for the argument at of , where /// index 0 is the command name itself. diff --git a/EpicLoot/src/Terminal/Commands/MagicMaterials.cs b/EpicLoot/src/Terminal/Commands/MagicMaterials.cs index c9bc8671e..b919ca5a9 100644 --- a/EpicLoot/src/Terminal/Commands/MagicMaterials.cs +++ b/EpicLoot/src/Terminal/Commands/MagicMaterials.cs @@ -1,4 +1,3 @@ -using System; using Jotunn.Managers; using UnityEngine; @@ -8,17 +7,35 @@ public static partial class TerminalManager { private static void SpawnMagicCraftingMaterials(Terminal.ConsoleEventArgs args) { + Transform transform = Player.m_localPlayer.transform; + foreach (string type in EpicLoot.MagicMaterials) { - foreach (ItemRarity rarity in Enum.GetValues(typeof(ItemRarity))) + foreach (ItemRarity rarity in Rarities.All) { string assetName = $"{type}{rarity}"; GameObject itemPrefab = PrefabManager.Instance.GetPrefab(assetName); - Transform transform = Player.m_localPlayer.transform; + if (itemPrefab == null) + { + EpicLoot.LogWarning($"magicmats: no prefab named {assetName}, skipping it."); + continue; + } + ItemDrop itemDrop = UnityEngine.Object.Instantiate(itemPrefab, transform.position + transform.forward * 2f + Vector3.up, Quaternion.identity).GetComponent(); - itemDrop.m_itemData.m_stack = itemDrop.m_itemData.m_shared.m_maxStackSize / 2; + + // Half a stack, but never zero. EtchedRunestone has m_maxStackSize 1, so the integer + // division spawned it at stack 0 -- and nothing downstream normalizes that. Vanilla + // Inventory.CanAddItem then answers true for such a drop (freeStackSpace 0 + free + // cells 0 >= stack 0) while AddItem has nowhere to put it, so with a full inventory + // Player.AutoPickup retries it every frame and the game logs "Trying to add item to + // occupied slot -1, -1" forever. Picked up successfully it is just as bad: a stack-0 + // item counts as zero everywhere, so the material is there but unusable. + // + // SetStack rather than assigning m_stack: it writes the ZDO as well, so the count + // survives the drop being saved or replicated instead of only living on this client. + itemDrop.SetStack(Mathf.Max(1, itemDrop.m_itemData.m_shared.m_maxStackSize / 2)); } } } diff --git a/EquipmentAndQuickSlots/EquipmentAndQuickSlots.cs b/EquipmentAndQuickSlots/EquipmentAndQuickSlots.cs index f3539e536..4c69546f6 100644 --- a/EquipmentAndQuickSlots/EquipmentAndQuickSlots.cs +++ b/EquipmentAndQuickSlots/EquipmentAndQuickSlots.cs @@ -1,12 +1,14 @@ -using System.Reflection; -using BepInEx; +using BepInEx; using Common; using HarmonyLib; +using Jotunn.Utils; +using System.Reflection; using UnityEngine; namespace EquipmentAndQuickSlots { [BepInPlugin(PluginId, "Equipment and Quick Slots", Version)] [BepInDependency(Jotunn.Main.ModGuid)] + [SynchronizationMode(AdminOnlyStrictness.IfOnServer)] [BepInDependency("moreslots", BepInDependency.DependencyFlags.SoftDependency)] [BepInDependency("randyknapp.mods.auga", BepInDependency.DependencyFlags.SoftDependency)] [BepInDependency("randyknapp.mods.epicloot", BepInDependency.DependencyFlags.SoftDependency)] @@ -20,7 +22,7 @@ namespace EquipmentAndQuickSlots { [BepInIncompatibility("com.bruce.valheim.comfyquickslots")] public class EquipmentAndQuickSlots : BaseUnityPlugin { public const string PluginId = "randyknapp.mods.equipmentandquickslots"; - public const string Version = "3.1.1"; + public const string Version = "3.1.2"; public static Sprite PaperdollMale; public static Sprite PaperdollFemale; diff --git a/EquipmentAndQuickSlots/Package/CHANGELOG.md b/EquipmentAndQuickSlots/Package/CHANGELOG.md index d7c08627e..35bd13f74 100644 --- a/EquipmentAndQuickSlots/Package/CHANGELOG.md +++ b/EquipmentAndQuickSlots/Package/CHANGELOG.md @@ -1,3 +1,8 @@ +**3.1.2** +* Fixes item placement change when secondary sources change the inventory size +* Console commands need devcommands again +* Makes configuring the mod without it being on the server more flexible, server synced configs are still enforced if the mod is on the server + **3.1.1** * Fix item duplication from loading/unloading a character in Deep North diff --git a/EquipmentAndQuickSlots/Package/manifest.json b/EquipmentAndQuickSlots/Package/manifest.json index 75e824a90..95b7e63a2 100644 --- a/EquipmentAndQuickSlots/Package/manifest.json +++ b/EquipmentAndQuickSlots/Package/manifest.json @@ -1,6 +1,6 @@ { "name": "EquipmentAndQuickSlots", - "version_number": "3.1.1", + "version_number": "3.1.2", "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/src/InventoryPatches.cs b/EquipmentAndQuickSlots/src/InventoryPatches.cs index 74921f7b7..7d684e6be 100644 --- a/EquipmentAndQuickSlots/src/InventoryPatches.cs +++ b/EquipmentAndQuickSlots/src/InventoryPatches.cs @@ -98,6 +98,34 @@ private static void Prefix(Humanoid __instance) { } } + // Vanilla stores the row count with the character but applies it only in OnSpawned, and + // Player.Load reads it only after the inventory -- so the saved items come in while BaseRows + // is still the prefab's count (fresh launch) or the previous character's (character + // switch). Everything after this that reads a saved grid position has to see this + // character's layout: the row migration, the legacy 2.x migration and the backup restore + // (all lower priority). The resize in OnSpawned then finds the cells already in place. + [HarmonyPatch(typeof(Player), nameof(Player.Load))] + private static class Player_Load_LayOutSlotsForCharacterRows { + [HarmonyPriority(Priority.First)] + private static void Postfix(Player __instance) { + if (!FejdStartup.instance && !IsValidPlayer(__instance)) + return; + + // Exactly what OnSpawned will do: a stored count is applied with SetInventorySize's + // clamp; a missing or unreadable one leaves the size a new Player is created with. + int rows = __instance.TryGetUniqueKeyValue(Player.InventoryRowsKey, out string stored) && int.TryParse(stored, out int storedRows) + ? Mathf.Clamp(storedRows, 0, 9) + : PrefabRows; + + loadedPlayer = __instance; + try { + SetBaseRowsForLoad(rows); + } finally { + loadedPlayer = null; + } + } + } + [HarmonyPatch(typeof(Player), nameof(Player.Update))] private static class Player_Update_UpdateInventoryHeight { private static void Postfix(Player __instance) { @@ -558,15 +586,22 @@ private static void Prefix(Inventory __instance, string name) { private static void Postfix() => itemToFindSlot = null; } - [HarmonyPatch(typeof(Inventory), nameof(Inventory.AddItem), typeof(string), typeof(int), typeof(float), typeof(Vector2i), typeof(bool), typeof(int), typeof(int), typeof(long), typeof(string), typeof(Dictionary), typeof(int), typeof(bool), typeof(bool), typeof(bool))] + // Brackets each item Inventory.Load adds. Current saves come in through + // AddItem(int prefabHash, ItemData, bool) and old ones through LoadOld's string-name overload, + // which only hashes the name and forwards -- both end here. Before the Sept 2026 update Load + // called the string overload directly; the marker left on it stopped firing without an error, + // taking the load guard and the item loss prevention above with it. + [HarmonyPatch(typeof(Inventory), nameof(Inventory.AddItem), typeof(int), typeof(int), typeof(float), typeof(Vector2i), typeof(bool), typeof(int), typeof(int), typeof(long), typeof(string), typeof(Dictionary), typeof(int), typeof(bool), typeof(bool), typeof(bool))] public static class Inventory_AddItem_OnLoad_Marker { public static bool inCall = false; [HarmonyPriority(Priority.First)] private static void Prefix() => inCall = true; + // A finalizer, not a postfix: a marker left on by a throw would let the load-only item + // loss prevention act on every failed add afterwards, refused drag & drops included. [HarmonyPriority(Priority.First)] - private static void Postfix() => inCall = false; + private static void Finalizer() => inCall = false; } [HarmonyPatch(typeof(Inventory), nameof(Inventory.CanAddItem), typeof(ItemDrop.ItemData), typeof(int))] diff --git a/EquipmentAndQuickSlots/src/Migration.cs b/EquipmentAndQuickSlots/src/Migration.cs index 04cdb1dfb..5a85bcea2 100644 --- a/EquipmentAndQuickSlots/src/Migration.cs +++ b/EquipmentAndQuickSlots/src/Migration.cs @@ -1,5 +1,6 @@ using EquipmentAndQuickSlots.src; using HarmonyLib; +using System.Collections.Generic; using System.Globalization; using System.Linq; using static EquipmentAndQuickSlots.Slots; @@ -123,8 +124,11 @@ private static void RemoveLegacyKey(Player player, string key) { // past the end of the inventory where nothing can reach them again. // // The shift is a bijection over the slot region (everything at or below the old first slot - // row moves by the same delta), so it cannot make two items collide. Anything that was in - // the old visible grid and now finds itself on a slot cell is left to the validation sweep. + // row moves by the same delta), so slot contents cannot collide with each other. When the + // rows shrank, though, the region lands on rows that held ordinary items. Those are lifted + // out before the shift and rehomed after it: left in place, each would share a cell with a + // slot item, and the validation sweep settles an overlap by list order -- whichever of the + // two happens to come first keeps the cell. internal static void MigrateSlotRegionRows(Player player, int previousVisibleRows) { int delta = VisibleRows - previousVisibleRows; Inventory inventory = player.GetInventory(); @@ -132,29 +136,65 @@ internal static void MigrateSlotRegionRows(Player player, int previousVisibleRow return; int moved = 0; + List displaced = new List(); foreach (ItemDrop.ItemData item in inventory.m_inventory) { - if (item.m_gridPos.y < previousVisibleRows) + if (item.m_gridPos.y < previousVisibleRows) { + // Only possible when the rows shrank: an ordinary item in a row the slot region is + // about to cover (or that is now past the end of the grid). Parked off-grid while + // the region moves, so no cell lookup below can mistake it for a slot's contents. + // It never leaves the inventory, and the sweep rescues off-grid items regardless. + if (item.m_gridPos.y >= VisibleRows) { + displaced.Add(item); + item.m_gridPos = emptyPosition; + } + continue; + } item.m_gridPos = new Vector2i(item.m_gridPos.x, item.m_gridPos.y + delta); moved++; } - if (moved == 0) + if (moved == 0 && displaced.Count == 0) return; + ClearCachedItems(); + foreach (ItemDrop.ItemData item in displaced) { + item.m_gridPos = FindHomeForDisplacedItem(inventory, item); + ClearCachedItems(); + } + // Record the new count now: a second Load on this same object would otherwise read the // stale marker and shift everything a second time. player.m_customData[VisibleRowsKey] = VisibleRows.ToString(CultureInfo.InvariantCulture); - ClearCachedItems(); inventory.Changed(); SlotValidation.ValidateItems(); SlotValidation.ValidateSlots(); // Ungated: this moved the player's gear, and a report about misplaced items has to // be answerable from the log even with logging turned off. - EquipmentAndQuickSlots.LogInfo($"Visible rows changed {previousVisibleRows} -> {VisibleRows}; moved {moved} item(s) with the slot region"); + EquipmentAndQuickSlots.LogInfo($"Visible rows changed {previousVisibleRows} -> {VisibleRows}; moved {moved} item(s) with the slot region" + + (displaced.Count > 0 ? $" and {displaced.Count} out of the rows it now covers" : "")); + } + + // The bottom of the visible grid first, keeping the item near the rows it came from + // (FindEmptySlot only scans the visible rows, then offers a free quick slot); then any slot + // the item belongs in; then room made by pushing a visible item into a slot. Failing all of + // that, the last cell of the region, as for migrated 2.x items: the sweep keeps looking. + private static Vector2i FindHomeForDisplacedItem(Inventory inventory, ItemDrop.ItemData item) { + Vector2i free = inventory.FindEmptySlot(false); + if (free.x >= 0) + return free; + + if (TryFindFreeSlotForItem(item, out Slot slot)) + return slot.GridPosition; + + if (TryMakeFreeSpaceInPlayerInventory(out Vector2i gridPos)) + return gridPos; + + EquipmentAndQuickSlots.LogWarning($"No room for {item.m_shared.m_name} from a removed inventory row; parking it for validation to relocate"); + return new Vector2i(InventoryWidth - 1, FullHeight - 1); } private static bool TryGetPreviousVisibleRows(Player player, out int visibleRows) { diff --git a/EquipmentAndQuickSlots/src/Slots.cs b/EquipmentAndQuickSlots/src/Slots.cs index bc7044b25..b0f6a5c4e 100644 --- a/EquipmentAndQuickSlots/src/Slots.cs +++ b/EquipmentAndQuickSlots/src/Slots.cs @@ -73,8 +73,10 @@ public string OwnerGuid { get; internal set; } - internal void UpdateGridPosition() { - ItemDrop.ItemData item = Item; + // moveItem: the resident travels with the cell. Off while a character loads, when items + // still hold the positions they were saved at (see SetBaseRowsForLoad). + internal void UpdateGridPosition(bool moveItem = true) { + ItemDrop.ItemData item = moveItem ? Item : null; _gridPos = new Vector2i(_index % InventoryWidth, VisibleRows + _index / InventoryWidth); if (item != null) item.m_gridPos = _gridPos; @@ -203,6 +205,11 @@ public Slot(string slotID, int slotIndex, Func getName, Func - /// Registers a console command behind the world's admin list: a solo player or host always - /// passes, a client only when its user id is on the server's adminlist.txt (which the server - /// syncs to every client, so both sides read the same list). + /// Registers a console command as a cheat: it needs devcommands and the cheat confirmation, and + /// flags the profile as cheated. Vanilla only enables cheats on the machine running the world + /// (Terminal.IsCheatsEnabled requires ZNet.IsServer), so a client of a dedicated server needs a + /// mod that grants them to the server's admins, such as Server Devcommands. /// - /// - /// The check has to live in the action. Nothing in the game reads - /// Terminal.ConsoleCommand.OnlyAdmin, and only the ConsoleEventFailable overload folds that flag - /// into OnlyServer - the ConsoleEvent overload used here drops it, so passing onlyAdmin: true - /// would do nothing. OnlyServer would be wrong regardless: it rejects the command on any client - /// of a dedicated server, admin or not. - /// private static void Register(string name, string description, Terminal.ConsoleEvent action) { - new Terminal.ConsoleCommand(name, description, args => { - if (ZNet.instance == null || !ZNet.instance.LocalPlayerIsAdminOrHost()) { - args.Context?.AddString($"'{name}' requires admin."); - return; - } - - action(args); - }); + new Terminal.ConsoleCommand(name, description, action, isCheat: true); } } } From 495c9999b54a964398bb51aa9d315e8b014f5a40 Mon Sep 17 00:00:00 2001 From: MidnightsFX Date: Fri, 11 Sep 2026 09:49:03 -0700 Subject: [PATCH 02/20] version bump --- EpicLoot/EpicLoot.cs | 2 +- EpicLoot/Package/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/EpicLoot/EpicLoot.cs b/EpicLoot/EpicLoot.cs index 5df55b1c6..ecef836cd 100644 --- a/EpicLoot/EpicLoot.cs +++ b/EpicLoot/EpicLoot.cs @@ -38,7 +38,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.14.2"; + public const string Version = "0.14.3"; private static string ConfigFileName = PluginId + ".cfg"; private static string ConfigFileFullPath = BepInEx.Paths.ConfigPath + Path.DirectorySeparatorChar + ConfigFileName; diff --git a/EpicLoot/Package/manifest.json b/EpicLoot/Package/manifest.json index 361fd736a..ef7659e4b 100644 --- a/EpicLoot/Package/manifest.json +++ b/EpicLoot/Package/manifest.json @@ -1,6 +1,6 @@ { "name": "EpicLoot", - "version_number": "0.14.2", + "version_number": "0.14.3", "website_url": "https://discord.gg/ZNhYeavv3C", "description": "Adds loot drops, magic items, and enchanting to Valheim.", "dependencies": [ From 7bbe3101fa0f4f177616a9c300b214eaa5ce7754 Mon Sep 17 00:00:00 2001 From: MidnightsFX Date: Fri, 11 Sep 2026 22:10:52 -0700 Subject: [PATCH 03/20] 0.14.4 --- EpicLoot/EpicLoot.cs | 2 +- EpicLoot/Package/manifest.json | 2 +- EpicLoot/src/GamePatches/Humanoid_Patch.cs | 15 ++- .../GamePatches/Multiplayer_Player_Patch.cs | 8 ++ EpicLoot/src/Magic/AutoAddEnchantableItems.cs | 79 ++++++++++++- EpicLoot/src/Magic/ItemDataExtensions.cs | 108 +++++++++++++++++- .../src/Magic/MagicItemEffects/Bloodlust.cs | 25 +++- .../src/Magic/MagicItemEffects/MultiShot.cs | 6 +- EpicLoot/src/Terminal/Commands/MagicItem.cs | 18 ++- EpicLoot/src/Terminal/Utils.cs | 2 +- EpicLoot/src/Tooltip/MagicTooltip_Helpers.cs | 4 +- EpicLoot/src/data/CustomDataManager.cs | 6 +- 12 files changed, 246 insertions(+), 29 deletions(-) diff --git a/EpicLoot/EpicLoot.cs b/EpicLoot/EpicLoot.cs index ecef836cd..75b6823a5 100644 --- a/EpicLoot/EpicLoot.cs +++ b/EpicLoot/EpicLoot.cs @@ -38,7 +38,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.14.3"; + public const string Version = "0.14.4"; private static string ConfigFileName = PluginId + ".cfg"; private static string ConfigFileFullPath = BepInEx.Paths.ConfigPath + Path.DirectorySeparatorChar + ConfigFileName; diff --git a/EpicLoot/Package/manifest.json b/EpicLoot/Package/manifest.json index ef7659e4b..4b25fa97a 100644 --- a/EpicLoot/Package/manifest.json +++ b/EpicLoot/Package/manifest.json @@ -1,6 +1,6 @@ { "name": "EpicLoot", - "version_number": "0.14.3", + "version_number": "0.14.4", "website_url": "https://discord.gg/ZNhYeavv3C", "description": "Adds loot drops, magic items, and enchanting to Valheim.", "dependencies": [ diff --git a/EpicLoot/src/GamePatches/Humanoid_Patch.cs b/EpicLoot/src/GamePatches/Humanoid_Patch.cs index c53846912..8f4d96ab9 100644 --- a/EpicLoot/src/GamePatches/Humanoid_Patch.cs +++ b/EpicLoot/src/GamePatches/Humanoid_Patch.cs @@ -43,10 +43,21 @@ public static void SetupVisEquipment_Postfix(Humanoid __instance, bool isRagdoll private static void AssignEmptyToNull(ref ItemDrop.ItemData data) { - if (data != null && data.m_dropPrefab == null) + if (data == null || data.m_dropPrefab != null) { - data.m_dropPrefab = EpicAssets.DummyPrefab(); + return; + } + + // Try for the real prefab first. The dummy exists only to keep SetupVisEquipment from throwing, + // but it is an empty stand-in: an item wearing it renders as nothing, and being non-null while + // carrying no ItemDrop it slips past every `m_dropPrefab == null` guard downstream. Stamping it + // is the fallback, not the goal. + if (data.HealDropPrefab()) + { + return; } + + data.m_dropPrefab = EpicAssets.DummyPrefab(); } } } diff --git a/EpicLoot/src/GamePatches/Multiplayer_Player_Patch.cs b/EpicLoot/src/GamePatches/Multiplayer_Player_Patch.cs index b283d0ff7..c2b57d1af 100644 --- a/EpicLoot/src/GamePatches/Multiplayer_Player_Patch.cs +++ b/EpicLoot/src/GamePatches/Multiplayer_Player_Patch.cs @@ -151,6 +151,14 @@ private static bool DoCheck(Player player, ZDO zdo, string equipKey, if (itemPrefab?.GetComponent()?.m_itemData is ItemDrop.ItemData targetItemData) { itemData = targetItemData.Clone(); + // An ObjectDB prefab is never instantiated, so ItemDrop.Awake -- the only thing that + // assigns m_dropPrefab -- has never run on it, and the clone inherits a NULL prefab + // reference. Vanilla does exactly this assignment after the same Clone() in + // Inventory.AddItem(GameObject, int). Without it this stub reaches SetupVisEquipment with + // a null m_dropPrefab, where Humanoid_Patch.AssignEmptyToNull stamps the empty + // EL_DummyPrefab into it: the weapon then renders as nothing, and because the dummy + // carries no ItemDrop component every later InitializeCustomData on that item throws. + itemData.m_dropPrefab = itemPrefab; itemData.m_durability = float.PositiveInfinity; MagicItemComponent magicItemComponent = itemData.Data().GetOrCreate(); MagicItem stubMagicItem = new MagicItem { Rarity = ItemRarity.Legendary, LegendaryID = zdoLegendaryID }; diff --git a/EpicLoot/src/Magic/AutoAddEnchantableItems.cs b/EpicLoot/src/Magic/AutoAddEnchantableItems.cs index a4e880a66..81e120904 100644 --- a/EpicLoot/src/Magic/AutoAddEnchantableItems.cs +++ b/EpicLoot/src/Magic/AutoAddEnchantableItems.cs @@ -112,7 +112,8 @@ public static void CheckAndAddAllEnchantableItems(bool deregister = true) i.m_autoPickup == true && string.IsNullOrEmpty(i.m_itemData.m_shared.m_dlc) && !string.IsNullOrEmpty(i.m_itemData.m_shared.m_description) && - EpicLoot.IsAllowedMagicItemType(i.m_itemData)).ToList(); + EpicLoot.IsAllowedMagicItemType(i.m_itemData) && + !AttackKillsWielder(i.m_itemData)).ToList(); EpicLoot.Log($"Checking all equipment in game."); foundByCategory = EnsureItemsInConfigMutating(foundByCategory, itemsByCategory, allEquipment); @@ -133,6 +134,12 @@ public static void CheckAndAddAllEnchantableItems(bool deregister = true) // merge dataset and ensure unique values List newConfig = MergeItemsByBossConfig(itemsByCategory); + // Strip prop items an EARLIER run already wrote to disk. Filtering allEquipment above only stops + // new ones being added; an entry already in iteminfo.json survives unless + // AutoRemoveEquipmentNotFound is on, and AddRemoveItemsFromLootLists below would feed it straight + // back into the loot tables. + RemovePropItemsFromConfig(newConfig, allItems); + // Add/remove items from vendor if enabled. AddRemoveItemsFromVendor(newConfig); @@ -166,6 +173,63 @@ public static void CheckAndAddAllEnchantableItems(bool deregister = true) ELConfig.ReloadBaseConfigsFromDisk(RewrittenConfigFiles); } + /// + /// True for a prop weapon that kills whoever swings it, which must never be offered to a player. + /// + /// The Deep North update added 28 "SP_" prefabs (SP_AxeBronze, SP_BowDraugrFang, + /// SP_SwordBlackmetal, ...) as scripted-NPC props. They are indistinguishable from real gear by every + /// other test in the scan above -- full ItemDrops with m_autoPickup, a description, no DLC flag and a + /// real item type -- and they even share the vanilla display token ($item_axe_bronze), so they sail + /// into iteminfo.json and from there into the loot lists. What sets them apart is that their PRIMARY + /// attack carries m_attackKillsSelf, and Attack.Trigger (assembly_valheim/Attack.cs:578) answers that + /// with 9,999,999 untyped true damage to the wielder via ApplyDamage the instant the swing completes + /// -- no damage text, no attacker, straight past Character.Damage. Their secondary attack does not set + /// it, which is why only the basic attack is fatal. + /// + /// Tested on the flag rather than the "SP_" name prefix, so a prop added under some other naming + /// convention later is excluded too. + /// + private static bool AttackKillsWielder(ItemDrop.ItemData item) + { + return item.m_shared.m_attack?.m_attackKillsSelf == true || + item.m_shared.m_secondaryAttack?.m_attackKillsSelf == true; + } + + /// + /// Purges every prop item from an already-written config, matched on + /// prefab name -- the same identity EnsureItemsInConfigMutating writes. + /// + private static void RemovePropItemsFromConfig(List config, List allItems) + { + HashSet propNames = new HashSet(allItems + .Where(i => i.m_itemData?.m_shared != null && AttackKillsWielder(i.m_itemData)) + .Select(i => i.name)); + + if (propNames.Count == 0) + { + return; + } + + int removed = 0; + foreach (ItemTypeInfo itemType in config) + { +#pragma warning disable 612 // Items is obsolete, but a config written by an older build may still use it. + removed += itemType.Items.RemoveAll(propNames.Contains); +#pragma warning restore 612 + foreach (KeyValuePair> byBoss in itemType.ItemsByBoss) + { + removed += byBoss.Value.RemoveAll(propNames.Contains); + } + } + + if (removed > 0) + { + EpicLoot.LogWarningForce($"Removed {removed} self-killing prop item entries from iteminfo.json " + + $"(e.g. {string.Join(", ", propNames.OrderBy(x => x).Take(3))}). These are NPC props whose " + + "basic attack kills whoever swings it; they must never be player loot."); + } + } + private static void AddRemoveItemsFromLootLists(List magicMats, Dictionary foundByCategory, List newConfig) @@ -660,10 +724,15 @@ private static bool IsValidLootEntryName(string name, List metaItemSetNa } } - return validItems.Contains(name) - || metaItemSetNames.Contains(name) - || magicMats.Contains(name) - || ObjectDB.instance.GetItemPrefab(name) != null; + if (validItems.Contains(name) || metaItemSetNames.Contains(name) || magicMats.Contains(name)) + { + return true; + } + + // ObjectDB.m_items also holds a few vanilla non-item prefabs (SnowRoller, ...), which + // LootRoller can never spawn as a drop, so a name has to resolve to an actual ItemDrop. + GameObject prefab = ObjectDB.instance.GetItemPrefab(name); + return prefab != null && prefab.TryGetComponent(out ItemDrop _); } // Drops only the unresolvable rarities from an entry's per-rarity map, leaving the entry itself diff --git a/EpicLoot/src/Magic/ItemDataExtensions.cs b/EpicLoot/src/Magic/ItemDataExtensions.cs index dbe625acc..9fe1b04c6 100644 --- a/EpicLoot/src/Magic/ItemDataExtensions.cs +++ b/EpicLoot/src/Magic/ItemDataExtensions.cs @@ -324,22 +324,105 @@ public static List GetMundaneSetPieces(string setName) continue; } - ItemDrop itemDrop = itemPrefab.GetComponent(); - if (itemDrop == null) + // Vanilla registers some non-item prefabs in ObjectDB.m_items (Deep North added + // PropFeastDeepNorth, SnowRoller and FrozenKing_Summon); its own lookups skip them too. + if (!itemPrefab.TryGetComponent(out ItemDrop itemDrop)) { - EpicLoot.LogError($"Item in ObjectDB missing ItemDrop: ({itemPrefab.name})"); continue; } if (itemDrop.m_itemData.m_shared.m_setName == setName) { - results.Add(itemPrefab.GetComponent().m_itemData.m_shared.m_name); + results.Add(itemDrop.m_itemData.m_shared.m_name); } } return results; } + /// + /// Restores a real item prefab to when it is missing or has + /// been replaced by . + /// + /// stamps the dummy -- an empty prefab with no mesh and no ItemDrop -- + /// onto any equipped ItemData whose m_dropPrefab is null, and that write is serialized with the item. The + /// item then renders as nothing ("transparent weapon") and, because the dummy is non-null but carries no + /// ItemDrop, it walks straight past every `m_dropPrefab == null` guard in the mod. Healing here repairs + /// items already saved that way; the sources that produced them are fixed separately. + /// + /// Resolution order: + /// + /// ObjectDB's reference-keyed m_itemByData map (TryGetItemPrefab(SharedData)). + /// ItemData.Clone is a MemberwiseClone, so an instance shares its m_shared reference with the + /// prefab it came from -- this is exact and O(1). + /// A scan of ObjectDB matching m_shared.m_name, for items whose shared data was deep-copied + /// rather than shared (Instantiate does this, and shards rebuild theirs) and so miss the map. + /// + /// Returns true only when a prefab was actually restored. + /// + public static bool HealDropPrefab(this ItemDrop.ItemData itemData) + { + if (itemData?.m_shared == null || ObjectDB.instance == null) + { + return false; + } + + // Unity's operator== is the only thing that reports a destroyed object, so compare against null + // rather than pattern-matching. A prefab that is present and is not the dummy is already good. + GameObject current = itemData.m_dropPrefab; + if (current != null && current.name != EpicAssets.DummyName) + { + return false; + } + + if (!ObjectDB.instance.TryGetItemPrefab(itemData.m_shared, out GameObject prefab) || prefab == null) + { + prefab = FindItemPrefabBySharedName(itemData.m_shared.m_name); + } + + if (prefab == null) + { + // Nothing to restore it to. Leave whatever is there: the dummy at least keeps + // Humanoid.SetupVisEquipment from throwing, which is why it exists. + return false; + } + + itemData.m_dropPrefab = prefab; + EpicLoot.Log($"Healed m_dropPrefab on '{itemData.m_shared.m_name}' -> '{prefab.name}' " + + $"(was {(current == null ? "null" : EpicAssets.DummyName)})."); + return true; + } + + /// + /// Last-resort lookup for : the first ObjectDB item whose shared name matches. + /// Ambiguous in principle (two prefabs may share a display token) but only reached when the exact + /// reference lookup has already failed, and a same-named item prefab is a far better answer than the dummy. + /// + private static GameObject FindItemPrefabBySharedName(string sharedName) + { + if (sharedName.IsNullOrWhiteSpace()) + { + return null; + } + + foreach (GameObject itemPrefab in ObjectDB.instance.m_items) + { + if (itemPrefab == null) + { + continue; + } + + // Vanilla registers some non-item prefabs in ObjectDB.m_items; its own lookups skip them too. + if (itemPrefab.TryGetComponent(out ItemDrop itemDrop) && + itemDrop.m_itemData?.m_shared?.m_name == sharedName) + { + return itemPrefab; + } + } + + return null; + } + /// /// Copies the MagicItemComponent Magic Item from the drop prefab to set the magic data on this instance. /// @@ -350,13 +433,28 @@ public static void InitializeCustomData(this ItemDrop.ItemData itemData) // even when the prefab is unresolved. Cheap no-op for everything else. Shards.EnsureShardMetadata(itemData); + // Repair a dummy-stamped prefab before anything reads it. This runs from the ItemDrop.Awake, + // Inventory.Load and Container.Load postfixes, so it is the point every already-corrupted item + // passes through on load. + itemData.HealDropPrefab(); + GameObject prefab = itemData.m_dropPrefab; if (prefab == null) { return; } - ItemDrop itemDropPrefab = prefab.GetComponent(); + // m_dropPrefab is not necessarily an ITEM prefab: Humanoid_Patch.AssignEmptyToNull stamps + // EpicAssets.DummyPrefab (an empty CreateEmptyPrefab stand-in carrying no ItemDrop) onto any + // ItemData whose m_dropPrefab is null, which turns the safe null this method guards against into a + // non-null that sails past that guard. Without this check GetComponent returns null and the + // m_itemData dereference below throws -- and because the dummy is written back into the ItemData, + // that NRE then repeats on every later Inventory.Load / Container.Load / ItemDrop.Awake for the item. + if (!prefab.TryGetComponent(out ItemDrop itemDropPrefab)) + { + return; + } + if (EpicLoot.CanBeMagicItem(itemDropPrefab.m_itemData) && !itemData.IsExtended()) { MagicItemComponent instanceData = itemData.Data().Add(); diff --git a/EpicLoot/src/Magic/MagicItemEffects/Bloodlust.cs b/EpicLoot/src/Magic/MagicItemEffects/Bloodlust.cs index 1e9286a4c..b57f91115 100755 --- a/EpicLoot/src/Magic/MagicItemEffects/Bloodlust.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Bloodlust.cs @@ -11,9 +11,7 @@ public static class Attack_GetAttackStamina_Prefix_Patch_Bloodlust { public static bool Prefix(Attack __instance, ref float __result) { - if (__instance.m_character is Player player && - MagicEffectsHelper.HasActiveMagicEffectOnWeapon( - player, __instance.m_weapon, MagicEffectType.Bloodlust, out float effectValue)) + if (__instance.m_character is Player && WeaponHasBloodlust(__instance.m_weapon)) { __result = GetBloodlustStamina(); return false; @@ -31,9 +29,7 @@ public static void Prefix(Attack __instance, ref float __state) { __state = __instance.m_attackHealth; - if (__instance.m_character is Player player && - MagicEffectsHelper.HasActiveMagicEffectOnWeapon( - player, __instance.m_weapon, MagicEffectType.Bloodlust, out float effectValue)) + if (__instance.m_character is Player && WeaponHasBloodlust(__instance.m_weapon)) { __instance.m_attackHealth = GetBloodlustHealth(__instance.m_attackHealth, __instance.m_attackStamina); } @@ -45,6 +41,23 @@ public static void Postfix(Attack __instance, ref float __state) } } + /// + /// Bloodlust is a property of the WEAPON being swung -- it rewrites that attack's stamina cost into a + /// health cost -- so it must be read off the weapon, the way Throwable and ChainLightning read theirs. + /// does NOT do that despite its name and + /// its weapon argument: it sums the effect across every equipped magic item plus active set bonuses + /// and subtracts only the OFF-HAND weapon, so a single Bloodlust source anywhere in the loadout made + /// every weapon cost health. Reading the weapon also realigns behaviour with the two places that + /// already use per-item semantics -- the tooltip (MagicTooltipWeapon.AddAttackStaminaUse) and + /// MagicItemEffectDefinition's ItemUsesHealthOnAttack requirement -- which otherwise disagreed with + /// what the game actually charged. + /// + private static bool WeaponHasBloodlust(ItemDrop.ItemData weapon) + { + return weapon != null && weapon.IsMagic(out MagicItem magicItem) && + magicItem.HasEffect(MagicEffectType.Bloodlust, includeSocketed: true); + } + public static float GetBloodlustStamina() { return 0f; diff --git a/EpicLoot/src/Magic/MagicItemEffects/MultiShot.cs b/EpicLoot/src/Magic/MagicItemEffects/MultiShot.cs index 0bba4583c..6004a32ee 100755 --- a/EpicLoot/src/Magic/MagicItemEffects/MultiShot.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/MultiShot.cs @@ -178,7 +178,11 @@ public static void ModifyAttackCost(Player player, float scale, float stamcost, { if (stamcost > 0) { player.UseStamina(stamcost * scale); } if (eitrcost > 0) { player.UseEitr(eitrcost * scale); } - if (healthcost > 0) { player.UseHealth(healthcost * scale); } + // Clamp to leave 1 HP, as vanilla does at both of its own attack-health spends (Attack.cs + // DoMeleeAttack / FireProjectileBurst): Character.UseHealth clamps to 0, not 1, so an unclamped + // charge here can take the player to 0 and kill them. This runs as a FireProjectileBurst PREFIX, + // so vanilla then charges its own (clamped) share in the same method -- ours is on top of that. + if (healthcost > 0) { player.UseHealth(Mathf.Min(player.GetHealth() - 1f, healthcost * scale)); } } } diff --git a/EpicLoot/src/Terminal/Commands/MagicItem.cs b/EpicLoot/src/Terminal/Commands/MagicItem.cs index ef6329838..de18e7692 100644 --- a/EpicLoot/src/Terminal/Commands/MagicItem.cs +++ b/EpicLoot/src/Terminal/Commands/MagicItem.cs @@ -85,7 +85,12 @@ private static List GetValidMagicItemNamesWithRequirements(string effect for (int i = 0; i < ObjectDB.instance.m_items.Count; ++i) { var itemPrefab = ObjectDB.instance.m_items[i]; - var itemData = itemPrefab.GetComponent().m_itemData.Clone(); + if (!itemPrefab.TryGetComponent(out ItemDrop itemDrop)) + { + continue; + } + + var itemData = itemDrop.m_itemData.Clone(); itemData.m_dropPrefab = itemPrefab; MagicItem dummyMagicItem = new MagicItem { Rarity = definition.Requirements.AllowedRarities.Count == 0 ? ItemRarity.Magic : definition.Requirements.AllowedRarities.First() }; if (definition.Requirements.CheckRequirements(itemData, dummyMagicItem)) @@ -119,13 +124,13 @@ private static void SpawnMagicItemWithEffect(Terminal.ConsoleEventArgs args) } GameObject itemPrefab = ObjectDB.instance.GetItemPrefab(itemPrefabNameArg); - if (itemPrefab == null) + if (itemPrefab == null || !itemPrefab.TryGetComponent(out ItemDrop itemDrop)) { args.Context.PrintWarning($"> Could not find item: {itemPrefabNameArg}"); return; } - ItemDrop.ItemData fromItemData = itemPrefab.GetComponent().m_itemData; + ItemDrop.ItemData fromItemData = itemDrop.m_itemData; if (!EpicLoot.CanBeMagicItem(fromItemData)) { args.Context.PrintWarning($"> Can't be magic item: {itemPrefabNameArg}"); @@ -320,7 +325,12 @@ private static List GetValidLegendaryItemNames(string legendaryID, ItemR for (int i = 0; i < ObjectDB.instance.m_items.Count; ++i) { var itemPrefab = ObjectDB.instance.m_items[i]; - var itemData = itemPrefab.GetComponent().m_itemData.Clone(); + if (!itemPrefab.TryGetComponent(out ItemDrop itemDrop)) + { + continue; + } + + var itemData = itemDrop.m_itemData.Clone(); if (!EpicLoot.CanBeMagicItem(itemData)) continue; itemData.m_dropPrefab = itemPrefab; if (itemInfo.Requirements.CheckRequirements(itemData, dummyMagicItem)) diff --git a/EpicLoot/src/Terminal/Utils.cs b/EpicLoot/src/Terminal/Utils.cs index 983c3270b..c4eef1229 100644 --- a/EpicLoot/src/Terminal/Utils.cs +++ b/EpicLoot/src/Terminal/Utils.cs @@ -30,7 +30,7 @@ private static List GetValidMagicItemNames() { _tempAllItemNames.Clear(); _tempAllItemNames.AddRange(ObjectDB.instance.m_items - .Where(x => EpicLoot.CanBeMagicItem(x.GetComponent().m_itemData)) + .Where(x => x.TryGetComponent(out ItemDrop itemDrop) && EpicLoot.CanBeMagicItem(itemDrop.m_itemData)) .Where(x => x.name != "HelmetDverger" && x.name != "BeltStrength" && x.name != "Wishbone") .Select(x => x.name)); lastAllItemNameTime = Time.time; diff --git a/EpicLoot/src/Tooltip/MagicTooltip_Helpers.cs b/EpicLoot/src/Tooltip/MagicTooltip_Helpers.cs index 02063e547..9f6ce7201 100644 --- a/EpicLoot/src/Tooltip/MagicTooltip_Helpers.cs +++ b/EpicLoot/src/Tooltip/MagicTooltip_Helpers.cs @@ -46,8 +46,10 @@ public static float GetJumpStaminaUsageModifierValue(ItemDrop.ItemData item, Mag public static float GetAttackStaminaModifierValue(ItemDrop.ItemData item, MagicItem magicItem, out bool hasModifiers) { hasModifiers = magicItem.HasEffect(MagicEffectType.ModifyAttackStaminaUse); + // Subtract the raw effect fraction, not GetModifyAttackValue's (1 - x) MULTIPLIER: subtracting the + // multiplier rendered a -13% enchant as "-87%". Mirrors GetBlockStaminaModifierValue below. return item.m_shared.m_attackStaminaModifier - - ModifyAttackCosts.GetModifyAttackValue(null, item, MagicEffectType.ModifyAttackStaminaUse); + magicItem.GetTotalEffectValue(MagicEffectType.ModifyAttackStaminaUse, 0.01f); } public static float GetBlockStaminaModifierValue(ItemDrop.ItemData item, MagicItem magicItem, out bool hasModifiers) diff --git a/EpicLoot/src/data/CustomDataManager.cs b/EpicLoot/src/data/CustomDataManager.cs index d50dfd4ee..82751bbdf 100644 --- a/EpicLoot/src/data/CustomDataManager.cs +++ b/EpicLoot/src/data/CustomDataManager.cs @@ -688,9 +688,11 @@ private static void CopyCustomDataFromUpgradedItem(ItemDrop item) { // inserted an empty int bucket into vanilla's global store just to ask a question. var containsDataCount = zdo.GetInt("dataCount", -1) >= 0; - if (containsDataCount != true) { + // Same dummy-prefab hazard as ItemDataExtensions.InitializeCustomData: m_dropPrefab may be + // EpicAssets.DummyPrefab, which carries no ItemDrop, so this must not assume one. + if (containsDataCount != true && prefab.TryGetComponent(out ItemDrop dropPrefabItem)) { item.m_itemData.m_customData = new Dictionary( - prefab.GetComponent().m_itemData.m_customData); + dropPrefabItem.m_itemData.m_customData); int num = 0; zdo.Set("dataCount", item.m_itemData.m_customData.Count); From 6787ba6a8d7882c6d5e90fd60e8f5064fe786ef0 Mon Sep 17 00:00:00 2001 From: MidnightsFX Date: Fri, 11 Sep 2026 22:13:12 -0700 Subject: [PATCH 04/20] changelog --- EpicLoot/Package/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/EpicLoot/Package/CHANGELOG.md b/EpicLoot/Package/CHANGELOG.md index 57dfb479c..1583b89b7 100644 --- a/EpicLoot/Package/CHANGELOG.md +++ b/EpicLoot/Package/CHANGELOG.md @@ -1,3 +1,7 @@ +**0.14.4** +* Adds safety checks for data lookup issues +* Prevents the item auto-sorter from adding new hidden, unused items to the loot pool + **0.14.3** * Improves config synchronization refresh from disk on dedicated Linux servers, now also for the baseconfig json files (magiceffects, loottables, ...): an edit is applied once the file stops changing, and is picked up even when the server never sees a file event (NFS, SFTP, container mounts) From e0399db788a1e43e8102038cd945ac3887555cc4 Mon Sep 17 00:00:00 2001 From: MidnightsFX Date: Sun, 13 Sep 2026 00:08:32 -0700 Subject: [PATCH 05/20] Add an internal denylist for items which absolutely shoult not be used as loot. --- EpicLoot/Package/CHANGELOG.md | 6 ++ EpicLoot/src/Adventure/MerchantPanel.cs | 61 +++++++++---- EpicLoot/src/Adventure/StoreGui_Patch.cs | 44 +++++++--- .../Adventure/feature/BountiesListPanel.cs | 25 ++++-- .../src/Adventure/feature/GambleListPanel.cs | 6 +- .../Adventure/feature/SecretStashListPanel.cs | 11 ++- .../Adventure/feature/TreasureMapListPanel.cs | 6 +- .../src/GatedItemType/GatedItemTypeHelper.cs | 42 +++++++-- EpicLoot/src/Loot/LootDenyList.cs | 85 +++++++++++++++++++ EpicLoot/src/Loot/LootRoller.cs | 18 ++++ EpicLoot/src/Magic/AutoAddEnchantableItems.cs | 75 ++++++++++------ EpicLoot/src/Magic/ItemDataExtensions.cs | 10 +++ EpicLoot/src/Magic/MagicItemComponent.cs | 7 ++ 13 files changed, 323 insertions(+), 73 deletions(-) create mode 100644 EpicLoot/src/Loot/LootDenyList.cs diff --git a/EpicLoot/Package/CHANGELOG.md b/EpicLoot/Package/CHANGELOG.md index 1583b89b7..2865dc702 100644 --- a/EpicLoot/Package/CHANGELOG.md +++ b/EpicLoot/Package/CHANGELOG.md @@ -1,3 +1,9 @@ +**0.14.5** +* Additional safety checks for world upgrades +* Fix for edge cases that could cause the trader UI to not show up +* Fix for existing loot pools that already contain invalid items + + **0.14.4** * Adds safety checks for data lookup issues * Prevents the item auto-sorter from adding new hidden, unused items to the loot pool diff --git a/EpicLoot/src/Adventure/MerchantPanel.cs b/EpicLoot/src/Adventure/MerchantPanel.cs index 9a6384fd3..ed8eaaffd 100755 --- a/EpicLoot/src/Adventure/MerchantPanel.cs +++ b/EpicLoot/src/Adventure/MerchantPanel.cs @@ -63,6 +63,12 @@ public class MerchantPanel : MonoBehaviour, IBeginDragHandler, IDragHandler, IEn private RectTransform _rt; private Vector2 _dragOffset; + /// + /// Indices into that threw since this window was opened. Cleared by + /// OnEnable, so closing and reopening the merchant gives every section another go. + /// + private readonly HashSet _failedPanels = new HashSet(); + public void Awake() { _instance = this; @@ -249,11 +255,9 @@ public void Awake() public void OnEnable() { + _failedPanels.Clear(); UpdateCurrencies(); - foreach (var panel in Panels) - { - panel.RefreshItems(_currencies); - } + ForEachPanel("RefreshItems", panel => panel.RefreshItems(_currencies)); if (InputBlocker != null) { @@ -261,6 +265,37 @@ public void OnEnable() } } + /// + /// Runs one step against every list panel, isolating failures to the panel that caused them. + /// + /// These used to be walked in a bare foreach, so the first panel to throw took the whole + /// merchant window with it: nothing after it in the list ever refreshed and the player saw an + /// empty panel with no indication of why. A panel that throws is reported once and then left + /// out for the rest of this opening, which also keeps a failure in Update out of the log on + /// every frame. + /// + private void ForEachPanel(string step, Action action) + { + for (var i = 0; i < Panels.Count; i++) + { + if (_failedPanels.Contains(i)) + { + continue; + } + + try + { + action(Panels[i]); + } + catch (Exception e) + { + _failedPanels.Add(i); + EpicLoot.LogErrorForce($"[MerchantPanel] {Panels[i].GetType().Name}.{step} failed. " + + $"That section stays as it is until the merchant window is reopened.\n{e}"); + } + } + } + public void OnDisable() { if (GambleSuccessDialog != null) @@ -388,7 +423,7 @@ public void Update() UpdateRefreshTime(); var currenciesChanged = UpdateCurrencies(); - foreach (var panel in Panels) + ForEachPanel("RefreshItems", panel => { // A currency change only changes what the player can afford, never what is on offer: // rebuilding the rows for it wiped the selection, snapped the scroll and (with the @@ -402,34 +437,28 @@ public void Update() { panel.UpdateAffordability(_currencies); } - } + }); RefreshBuyButtons(); } public void RefreshAll() { - foreach (var panel in Panels) + ForEachPanel("RefreshAll", panel => { panel.RefreshItems(_currencies); panel.RefreshButton(_currencies); - } + }); } private void RefreshBuyButtons() { - foreach (var panel in Panels) - { - panel.RefreshButton(_currencies); - } + ForEachPanel("RefreshButton", panel => panel.RefreshButton(_currencies)); } private void UpdateRefreshTime() { - foreach (var panel in Panels) - { - panel.UpdateRefreshTime(); - } + ForEachPanel("UpdateRefreshTime", panel => panel.UpdateRefreshTime()); } private bool UpdateCurrencies() diff --git a/EpicLoot/src/Adventure/StoreGui_Patch.cs b/EpicLoot/src/Adventure/StoreGui_Patch.cs index 084c80fc1..d95812220 100644 --- a/EpicLoot/src/Adventure/StoreGui_Patch.cs +++ b/EpicLoot/src/Adventure/StoreGui_Patch.cs @@ -13,14 +13,21 @@ public static class StoreGui_Patch [HarmonyPostfix] public static void Show_Postfix(StoreGui __instance) { - if (!EpicLoot.IsAdventureModeEnabled() || __instance == null) + // Show runs its body only when the trader or the visibility changed, but the postfix runs + // either way, and Hide leaves m_trader null -- so never assume it is there. + if (!EpicLoot.IsAdventureModeEnabled() || __instance == null || __instance.m_trader == null) { return; } if (__instance.m_trader.m_name == "$npc_hildir") { - if (__instance.transform.Find(nameof(TemperPanel)) == null) + var existingTemperPanel = __instance.transform.Find(nameof(TemperPanel)); + if (existingTemperPanel != null) + { + TemperPanel = existingTemperPanel.gameObject; + } + else { if (TemperPanel != null) { @@ -42,35 +49,50 @@ public static void Show_Postfix(StoreGui __instance) return; } - if (__instance.transform.Find(nameof(MerchantPanel)) == null) + // The hierarchy is the authority, not the static. They can disagree -- the StoreGui.OnDestroy + // postfix clears the static while the panel is still parented under a StoreGui that outlived + // it, and a panel already in place is found here on every later open. Adopting what is + // actually there keeps the SetActive at the end off a null field, which otherwise throws out + // of the postfix and leaves the panel closed for the rest of the session. + var existingMerchantPanel = __instance.transform.Find(nameof(MerchantPanel)); + if (existingMerchantPanel != null) + { + MerchantPanel = existingMerchantPanel.gameObject; + } + else { if (MerchantPanel != null) { + // Orphaned under some other StoreGui: it can never be shown under this one. Object.Destroy(MerchantPanel); } MerchantPanel = Object.Instantiate(EpicAssets.MerchantPanel, __instance.transform, false); + // Named here rather than relying on MerchantPanel.Awake to do it: Awake does not run + // until the object first goes active, and the Find above has to match before that. + MerchantPanel.name = nameof(MerchantPanel); MerchantPanel.AddComponent(); } - MerchantPanel.gameObject.SetActive(true); + MerchantPanel.SetActive(true); } [HarmonyPatch(nameof(StoreGui.Hide))] [HarmonyPostfix] public static void Hide(StoreGui __instance) { - if (global::EpicLoot.TemperPanel.Instance) + // The merchant panel goes first. Running it after the temper panel meant anything thrown + // over there left this one active, and an object that is already active gets no OnEnable on + // the next open -- which is the call that refreshes every list in it. + if (MerchantPanel != null) { - global::EpicLoot.TemperPanel.Instance.Hide(); + MerchantPanel.SetActive(false); } - - if (MerchantPanel == null) + + if (global::EpicLoot.TemperPanel.Instance) { - return; + global::EpicLoot.TemperPanel.Instance.Hide(); } - - MerchantPanel.SetActive(false); } [HarmonyPatch(nameof(StoreGui.OnDestroy))] diff --git a/EpicLoot/src/Adventure/feature/BountiesListPanel.cs b/EpicLoot/src/Adventure/feature/BountiesListPanel.cs index e944fa928..37c30f0ce 100755 --- a/EpicLoot/src/Adventure/feature/BountiesListPanel.cs +++ b/EpicLoot/src/Adventure/feature/BountiesListPanel.cs @@ -27,8 +27,17 @@ public override bool NeedsRefresh() public override void RefreshButton(Currencies playerCurrencies) { var selectedItem = GetSelectedItem(); - - var saveData = Player.m_localPlayer.GetAdventureSaveData(); + + // Runs from Update, which keeps ticking through a death, a respawn and a world change -- + // every other panel already tolerates a missing player here. + var player = Player.m_localPlayer; + if (player == null) + { + MainButton.interactable = false; + return; + } + + var saveData = player.GetAdventureSaveData(); var bountyInProgressCount = saveData.GetInProgressBounties().Count; bool allowedToBuy = !(ELConfig.EnableLimitedBountiesInProgress.Value && bountyInProgressCount >= ELConfig.MaxInProgressBounties.Value); @@ -90,11 +99,14 @@ protected override void OnMainButtonClicked() public override void RefreshItems(Currencies currencies) { - _currentInterval = AdventureDataManager.Bounties.GetCurrentInterval(); + // Rows are gathered before the old ones are destroyed. The other order left the list + // permanently empty whenever the gather threw, since nothing puts rows back until the next + // refresh -- and the next refresh throws in the same place. + var allItems = AdventureDataManager.Bounties.GetAvailableBounties(); + _currentInterval = AdventureDataManager.Bounties.GetCurrentInterval(); DestroyAllListElementsInList(); - var allItems = AdventureDataManager.Bounties.GetAvailableBounties(); for (int index = 0; index < allItems.Count; index++) { var itemInfo = allItems[index]; @@ -195,11 +207,12 @@ private void OnAbandonButtonClicked() public override void RefreshItems(Currencies currencies) { - _currentInterval = AdventureDataManager.Bounties.GetCurrentInterval(); + // Gathered before the destroy, for the reason given in AvailableBountiesListPanel. + var allItems = AdventureDataManager.Bounties.GetClaimableBounties(); + _currentInterval = AdventureDataManager.Bounties.GetCurrentInterval(); DestroyAllListElementsInList(); - var allItems = AdventureDataManager.Bounties.GetClaimableBounties(); for (int index = 0; index < allItems.Count; index++) { var itemInfo = allItems[index]; diff --git a/EpicLoot/src/Adventure/feature/GambleListPanel.cs b/EpicLoot/src/Adventure/feature/GambleListPanel.cs index 03a6714bd..98214f509 100644 --- a/EpicLoot/src/Adventure/feature/GambleListPanel.cs +++ b/EpicLoot/src/Adventure/feature/GambleListPanel.cs @@ -51,10 +51,12 @@ public override void UpdateRefreshTime() public override void RefreshItems(Currencies currencies) { - _currentInterval = AdventureDataManager.Gamble.GetCurrentInterval(); + // Gathered before the destroy, for the reason given in SecretStashListPanel. + var allItems = AdventureDataManager.Gamble.GetGambleItems(); + _currentInterval = AdventureDataManager.Gamble.GetCurrentInterval(); DestroyAllListElementsInList(); - var allItems = AdventureDataManager.Gamble.GetGambleItems(); + for (var index = 0; index < allItems.Count; index++) { var itemInfo = allItems[index]; diff --git a/EpicLoot/src/Adventure/feature/SecretStashListPanel.cs b/EpicLoot/src/Adventure/feature/SecretStashListPanel.cs index c749dc630..b2a725b07 100644 --- a/EpicLoot/src/Adventure/feature/SecretStashListPanel.cs +++ b/EpicLoot/src/Adventure/feature/SecretStashListPanel.cs @@ -51,13 +51,16 @@ public override void UpdateRefreshTime() public override void RefreshItems(Currencies currencies) { - _currentInterval = AdventureDataManager.SecretStash.GetCurrentInterval(); - - DestroyAllListElementsInList(); + // Gathered before the old rows are destroyed. The other order left the stash permanently + // empty whenever the gather threw: nothing puts rows back until the next refresh, and the + // next refresh throws in the same place. var items = AdventureDataManager.SecretStash.GetSecretStashItems(); var forestTokenItems = AdventureDataManager.SecretStash.GetForestTokenItems(); - var allItems = items.Concat(forestTokenItems).ToList(); + + _currentInterval = AdventureDataManager.SecretStash.GetCurrentInterval(); + DestroyAllListElementsInList(); + for (var index = 0; index < allItems.Count; index++) { var itemInfo = allItems[index]; diff --git a/EpicLoot/src/Adventure/feature/TreasureMapListPanel.cs b/EpicLoot/src/Adventure/feature/TreasureMapListPanel.cs index 7715ab9f3..6ada51b74 100644 --- a/EpicLoot/src/Adventure/feature/TreasureMapListPanel.cs +++ b/EpicLoot/src/Adventure/feature/TreasureMapListPanel.cs @@ -103,10 +103,12 @@ private void OnSpawnTreasureChest(int price, bool success, Vector3 position) public override void RefreshItems(Currencies currencies) { - _currentInterval = AdventureDataManager.TreasureMaps.GetCurrentInterval(); + // Gathered before the destroy, for the reason given in SecretStashListPanel. + System.Collections.Generic.List allItems = AdventureDataManager.TreasureMaps.GetTreasureMaps(); + _currentInterval = AdventureDataManager.TreasureMaps.GetCurrentInterval(); DestroyAllListElementsInList(); - System.Collections.Generic.List allItems = AdventureDataManager.TreasureMaps.GetTreasureMaps(); + for (int index = 0; index < allItems.Count; index++) { TreasureMapItemInfo itemInfo = allItems[index]; diff --git a/EpicLoot/src/GatedItemType/GatedItemTypeHelper.cs b/EpicLoot/src/GatedItemType/GatedItemTypeHelper.cs index 76b9696b2..abf7685e3 100644 --- a/EpicLoot/src/GatedItemType/GatedItemTypeHelper.cs +++ b/EpicLoot/src/GatedItemType/GatedItemTypeHelper.cs @@ -64,6 +64,8 @@ public static void Initialize(ItemInfoConfig config) BiomesInOrder.Clear(); BiomesToBossKeys.Clear(); + int deniedCount = 0; + // Add to required lists foreach (ItemTypeInfo info in config.ItemInfo) { @@ -72,24 +74,41 @@ public static void Initialize(ItemInfoConfig config) FallbackByType.Add(info.Type, new Fallback { Type = info.Fallback, - Item = info.ItemFallback + // A denied fallback item is no fallback at all -- never hand a prop out as the default. + Item = LootDenyList.IsDenied(info.ItemFallback) ? null : info.ItemFallback }); } Dictionary> itemsByBoss = new() { }; foreach (KeyValuePair> itemByBoss in info.ItemsByBoss) { + // Filtered COPY, never the config's own list. These pools feed every gated pick -- loot + // fallbacks, the merchant's gamble stock, the terminal commands -- so a denied prop sitting + // in a polluted iteminfo.json must not be selectable from them. Copying also leaves the + // loaded config untouched for AutoAddEnchantableItems to rewrite cleanly. + List allowedItems = new List(itemByBoss.Value.Count); + foreach (string candidate in itemByBoss.Value) + { + if (LootDenyList.IsDenied(candidate)) + { + deniedCount++; + continue; + } + + allowedItems.Add(candidate); + } + if (itemsByBoss.ContainsKey(itemByBoss.Key)) { EpicLoot.Log($"Merging [{itemByBoss.Key}] entries, duplicates will be removed."); - itemsByBoss[itemByBoss.Key].Union(itemByBoss.Value).ToList(); + itemsByBoss[itemByBoss.Key].Union(allowedItems).ToList(); } else { - itemsByBoss.Add(itemByBoss.Key, itemByBoss.Value); + itemsByBoss.Add(itemByBoss.Key, allowedItems); } - foreach (string item in itemByBoss.Value) + foreach (string item in allowedItems) { if (AllItemsWithDetails.ContainsKey(item)) { @@ -120,6 +139,12 @@ public static void Initialize(ItemInfoConfig config) RebuildBiomeOrder(); + if (deniedCount > 0) + { + EpicLoot.LogWarning($"iteminfo.json lists {deniedCount} denied prop item entries (see LootDenyList). " + + "They were left out of the gated item pools and will never be picked."); + } + EpicLoot.Log($"Gated items configured, total registered: {AllItemsWithDetails.Keys.Count}"); } @@ -280,6 +305,13 @@ public static string GetGatedItemNameFromItemOrType(string itemOrType, GatedItem return null; } + // A loot entry that names a denied prop directly resolves to nothing. Both LootRoller spawn paths + // treat a null name as "skip this drop". + if (LootDenyList.IsDenied(itemOrType)) + { + return null; + } + string type = itemOrType; List bossList = null; @@ -291,7 +323,7 @@ public static string GetGatedItemNameFromItemOrType(string itemOrType, GatedItem List potentialItems = new List(); foreach (LootTable lt in ltcategory) { - potentialItems.AddRange(lt.Loot.Select(x => x.Item).ToList()); + potentialItems.AddRange(lt.Loot.Select(x => x.Item).Where(x => !LootDenyList.IsDenied(x))); } if (potentialItems.Count == 0) diff --git a/EpicLoot/src/Loot/LootDenyList.cs b/EpicLoot/src/Loot/LootDenyList.cs new file mode 100644 index 000000000..ea3438ccc --- /dev/null +++ b/EpicLoot/src/Loot/LootDenyList.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; + +namespace EpicLoot +{ + public static class LootDenyList + { + private static readonly HashSet DeniedPrefabs = new HashSet(StringComparer.Ordinal) + { + // Cheat Items + "SledgeCheat", + "SwordCheat", + + // Unused Items + "ShieldKnight", + + // Enemy Items + "DvergerArbalest_shootDeepNorth", + "DvergerArbalest", + + // FW_ -- Fallen Warrior (25) + "FW_ArmorBronzeChest", + "FW_ArmorBronzeLegs", + "FW_ArmorFenringChest", + "FW_ArmorFenringLegs", + "FW_ArmorMageChest", + "FW_ArmorMageChest_Ashlands", + "FW_ArmorMageLegs", + "FW_ArmorMageLegs_Ashlands", + "FW_ArmorPaddedCuirass", + "FW_ArmorPaddedGreaves", + "FW_ArmorTrollLeatherChest", + "FW_ArmorTrollLeatherLegs", + "FW_AxeBronze", + "FW_BattleaxeCrystal", + "FW_BowDraugrFang", + "FW_CapeLinen", + "FW_CapeTrollHide", + "FW_CapeWolf", + "FW_HelmetBronze", + "FW_KnifeSilver", + "FW_KnifeSkollAndHati", + "FW_ShieldBlackmetalTower", + "FW_StaffFireball", + "FW_StaffLightning", + "FW_SwordBlackmetal", + + // SP_ -- self-killing primary attack on its weapons (28) + "SP_ArmorBronzeChest", + "SP_ArmorBronzeLegs", + "SP_ArmorDress1", + "SP_ArmorFenringChest", + "SP_ArmorFenringLegs", + "SP_ArmorLeatherLegs", + "SP_ArmorMageChest", + "SP_ArmorMageChest_Ashlands", + "SP_ArmorMageLegs", + "SP_ArmorMageLegs_Ashlands", + "SP_ArmorPaddedCuirass", + "SP_ArmorPaddedGreaves", + "SP_ArmorTrollLeatherChest", + "SP_ArmorTrollLeatherLegs", + "SP_ArmorTunic5", + "SP_AxeBronze", + "SP_BattleaxeCrystal", + "SP_BowDraugrFang", + "SP_CapeLinen", + "SP_CapeTrollHide", + "SP_CapeWolf", + "SP_HelmetBronze", + "SP_KnifeSilver", + "SP_KnifeSkollAndHati", + "SP_ShieldBlackmetalTower", + "SP_StaffFireball", + "SP_StaffLightning", + "SP_SwordBlackmetal", + }; + + /// True when is a prop that must never drop as loot. + public static bool IsDenied(string prefabName) + { + return !string.IsNullOrEmpty(prefabName) && DeniedPrefabs.Contains(prefabName); + } + } +} diff --git a/EpicLoot/src/Loot/LootRoller.cs b/EpicLoot/src/Loot/LootRoller.cs index 221cbc3f6..8c0f5bea0 100644 --- a/EpicLoot/src/Loot/LootRoller.cs +++ b/EpicLoot/src/Loot/LootRoller.cs @@ -452,6 +452,15 @@ public static Dictionary GetLootTableChances(Vector3 location, Lis GatedItemTypeHelper.GetGatedItemNameFromItemOrType(lootDrop.Item, GatedItemTypeMode.Unlimited) : GatedItemTypeHelper.GetGatedItemNameFromItemOrType(lootDrop.Item, EpicLoot.GetGatedItemTypeMode()); + // Null when the entry resolved to nothing -- which now includes a denied prop. Guard + // before PrefabManager: Jotunn's GetPrefab starts with Dictionary.TryGetValue(name), + // which throws on a null key. + if (string.IsNullOrEmpty(gatedItemName)) + { + failures += 1; + continue; + } + GameObject prefab = PrefabManager.Instance.GetPrefab(gatedItemName); if (prefab == null) { @@ -609,6 +618,15 @@ private static List RollLootTableInternal(LootTable lootTable, // rarity that chose it. var lootDrop = ResolveLootDrop(ld, luckFactor); + // A polluted on-disk loottables.json can still name a denied prop (LootDenyList). Skip it here, + // before the drop-type switch, so it cannot come out as itself, as an unidentified stand-in, + // or as sacrifice materials either. + if (LootDenyList.IsDenied(lootDrop?.Item)) + { + EpicLoot.Log($"Skipping denied prop item {lootDrop.Item} for ({objectName})."); + continue; + } + var itemName = !string.IsNullOrEmpty(lootDrop?.Item) ? lootDrop.Item : "Invalid Item Name"; var rarityLength = lootDrop?.Rarity?.Length != null ? lootDrop.Rarity.Length : -1; EpicLoot.Log($"Item: {itemName} - Rarity Count: {rarityLength} - Weight: {lootDrop.Weight}"); diff --git a/EpicLoot/src/Magic/AutoAddEnchantableItems.cs b/EpicLoot/src/Magic/AutoAddEnchantableItems.cs index 81e120904..35f2f5107 100644 --- a/EpicLoot/src/Magic/AutoAddEnchantableItems.cs +++ b/EpicLoot/src/Magic/AutoAddEnchantableItems.cs @@ -113,6 +113,7 @@ public static void CheckAndAddAllEnchantableItems(bool deregister = true) string.IsNullOrEmpty(i.m_itemData.m_shared.m_dlc) && !string.IsNullOrEmpty(i.m_itemData.m_shared.m_description) && EpicLoot.IsAllowedMagicItemType(i.m_itemData) && + !LootDenyList.IsDenied(i.name) && !AttackKillsWielder(i.m_itemData)).ToList(); EpicLoot.Log($"Checking all equipment in game."); @@ -174,59 +175,68 @@ public static void CheckAndAddAllEnchantableItems(bool deregister = true) } /// - /// True for a prop weapon that kills whoever swings it, which must never be offered to a player. + /// True for a weapon that kills whoever swings it: its attack sets m_attackKillsSelf, which + /// Attack.Trigger (assembly_valheim/Attack.cs:578) answers with 9,999,999 untyped true damage to the + /// wielder via ApplyDamage as the swing completes. /// - /// The Deep North update added 28 "SP_" prefabs (SP_AxeBronze, SP_BowDraugrFang, - /// SP_SwordBlackmetal, ...) as scripted-NPC props. They are indistinguishable from real gear by every - /// other test in the scan above -- full ItemDrops with m_autoPickup, a description, no DLC flag and a - /// real item type -- and they even share the vanilla display token ($item_axe_bronze), so they sail - /// into iteminfo.json and from there into the loot lists. What sets them apart is that their PRIMARY - /// attack carries m_attackKillsSelf, and Attack.Trigger (assembly_valheim/Attack.cs:578) answers that - /// with 9,999,999 untyped true damage to the wielder via ApplyDamage the instant the swing completes - /// -- no damage text, no attacker, straight past Character.Damage. Their secondary attack does not set - /// it, which is why only the basic attack is fatal. - /// - /// Tested on the flag rather than the "SP_" name prefix, so a prop added under some other naming - /// convention later is excluded too. + /// This is the BACKSTOP, not the main defence. The Deep North SP_ weapons set the flag, but + /// their FW_ twins and every prop armor piece do not, and they are otherwise field-for-field + /// identical to real gear -- so the known props are excluded by name through + /// . The flag test stays to catch a self-killing prop that a later update + /// adds under a name the deny list does not know yet. /// private static bool AttackKillsWielder(ItemDrop.ItemData item) { - return item.m_shared.m_attack?.m_attackKillsSelf == true || - item.m_shared.m_secondaryAttack?.m_attackKillsSelf == true; + return item?.m_shared != null && + (item.m_shared.m_attack?.m_attackKillsSelf == true || + item.m_shared.m_secondaryAttack?.m_attackKillsSelf == true); } /// - /// Purges every prop item from an already-written config, matched on - /// prefab name -- the same identity EnsureItemsInConfigMutating writes. + /// Purges prop items from an already-written iteminfo config: anything on , + /// plus any item whose attack kills its wielder. Matched on prefab name -- the identity + /// EnsureItemsInConfigMutating writes. + /// + /// Runs on the merged result rather than relying on the equipment scan alone, because an entry + /// already in iteminfo.json is carried forward before any ignore check is consulted + /// (EnsureItemsInConfigMutating's "already in the config" branch), so a prop written by an earlier + /// build would otherwise be kept forever. /// private static void RemovePropItemsFromConfig(List config, List allItems) { - HashSet propNames = new HashSet(allItems - .Where(i => i.m_itemData?.m_shared != null && AttackKillsWielder(i.m_itemData)) + HashSet selfKilling = new HashSet(allItems + .Where(i => AttackKillsWielder(i.m_itemData)) .Select(i => i.name)); - if (propNames.Count == 0) + SortedSet removedNames = new SortedSet(StringComparer.Ordinal); + bool IsProp(string name) { - return; + if (!LootDenyList.IsDenied(name) && !selfKilling.Contains(name)) + { + return false; + } + + removedNames.Add(name); + return true; } int removed = 0; foreach (ItemTypeInfo itemType in config) { #pragma warning disable 612 // Items is obsolete, but a config written by an older build may still use it. - removed += itemType.Items.RemoveAll(propNames.Contains); + removed += itemType.Items.RemoveAll(IsProp); #pragma warning restore 612 foreach (KeyValuePair> byBoss in itemType.ItemsByBoss) { - removed += byBoss.Value.RemoveAll(propNames.Contains); + removed += byBoss.Value.RemoveAll(IsProp); } } if (removed > 0) { - EpicLoot.LogWarningForce($"Removed {removed} self-killing prop item entries from iteminfo.json " + - $"(e.g. {string.Join(", ", propNames.OrderBy(x => x).Take(3))}). These are NPC props whose " + - "basic attack kills whoever swings it; they must never be player loot."); + EpicLoot.LogWarningForce($"Removed {removed} prop item entries ({removedNames.Count} distinct) " + + $"from iteminfo.json: {string.Join(", ", removedNames)}. These are NPC props -- invisible " + + "when worn, and in some cases killing whoever attacks with them -- and must never be loot."); } } @@ -714,6 +724,15 @@ private static bool IsValidLootEntryName(string name, List metaItemSetNa return false; } + // Denied props are rejected before anything else. The ObjectDB fallback at the bottom accepts ANY + // real ItemDrop, which is exactly how SP_/FW_ entries survived every rewrite once written. + // Rejecting here deletes them from ItemSets, loot table Loot lists and RarityItems maps alike. + if (LootDenyList.IsDenied(name)) + { + EpicLoot.Log($"REMOVING denied prop item {name} from the loot configuration."); + return false; + } + if (metaLootTables != null && name.Contains(".")) { string reference = name.Split('.')[0]; @@ -731,8 +750,10 @@ private static bool IsValidLootEntryName(string name, List metaItemSetNa // ObjectDB.m_items also holds a few vanilla non-item prefabs (SnowRoller, ...), which // LootRoller can never spawn as a drop, so a name has to resolve to an actual ItemDrop. + // ...and a self-killing prop the deny list does not know yet is rejected on its flag. GameObject prefab = ObjectDB.instance.GetItemPrefab(name); - return prefab != null && prefab.TryGetComponent(out ItemDrop _); + return prefab != null && prefab.TryGetComponent(out ItemDrop itemDrop) && + !AttackKillsWielder(itemDrop.m_itemData); } // Drops only the unresolvable rarities from an entry's per-rarity map, leaving the entry itself diff --git a/EpicLoot/src/Magic/ItemDataExtensions.cs b/EpicLoot/src/Magic/ItemDataExtensions.cs index 9fe1b04c6..1b0236d9b 100644 --- a/EpicLoot/src/Magic/ItemDataExtensions.cs +++ b/EpicLoot/src/Magic/ItemDataExtensions.cs @@ -428,6 +428,16 @@ private static GameObject FindItemPrefabBySharedName(string sharedName) /// public static void InitializeCustomData(this ItemDrop.ItemData itemData) { + // Opening a pre-1.0 world runs ZDOMan.ConvertContainers, which loads and re-saves every chest through + // a temporary Inventory. Its items are bare ItemData (AddTempItem sets m_dropPrefab, never m_shared), + // and the Inventory.Load postfix still sees them: MagicItemComponent.FirstLoad reading m_shared.m_name + // threw out of ZNet.LoadOldWorld and aborted the world load. Skipping loses nothing -- vanilla writes + // m_customData back verbatim, and the item is initialized for real when the chest is next loaded. + if (itemData?.m_shared == null) + { + return; + } + // Shards rebuild their own magic data from m_shared.m_ammoType, so they need neither the prefab // reference nor its baked custom data. Done ahead of the m_dropPrefab check so a shard is healed // even when the prefab is unresolved. Cheap no-op for everything else. diff --git a/EpicLoot/src/Magic/MagicItemComponent.cs b/EpicLoot/src/Magic/MagicItemComponent.cs index 9c47bfa69..f6103bebd 100755 --- a/EpicLoot/src/Magic/MagicItemComponent.cs +++ b/EpicLoot/src/Magic/MagicItemComponent.cs @@ -77,6 +77,13 @@ public CustomItemData Clone() public override void FirstLoad() { + // A bare ItemData with no shared data (see ItemDataExtensions.InitializeCustomData) has nothing to + // seed. ItemInfo.Add does not catch, so a throw here escapes into whichever vanilla load touched it. + if (Item?.m_shared == null) + { + return; + } + if (Item.m_shared.m_name == "$item_helmet_dverger") { MagicItem magicItem = new MagicItem(); From 3a7e55319ec756306f722fc4508b75a60fec9523 Mon Sep 17 00:00:00 2001 From: MidnightsFX Date: Sun, 13 Sep 2026 00:11:32 -0700 Subject: [PATCH 06/20] version bump --- EpicLoot/EpicLoot.cs | 2 +- EpicLoot/Package/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/EpicLoot/EpicLoot.cs b/EpicLoot/EpicLoot.cs index 75b6823a5..9c16f9d41 100644 --- a/EpicLoot/EpicLoot.cs +++ b/EpicLoot/EpicLoot.cs @@ -38,7 +38,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.14.4"; + public const string Version = "0.14.5"; private static string ConfigFileName = PluginId + ".cfg"; private static string ConfigFileFullPath = BepInEx.Paths.ConfigPath + Path.DirectorySeparatorChar + ConfigFileName; diff --git a/EpicLoot/Package/manifest.json b/EpicLoot/Package/manifest.json index 4b25fa97a..107c45154 100644 --- a/EpicLoot/Package/manifest.json +++ b/EpicLoot/Package/manifest.json @@ -1,6 +1,6 @@ { "name": "EpicLoot", - "version_number": "0.14.4", + "version_number": "0.14.5", "website_url": "https://discord.gg/ZNhYeavv3C", "description": "Adds loot drops, magic items, and enchanting to Valheim.", "dependencies": [ From 344c157b6324a801acef59ddb4a61b605f699906 Mon Sep 17 00:00:00 2001 From: MidnightsFX Date: Mon, 14 Sep 2026 20:52:41 -0700 Subject: [PATCH 07/20] Controller support for EAQS --- EpicLoot/Package/CHANGELOG.md | 3 + .../src/CraftingV2/EnchantingTable_Patch.cs | 59 +++- EpicLoot/src/UI/Enchanting/EnchantingTable.cs | 2 +- .../EquipmentAndQuickSlots.cs | 2 +- EquipmentAndQuickSlots/Package/CHANGELOG.md | 6 + EquipmentAndQuickSlots/Package/manifest.json | 2 +- EquipmentAndQuickSlots/src/EquipmentPanel.cs | 33 --- .../src/GamepadNavigation.cs | 258 ++++++++++++++++++ Jam/release.yml | 4 +- MinimalStatusEffects/release.yml | 15 + 10 files changed, 337 insertions(+), 47 deletions(-) create mode 100644 EquipmentAndQuickSlots/src/GamepadNavigation.cs diff --git a/EpicLoot/Package/CHANGELOG.md b/EpicLoot/Package/CHANGELOG.md index 2865dc702..ebd2447c0 100644 --- a/EpicLoot/Package/CHANGELOG.md +++ b/EpicLoot/Package/CHANGELOG.md @@ -1,3 +1,6 @@ +**0.14.6** +* Destroying an enchanting table now refunds the full upgrade cost of every unlocked feature. Amounts above an item's stack size (Surtling Cores, berries, mushrooms, fish) were previously lost + **0.14.5** * Additional safety checks for world upgrades * Fix for edge cases that could cause the trader UI to not show up diff --git a/EpicLoot/src/CraftingV2/EnchantingTable_Patch.cs b/EpicLoot/src/CraftingV2/EnchantingTable_Patch.cs index b39a82e19..587c50559 100644 --- a/EpicLoot/src/CraftingV2/EnchantingTable_Patch.cs +++ b/EpicLoot/src/CraftingV2/EnchantingTable_Patch.cs @@ -7,10 +7,14 @@ namespace EpicLoot.CraftingV2 { + /// + /// Refunds the feature unlock and upgrade costs paid into an enchanting table when the piece is + /// destroyed, the same way vanilla refunds the piece's own build resources. + /// [HarmonyPatch(typeof(Piece), nameof(Piece.DropResources))] public static class Piece_DropResources_Patch { - public static void Postfix(Piece __instance) + public static void Postfix(Piece __instance, HitData hitData) { var table = __instance.GetComponent(); if (table == null) @@ -24,6 +28,13 @@ public static void Postfix(Piece __instance) return; } + // Vanilla flags every drop of a piece that was placed with the no-cost cheat. + var nview = __instance.GetComponent(); + bool cheated = nview != null && nview.IsValid() && + nview.GetZDO().GetBool(ZDOVars.s_cheated) && !PlayerProfile.s_bypassCheatChecks; + Vector3 dropPosition = __instance.transform.position + + Vector3.up * __instance.m_returnResourceHeightOffset; + foreach (EnchantingFeature feature in Enum.GetValues(typeof(EnchantingFeature))) { if (!table.IsFeatureAvailable(feature) || table.IsFeatureLocked(feature)) @@ -31,19 +42,49 @@ public static void Postfix(Piece __instance) continue; } - var currentLevel = table.GetFeatureLevel(feature); - - for (var i = 0; i <= currentLevel; ++i) + // A feature starts at its configured default level for free, so only the unlock and + // upgrade steps above that level were ever paid for. + int firstPaidLevel = Mathf.Max(0, EnchantingTable.GetDefaultFeatureLevel(feature) + 1); + int currentLevel = table.GetFeatureLevel(feature); + for (int level = firstPaidLevel; level <= currentLevel; ++level) { - var cost = EnchantingTableUpgrades.GetUpgradeCost(feature, i); - foreach (var item in cost) + foreach (InventoryItemListElement cost in EnchantingTableUpgrades.GetUpgradeCost(feature, level)) { - var itemDrop = Object.Instantiate(item.Item.m_dropPrefab, - __instance.transform.position + Vector3.up, Quaternion.identity).GetComponent(); - itemDrop.SetStack(item.Item.m_stack); + DropCostItem(cost.Item, dropPosition, hitData, cheated); } } } } + + // Mirrors the per-requirement loop in Piece.DropResources. ItemDrop.SetStack clamps to the + // item's max stack size, so dropping each cost as a single ItemDrop silently lost everything + // above that limit: 200 Cloudberries came back as one stack of 50, 36 Surtling Cores as 10. + private static void DropCostItem(ItemDrop.ItemData costItem, Vector3 position, HitData hitData, bool cheated) + { + GameObject dropPrefab = costItem.m_dropPrefab; + if (dropPrefab == null) + { + return; + } + + int dropCount = costItem.m_stack; + var prefabItemDrop = dropPrefab.GetComponent(); + if (Game.instance != null && prefabItemDrop != null) + { + // Same conversion vanilla applies to the piece's own resources, e.g. wood burned by + // cinder fire drops as coal. + dropPrefab = Game.instance.CheckDropConversion(hitData, prefabItemDrop, dropPrefab, ref dropCount); + } + + while (dropCount > 0) + { + var itemDrop = Object.Instantiate(dropPrefab, position, Quaternion.identity).GetComponent(); + itemDrop.SetStack(Mathf.Min(dropCount, itemDrop.m_itemData.m_shared.m_maxStackSize)); + ItemDrop.OnCreateNew(itemDrop, cheated); + // SetStack is a no-op on a drop this client does not own, leaving the prefab's stack of + // one behind; never let a zero-stack prefab turn this into an endless loop. + dropCount -= Mathf.Max(1, itemDrop.m_itemData.m_stack); + } + } } } diff --git a/EpicLoot/src/UI/Enchanting/EnchantingTable.cs b/EpicLoot/src/UI/Enchanting/EnchantingTable.cs index 52bdc3752..14b83d656 100755 --- a/EpicLoot/src/UI/Enchanting/EnchantingTable.cs +++ b/EpicLoot/src/UI/Enchanting/EnchantingTable.cs @@ -210,7 +210,7 @@ private void InitFeatureLevels() } } - private static int GetDefaultFeatureLevel(EnchantingFeature feature) + public static int GetDefaultFeatureLevel(EnchantingFeature feature) { if (!EnchantingUIController.UpgradesActive(feature, out bool featureActive)) { diff --git a/EquipmentAndQuickSlots/EquipmentAndQuickSlots.cs b/EquipmentAndQuickSlots/EquipmentAndQuickSlots.cs index 4c69546f6..b6a4e2a8f 100644 --- a/EquipmentAndQuickSlots/EquipmentAndQuickSlots.cs +++ b/EquipmentAndQuickSlots/EquipmentAndQuickSlots.cs @@ -22,7 +22,7 @@ namespace EquipmentAndQuickSlots { [BepInIncompatibility("com.bruce.valheim.comfyquickslots")] public class EquipmentAndQuickSlots : BaseUnityPlugin { public const string PluginId = "randyknapp.mods.equipmentandquickslots"; - public const string Version = "3.1.2"; + public const string Version = "3.1.3"; public static Sprite PaperdollMale; public static Sprite PaperdollFemale; diff --git a/EquipmentAndQuickSlots/Package/CHANGELOG.md b/EquipmentAndQuickSlots/Package/CHANGELOG.md index 35bd13f74..f376becf7 100644 --- a/EquipmentAndQuickSlots/Package/CHANGELOG.md +++ b/EquipmentAndQuickSlots/Package/CHANGELOG.md @@ -1,3 +1,9 @@ +**3.1.3** +* Controller navigation in the inventory follows the equipment panel's layout: the D-pad moves + between the inventory, the equipment cells, the quick slots and API slots in the direction + pressed, an open chest can be reached with the D-pad again, and the controller tooltip follows + the selection onto slot cells. + **3.1.2** * Fixes item placement change when secondary sources change the inventory size * Console commands need devcommands again diff --git a/EquipmentAndQuickSlots/Package/manifest.json b/EquipmentAndQuickSlots/Package/manifest.json index 95b7e63a2..96cdcf7af 100644 --- a/EquipmentAndQuickSlots/Package/manifest.json +++ b/EquipmentAndQuickSlots/Package/manifest.json @@ -1,6 +1,6 @@ { "name": "EquipmentAndQuickSlots", - "version_number": "3.1.2", + "version_number": "3.1.3", "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/src/EquipmentPanel.cs b/EquipmentAndQuickSlots/src/EquipmentPanel.cs index a0758f9ee..6cdf00332 100644 --- a/EquipmentAndQuickSlots/src/EquipmentPanel.cs +++ b/EquipmentAndQuickSlots/src/EquipmentPanel.cs @@ -564,39 +564,6 @@ private static void Postfix() { } } - // Vanilla gamepad navigation walks the raw grid; steer the selection off reserved and - // inactive slot cells (their elements are hidden) onto the nearest active slot. - [HarmonyPatch(typeof(InventoryGrid), "UpdateGamepad")] - private static class InventoryGrid_UpdateGamepad_SkipInactiveSlotCells { - private static void Postfix(InventoryGrid __instance) { - if (!InventoryGui.instance || __instance != InventoryGui.instance.m_playerGrid) - return; - - Vector2i sel = __instance.m_selected; - if (sel.y < VisibleRows) - return; - - int slotIndex = (sel.y - VisibleRows) * InventoryWidth + sel.x; - if (slotIndex >= 0 && slotIndex < slots.Length && slots[slotIndex].IsActive) - return; - - int best = -1; - int bestDist = int.MaxValue; - for (int i = 0; i < slots.Length; i++) { - if (!slots[i].IsActive) - continue; - - int dist = Math.Abs(i - slotIndex); - if (dist < bestDist) { - bestDist = dist; - best = i; - } - } - - __instance.m_selected = best >= 0 ? slots[best].GridPosition : new Vector2i(Math.Min(sel.x, InventoryWidth - 1), VisibleRows - 1); - } - } - [HarmonyPatch(typeof(InventoryGrid), nameof(InventoryGrid.UpdateGui))] private static class InventoryGrid_UpdateGui_RelocateSlotElements { private static void Postfix(InventoryGrid __instance) { diff --git a/EquipmentAndQuickSlots/src/GamepadNavigation.cs b/EquipmentAndQuickSlots/src/GamepadNavigation.cs new file mode 100644 index 000000000..36d03e29b --- /dev/null +++ b/EquipmentAndQuickSlots/src/GamepadNavigation.cs @@ -0,0 +1,258 @@ +using HarmonyLib; +using UnityEngine; +using UnityEngine.UI; +using static EquipmentAndQuickSlots.Slots; + +namespace EquipmentAndQuickSlots { + // Controller navigation on the player grid. Vanilla InventoryGrid.UpdateGamepad walks raw grid + // coordinates, but the slot cells sit in hidden rows and are drawn wherever the panel puts them + // (config position, the extra utility column, API and quiver rows, the Auga diamond). Walked in + // grid order, Right off the inventory went nowhere, the paperdoll was stepped through in index + // order, and the container was out of reach: vanilla only jumps into it from the grid's last + // row, which is reserved capacity that is normally empty. + // + // So a direction press inside the visible grid keeps vanilla's one-cell step, and every other + // move picks the nearest on-screen cell in that direction, measured from the cells' real + // transforms — whatever the layout, the selection goes where it looks like it should. Down with + // nothing below jumps into the container. A / X and the trigger modifiers stay vanilla. + internal static class GamepadNavigation { + // Vanilla only jumps into the container once the stick was released at the edge, so a held + // stick repeating down the grid stops at the bottom first. Tracked here rather than in + // vanilla's jumpToNextContainer, which vanilla rewrites on every frame it runs using the + // full grid height, hidden rows included. + private static bool _downJumpArmed; + + [HarmonyPatch(typeof(InventoryGrid), "UpdateGamepad")] + private static class InventoryGrid_UpdateGamepad_SpatialNavigation { + private static bool Prefix(InventoryGrid __instance) { + if (!InventoryGui.instance || __instance != InventoryGui.instance.m_playerGrid || Player.m_localPlayer == null) + return true; + + // Vanilla's own conditions for reading the controller at all + if (!__instance.m_uiGroup.IsActive || Console.IsVisible() || ZInput.IsTouchActive() || !ZInput.IsExclusiveGamepadActive()) + return true; + + ResolveStaleSelection(__instance); + + if (!ZInput.GetButton("JoyDPadDown") && !ZInput.GetButton("JoyLStickDown")) + _downJumpArmed = true; + + bool left = ZInput.GetButtonDown("JoyDPadLeft") || ZInput.GetButtonDown("JoyLStickLeft"); + bool right = ZInput.GetButtonDown("JoyDPadRight") || ZInput.GetButtonDown("JoyLStickRight"); + bool up = ZInput.GetButtonDown("JoyDPadUp") || ZInput.GetButtonDown("JoyLStickUp"); + bool down = ZInput.GetButtonDown("JoyDPadDown") || ZInput.GetButtonDown("JoyLStickDown"); + + if (!left && !right && !up && !down) + return true; + + // Horizontal first and vertical second, like vanilla, so a diagonal still moves on + // both axes and a container jump is the last thing that happens this frame. + if (left) + Navigate(__instance, Vector2.left); + else if (right) + Navigate(__instance, Vector2.right); + + if (up) + Navigate(__instance, Vector2.up); + else if (down) + Navigate(__instance, Vector2.down); + + // Skipping vanilla drops an A / X press landing on the very same frame as a move. + return false; + } + } + + // Leaving a container upward: vanilla keeps the player grid's previous row, which may be a + // slot row. The container sits below the inventory, so land on the bottom inventory row. + [HarmonyPatch(typeof(InventoryGui), "MoveToUpperInventoryGrid")] + private static class InventoryGui_MoveToUpperInventoryGrid_LandOnInventory { + private static void Prefix(InventoryGui __instance, out int __state) { + __state = __instance.ActiveGroup; + } + + private static void Postfix(InventoryGui __instance, int __state) { + // Vanilla bails without switching groups when the inventory group isn't active + if (__instance.ActiveGroup == __state || !__instance.m_playerGrid) + return; + + Vector2i selection = __instance.m_playerGrid.SelectionGridPosition; + if (selection.y < VisibleRows) + return; + + __instance.m_playerGrid.SetGamepadSelection(new Vector2i(Mathf.Clamp(selection.x, 0, InventoryWidth - 1), VisibleRows - 1)); + } + } + + private static void Navigate(InventoryGrid grid, Vector2 direction) { + Vector2i from = grid.m_selected; + bool fromSlot = IsGridPositionASlot(from); + + if ((!fromSlot && TryGridStep(from, direction, out Vector2i target)) + || TryFindNeighbour(grid, from, direction, includeInventoryCells: fromSlot, out target)) { + Select(grid, target); + if (direction.y < 0f) + _downJumpArmed = false; + return; + } + + // Nothing below: on to the container, if one is open (vanilla checks that). Its column + // mapping expects a player grid column, so a slot cell passes the column under it. + if (direction.y < 0f && _downJumpArmed) + grid.OnMoveToLowerInventoryGrid?.Invoke(new Vector2i(fromSlot ? NearestInventoryColumn(grid, from) : from.x, VisibleRows - 1)); + } + + // Vanilla's one-cell step, as long as it stays inside the visible grid + private static bool TryGridStep(Vector2i from, Vector2 direction, out Vector2i target) { + target = new Vector2i(from.x + Mathf.RoundToInt(direction.x), from.y - Mathf.RoundToInt(direction.y)); + return target.x >= 0 && target.x < InventoryWidth && target.y >= 0 && target.y < VisibleRows; + } + + // The cell whose on-screen center lies nearest in the pressed direction, within a 45° cone + // either side of it. Scored as cos(angle) / distance, the heuristic Unity's own Selectable + // navigation uses: a close cell slightly off-axis beats a distant one dead ahead. Slot cells + // are always candidates; visible inventory cells only when leaving a slot cell, since + // inside the grid the plain step already covers them. + private static bool TryFindNeighbour(InventoryGrid grid, Vector2i from, Vector2 direction, bool includeInventoryCells, out Vector2i target) { + target = from; + + Camera camera = GetCanvasCamera(grid); + if (!TryGetScreenCenter(grid, from, camera, out Vector2 origin)) + return false; + + Vector2i best = from; + float bestScore = 0f; + + void Consider(Vector2i candidate) { + if (candidate == from || !TryGetScreenCenter(grid, candidate, camera, out Vector2 center)) + return; + + Vector2 offset = center - origin; + float along = Vector2.Dot(offset, direction); + float across = Mathf.Abs(offset.x * direction.y - offset.y * direction.x); + if (along <= 1f || across > along) + return; + + float score = along / offset.sqrMagnitude; + if (score > bestScore) { + bestScore = score; + best = candidate; + } + } + + foreach (Slot slot in slots) + if (slot.IsActive) + Consider(slot.GridPosition); + + if (includeInventoryCells) + for (int y = 0; y < VisibleRows; y++) + for (int x = 0; x < InventoryWidth; x++) + Consider(new Vector2i(x, y)); + + target = best; + return bestScore > 0f; + } + + // The bottom-row inventory column horizontally closest to a slot cell + private static int NearestInventoryColumn(InventoryGrid grid, Vector2i slotPosition) { + Camera camera = GetCanvasCamera(grid); + if (!TryGetScreenCenter(grid, slotPosition, camera, out Vector2 origin)) + return InventoryWidth - 1; + + int bestColumn = InventoryWidth - 1; + float bestDistance = float.MaxValue; + for (int x = 0; x < InventoryWidth; x++) { + if (!TryGetScreenCenter(grid, new Vector2i(x, VisibleRows - 1), camera, out Vector2 center)) + continue; + + float distance = Mathf.Abs(center.x - origin.x); + if (distance < bestDistance) { + bestDistance = distance; + bestColumn = x; + } + } + + return bestColumn; + } + + // A selection resting on a cell that is no longer shown (a slot switched off by config, or + // reserved capacity) is steered onto the nearest active slot, and the element is selected + // so the controller tooltip follows it. + private static void ResolveStaleSelection(InventoryGrid grid) { + Vector2i selection = grid.m_selected; + if (!IsGridPositionASlot(selection)) + return; + + int slotIndex = (selection.y - VisibleRows) * InventoryWidth + selection.x; + if (slotIndex >= 0 && slotIndex < slots.Length && slots[slotIndex].IsActive) + return; + + int best = -1; + int bestDistance = int.MaxValue; + for (int i = 0; i < slots.Length; i++) { + if (!slots[i].IsActive) + continue; + + int distance = Mathf.Abs(i - slotIndex); + if (distance < bestDistance) { + bestDistance = distance; + best = i; + } + } + + Select(grid, best >= 0 ? slots[best].GridPosition : new Vector2i(Mathf.Min(selection.x, InventoryWidth - 1), VisibleRows - 1)); + } + + // What vanilla does after a move: select the element (the controller tooltip only shows on + // the EventSystem's selected object) and scroll a visible cell into view. A relocated slot + // cell is not part of the grid's scroll content, so it never scrolls the grid. + private static void Select(InventoryGrid grid, Vector2i position) { + grid.m_selected = position; + + RectTransform rect = GetElementRect(grid, position); + if (!rect) + return; + + Selectable selectable = rect.GetComponent(); + if (selectable) + selectable.Select(); + + if (!IsGridPositionASlot(position) && grid.m_ensureVisible) + grid.m_ensureVisible.CenterOnItem(rect); + } + + private static RectTransform GetElementRect(InventoryGrid grid, Vector2i position) { + if (position.x < 0 || position.x >= InventoryWidth || position.y < 0) + return null; + + int index = position.y * InventoryWidth + position.x; + if (index >= grid.m_elements.Count) + return null; + + InventoryElement element = grid.m_elements[index]; + return element ? element.transform as RectTransform : null; + } + + private static Camera GetCanvasCamera(InventoryGrid grid) { + Canvas canvas = grid.GetComponentInParent(); + if (!canvas) + return null; + + Canvas root = canvas.rootCanvas; + return root.renderMode == RenderMode.ScreenSpaceOverlay ? null : root.worldCamera; + } + + // Only local-to-world conversions: they stay valid under the zero z-scale BetterUI writes + // onto the Player panel, where world-to-local would divide by it (see BetterUICompat). + // Hidden cells are parked under an inactive holder and are never candidates. + private static bool TryGetScreenCenter(InventoryGrid grid, Vector2i position, Camera camera, out Vector2 center) { + center = Vector2.zero; + + RectTransform rect = GetElementRect(grid, position); + if (!rect || !rect.gameObject.activeInHierarchy) + return false; + + center = RectTransformUtility.WorldToScreenPoint(camera, rect.TransformPoint(rect.rect.center)); + return true; + } + } +} diff --git a/Jam/release.yml b/Jam/release.yml index 97fdaab0d..5ee08252e 100644 --- a/Jam/release.yml +++ b/Jam/release.yml @@ -22,8 +22,8 @@ publish: enabled: true nexus: enabled: true - mod_id: 75 - file_id: 467302 + mod_id: 177 + file_id: 848593 github: enabled: true discord: diff --git a/MinimalStatusEffects/release.yml b/MinimalStatusEffects/release.yml index 4d798991e..45714e4e3 100644 --- a/MinimalStatusEffects/release.yml +++ b/MinimalStatusEffects/release.yml @@ -10,6 +10,21 @@ assembly: MinimalStatusEffects # This repository predates the tool and spells the version constant its own way. version_const: Version +# The Thunderstore/Hexium team these publish under. +author_name: RandyKnapp + # Copy the build into this machine's mod manager profiles after every build. deploy: enabled: true + +publish: + thunderstore: + enabled: true + nexus: + enabled: true + mod_id: 75 + file_id: 467302 + github: + enabled: true + discord: + enabled: true From 48dec0897a648cb1094fe1b0ec7ad15b5f0bda92 Mon Sep 17 00:00:00 2001 From: MidnightsFX Date: Wed, 16 Sep 2026 10:50:36 -0700 Subject: [PATCH 08/20] Epicloot improve bounty/treasure spawn speed. Log --- EpicLoot/EpicLoot.cs | 2 +- EpicLoot/Package/CHANGELOG.md | 2 + EpicLoot/Package/manifest.json | 2 +- EpicLoot/src/Adventure/StoreGui_Patch.cs | 33 +- .../bounties/AdventureSpawnController.cs | 298 ++++++++++++++++-- 5 files changed, 306 insertions(+), 31 deletions(-) diff --git a/EpicLoot/EpicLoot.cs b/EpicLoot/EpicLoot.cs index 9c16f9d41..c501e3021 100644 --- a/EpicLoot/EpicLoot.cs +++ b/EpicLoot/EpicLoot.cs @@ -38,7 +38,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.14.5"; + public const string Version = "0.14.6"; private static string ConfigFileName = PluginId + ".cfg"; private static string ConfigFileFullPath = BepInEx.Paths.ConfigPath + Path.DirectorySeparatorChar + ConfigFileName; diff --git a/EpicLoot/Package/CHANGELOG.md b/EpicLoot/Package/CHANGELOG.md index ebd2447c0..d91bd0079 100644 --- a/EpicLoot/Package/CHANGELOG.md +++ b/EpicLoot/Package/CHANGELOG.md @@ -1,5 +1,7 @@ **0.14.6** * Destroying an enchanting table now refunds the full upgrade cost of every unlocked feature. Amounts above an item's stack size (Surtling Cores, berries, mushrooms, fish) were previously lost +* Optimizes bounty and treasure chests spawning, now spawns the target considerably earlier +* Fixes Epicloot trader UI not showing up when other mods break the trader (your other mods are still breaking your game, just not breaking Epicloot) **0.14.5** * Additional safety checks for world upgrades diff --git a/EpicLoot/Package/manifest.json b/EpicLoot/Package/manifest.json index 107c45154..28ec30682 100644 --- a/EpicLoot/Package/manifest.json +++ b/EpicLoot/Package/manifest.json @@ -1,6 +1,6 @@ { "name": "EpicLoot", - "version_number": "0.14.5", + "version_number": "0.14.6", "website_url": "https://discord.gg/ZNhYeavv3C", "description": "Adds loot drops, magic items, and enchanting to Valheim.", "dependencies": [ diff --git a/EpicLoot/src/Adventure/StoreGui_Patch.cs b/EpicLoot/src/Adventure/StoreGui_Patch.cs index d95812220..21658576e 100644 --- a/EpicLoot/src/Adventure/StoreGui_Patch.cs +++ b/EpicLoot/src/Adventure/StoreGui_Patch.cs @@ -9,12 +9,37 @@ public static class StoreGui_Patch public static GameObject MerchantPanel; public static GameObject TemperPanel; + /// + /// A finalizer rather than a postfix, because Harmony skips every postfix once the original + /// throws -- and StoreGui.Show activates its own window *before* calling FillList, so a throw + /// in there leaves the vanilla store on screen with no adventure panel beside it and nothing + /// naming EpicLoot in the log. FillList reads `tradeItem.m_tooltip.Length` with no null check, + /// so any mod that adds trader stock from code without setting m_tooltip/m_name to "" (Unity's + /// serializer would have) takes this panel down with it. + /// + /// It does not swallow the exception: a void finalizer leaves whatever came in to keep + /// propagating, so the offending mod is still reported. + /// [HarmonyPatch(nameof(StoreGui.Show))] - [HarmonyPostfix] - public static void Show_Postfix(StoreGui __instance) + [HarmonyFinalizer] + public static void Show_Finalizer(StoreGui __instance) + { + try + { + OpenPanelFor(__instance); + } + catch (System.Exception e) + { + // Returning an exception from a finalizer replaces the original one, which would bury + // whatever actually broke Show. Report and let that one through untouched. + EpicLoot.LogErrorForce($"[StoreGui] Failed to open the EpicLoot trader panel:\n{e}"); + } + } + + private static void OpenPanelFor(StoreGui __instance) { - // Show runs its body only when the trader or the visibility changed, but the postfix runs - // either way, and Hide leaves m_trader null -- so never assume it is there. + // Show runs its body only when the trader or the visibility changed, but this runs either + // way, and Hide leaves m_trader null -- so never assume it is there. if (!EpicLoot.IsAdventureModeEnabled() || __instance == null || __instance.m_trader == null) { return; diff --git a/EpicLoot/src/Adventure/bounties/AdventureSpawnController.cs b/EpicLoot/src/Adventure/bounties/AdventureSpawnController.cs index 9d19029ce..2048b63ab 100644 --- a/EpicLoot/src/Adventure/bounties/AdventureSpawnController.cs +++ b/EpicLoot/src/Adventure/bounties/AdventureSpawnController.cs @@ -1,4 +1,5 @@ -using EpicLoot.Data; +using EpicLoot.Biomes; +using EpicLoot.Data; using System.Collections; using System.Collections.Generic; using UnityEngine; @@ -33,23 +34,52 @@ internal class AdventureSpawnController : MonoBehaviour private BoolZNetProperty isBounty { get; set; } - /// Frames to settle before the first search attempt. - private const int InitialSearchDelayFrames = 300; + /// + /// Seconds to wait before re-attempting a search that found nowhere to spawn. A blocked search + /// usually stays blocked until a ward comes down, but a retry is cheap now that the search + /// itself takes frames rather than seconds. + /// + private const float RetrySearchDelaySeconds = 10f; /// - /// Frames to wait before re-attempting a search that found nowhere to spawn. Longer than the - /// initial delay because a blocked search usually stays blocked until a ward comes down. + /// Seconds between lookups of a creature prefab ZNetScene does not have. Only re-installing the + /// mod that adds it can fix that, so there is no point asking often. /// - private const int RetrySearchDelayFrames = 1800; + private const float MissingPrefabRetrySeconds = 30f; + + /// How long the search waits for a world biome index that is still building. + private const float IndexWaitTimeoutSeconds = 10f; + + /// How often the search re-checks whether the area around the start point has loaded. + private const float AreaPollSeconds = 0.25f; private const int SpawnAttemptsPerBand = 100; + /// + /// Candidates evaluated before yielding a frame. Each one is two raycasts and a walk over the + /// loaded wards, so this keeps a search that rejects everything to a few milliseconds a frame. + /// + private const int CandidatesPerFrame = 10; + /// Set once a missing creature prefab has been reported, so it is logged one time. private bool reportedMissingPrefab = false; - private int currentUpdates = 0; - private int updatesRequired = InitialSearchDelayFrames; + /// Set once an exhausted search has been reported at Force level for this instance. + private bool reportedExhausted = false; + + /// Time.time before which Update will not start (or restart) a search. + private float nextSearchTime = 0f; private bool startedPlacement = false; + + // Timing for the placement log line, measured from when this client started searching. + private float searchStartTime; + private float areaReadyTime; + private int candidatesRejected; + private int placedBand; + + private static readonly List ZoneObjectsScratch = new(); + private readonly Dictionary zoneReadyCache = new(); + private Vector3 defaultSpawn = new(1, 1, 1); private BountyInfo defaultBounty = new(); private TreasureMapChestInfo defaultTreasure = new(); @@ -93,9 +123,8 @@ public void Update() return; } - if (currentUpdates < updatesRequired) + if (Time.time < nextSearchTime) { - currentUpdates += 1; return; } @@ -217,6 +246,7 @@ private void SpawnBountyTargets(BountyInfo bounty) } } + LogPlacement($"bounty target '{bounty.Target.MonsterID}'", bounty.Biome); placed.ForceSet(true); } @@ -244,24 +274,77 @@ private void SpawnChest(TreasureMapChestInfo treasure) } treasureChest.Setup(treasure.PlayerID, treasure.Biome, treasure.Interval); + LogPlacement("treasure chest", treasure.Biome); placed.ForceSet(true); } + /// + /// One always-visible line per placement. Every other line on this path is gated, which is why + /// "my bounty never spawned" reports used to arrive with logs that said nothing at all. When + /// the spawn point was chosen by another client (or before this instance was loaded), there is + /// no local search to time and only the fact of placement is reported. + /// + private void LogPlacement(string what, Heightmap.Biome biome) + { + string biomeName = BiomeDataManager.GetName(biome); + if (searchStartTime <= 0f) + { + EpicLoot.LogForce($"Adventure {what} ({biomeName}) placed at a spawn point chosen earlier."); + return; + } + + EpicLoot.LogForce($"Adventure {what} ({biomeName}) placed after {Time.time - searchStartTime:0.0}s: " + + $"area wait {areaReadyTime - searchStartTime:0.0}s, {candidatesRejected} spots rejected, " + + $"ring {placedBand}."); + } + internal IEnumerator DeterminespawnPoint(Vector3 startingSpawnPoint, Heightmap.Biome biome, WaterPlacement waterPlacement = WaterPlacement.Reject) { + searchStartTime = Time.time; + candidatesRejected = 0; + zoneReadyCache.Clear(); + // The owner of this spawner is not necessarily the player who bought it, so the biome - // index may never have been built on this client. Start it now; the waits below give it - // far longer than it needs, and IsOpenWater falls back safely if it is somehow not ready. + // index may never have been built on this client. A build takes a fraction of a second; + // IsOpenWater falls back safely if it is somehow still not ready when the wait gives up. WorldBiomeIndex.EnsureBuilt(); - yield return new WaitForSeconds(5); + float indexDeadline = Time.unscaledTime + IndexWaitTimeoutSeconds; + while (WorldBiomeIndex.State == BiomeIndexState.Building && Time.unscaledTime < indexDeadline) + { + yield return null; + if (!StillOwner()) + { + AbandonSearch(); + yield break; + } + } - while (!ZNetScene.instance.IsAreaReady(startingSpawnPoint)) + // This used to be a fixed 300 frames plus five seconds, then vanilla's IsAreaReady polled + // once a second -- 10-15s of nothing after arriving, before the search had even begun. + // IsAreaSettled waits exactly as long as loading actually takes. + while (!IsAreaSettled(startingSpawnPoint)) { - yield return new WaitForSeconds(1f); + yield return new WaitForSeconds(AreaPollSeconds); + if (!StillOwner()) + { + AbandonSearch(); + yield break; + } } + // One frame so colliders created this frame are in the physics scene before the raycasts. + yield return null; + if (!StillOwner()) + { + AbandonSearch(); + yield break; + } + + areaReadyTime = Time.time; + zoneReadyCache.Clear(); + // TODO: If bounties get their own minimap area radius config this must choose the correct one float radius = AdventureDataManager.Config.TreasureMap.MinimapAreaRadius; float waterSurface = ZoneSystem.instance.m_waterLevel; @@ -277,6 +360,7 @@ internal IEnumerator DeterminespawnPoint(Vector3 startingSpawnPoint, Vector3 determinedSpawn = startingSpawnPoint; bool foundSpawn = false; PrivateArea blockingWard = null; + int candidatesThisFrame = 0; // Band 0 is the original search disc. Every band after it is an annulus one MinimapAreaRadius // further out - the smallest step that can escape a ward, since a ward vetoes everything @@ -299,10 +383,26 @@ internal IEnumerator DeterminespawnPoint(Vector3 startingSpawnPoint, determinedSpawn = startingSpawnPoint + new Vector3( Mathf.Cos(sampleAngle) * sampleRadius, 0, Mathf.Sin(sampleAngle) * sampleRadius); - if (spawnLocationAttempts > 1 && spawnLocationAttempts % 10 == 0) + // Spread the work over frames. This used to sleep a whole second after every ten + // rejected candidates, so a cluttered or partly flooded area cost up to 10s per + // band -- a minute across every band -- before anything could appear. + if (++candidatesThisFrame >= CandidatesPerFrame) + { + candidatesThisFrame = 0; + yield return null; + if (!StillOwner()) + { + AbandonSearch(); + yield break; + } + } + + // A candidate whose zone has not finished creating its objects would pass the + // floor test straight through a rock that simply does not exist yet. + if (!IsCandidateZoneReady(ZoneSystem.GetZone(determinedSpawn))) { - // Sleep to avoid locking the thread - yield return new WaitForSeconds(1f); + spawnLocationAttempts += 1; + continue; } ZoneSystem.instance.GetGroundData( @@ -370,9 +470,13 @@ internal IEnumerator DeterminespawnPoint(Vector3 startingSpawnPoint, } foundSpawn = true; + placedBand = band; break; } + // Only failures increment the attempt counter, so it is exactly this band's rejections. + candidatesRejected += spawnLocationAttempts; + if (!foundSpawn && band < maxExpansions) { EpicLoot.LogWarning( @@ -385,13 +489,27 @@ internal IEnumerator DeterminespawnPoint(Vector3 startingSpawnPoint, if (!foundSpawn) { - EpicLoot.LogWarning( + string message = "Could not find a valid adventure spawn point after exhausting every search band. " + $"Start=({startingSpawnPoint.x:0.##}, {startingSpawnPoint.y:0.##}, {startingSpawnPoint.z:0.##}), " + $"Biome={biome}, SearchRadius={radius:0.##}, Expansions={maxExpansions}, " + $"Ward={AdventureWardCheck.DescribeWard(blockingWard)}. " + "Leaving the spawner in place to retry - it is not safe to discard a bounty or " + - "treasure map the player has already paid for."); + "treasure map the player has already paid for."; + + // This is the one outcome that really does leave a paid-for spawn missing, so the first + // report must reach a default-level log. Later retries stay gated: a player waiting in + // the area would otherwise get this line every RetrySearchDelaySeconds. + if (!reportedExhausted) + { + reportedExhausted = true; + EpicLoot.LogWarningForce(message); + } + else + { + EpicLoot.LogWarning(message); + } + ParkAndRetry(); yield break; } @@ -435,8 +553,139 @@ internal IEnumerator DeterminespawnPoint(Vector3 startingSpawnPoint, private void ParkAndRetry() { startedPlacement = false; - currentUpdates = 0; - updatesRequired = RetrySearchDelayFrames; + nextSearchTime = Time.time + RetrySearchDelaySeconds; + } + + /// + /// Whether this client still owns the spawner. Checked after every yield in the search, since + /// ownership moves to another player when the current owner leaves the area. + /// + private bool StillOwner() + { + return zNetView != null && zNetView.IsValid() && zNetView.IsOwner(); + } + + /// + /// Drops a search this client no longer owns. Without this the coroutine ran to the end + /// regardless, and its spawnPoint.ForceSet claimed ownership back from whoever had taken + /// over -- two clients searching and both reaching the spawn is how a bounty spawns twice. + /// No retry delay: if ownership comes back, searching again right away is correct. + /// + private void AbandonSearch() + { + EpicLoot.Log("Adventure spawner changed owner mid-search; leaving the search to the new owner."); + startedPlacement = false; + } + + /// + /// Whether the area around has finished loading, as far as this client + /// can load it. Vanilla requires the point's zone and all + /// eight around it, but a client only creates objects inside its own near simulation area. On a + /// low Simulation Distance setting that area is too small to contain all nine zones unless the + /// player stands in the point's own zone, and on the non-classic setting it never covers them + /// from a diagonal zone, so a player waiting inside the map circle could wait forever. Zones this + /// client cannot load are skipped rather than waited on. + /// + private bool IsAreaSettled(Vector3 point) + { + // Nothing is "near" a dedicated server's reference position; keep vanilla's rule for + // server-side simulation mods, which are the only way a server owns this spawner. + if (ZNet.instance.IsDedicated()) + { + return ZNetScene.instance.IsAreaReady(point); + } + + Vector2s centre = ZoneSystem.GetZone(point); + if (!IsZoneInLocalNearArea(centre)) + { + // The owner has to come closer before the point itself can load. + return false; + } + + for (int y = centre.y - 1; y <= centre.y + 1; y++) + { + for (int x = centre.x - 1; x <= centre.x + 1; x++) + { + var zone = new Vector2s(x, y); + if (IsZoneInLocalNearArea(zone) && !IsZoneInstantiated(zone)) + { + return false; + } + } + } + + return true; + } + + /// + /// Whether a search candidate's zone is loaded and populated, cached for the rest of the search. + /// A zone outside the near area is rejected outright, as GetGroundData would do for it anyway. + /// + private bool IsCandidateZoneReady(Vector2s zone) + { + // A dedicated server has no near area of its own (see IsAreaSettled); leave candidates to + // the GetGroundData check, as before. + if (ZNet.instance.IsDedicated()) + { + return true; + } + + if (!zoneReadyCache.TryGetValue(zone, out bool ready)) + { + ready = IsZoneInLocalNearArea(zone) && IsZoneInstantiated(zone); + zoneReadyCache[zone] = ready; + } + + return ready; + } + + /// + /// Whether ZNetScene creates the objects of on this client. The same + /// shape ZDOMan.FindSectorObjects uses for the near area: a square of NearSimulationDistance + /// zones, trimmed to a circle unless the setting is classic. + /// + private static bool IsZoneInLocalNearArea(Vector2s zone) + { + Vector2s centre = ZoneSystem.GetZone(ZNet.instance.GetReferencePosition()); + SimulationDistance distance = ZNet.instance.GetSyncedSimulationDistance(); + int near = distance.NearSimulationDistance; + int ring = Mathf.Max(Mathf.Abs(zone.x - centre.x), Mathf.Abs(zone.y - centre.y)); + + if (ring == 0) + { + return true; + } + + return ring <= near && + (distance.IsClassic || ZoneSystem.instance.ZonesWithinRadius(centre, zone, near)); + } + + /// + /// Vanilla narrowed to a single zone: the zone is loaded and + /// every object in it with a known prefab has been created. + /// + private static bool IsZoneInstantiated(Vector2s zone) + { + if (!ZoneSystem.instance.IsZoneLoaded(zone)) + { + return false; + } + + ZoneObjectsScratch.Clear(); + ZDOMan.instance.FindSectorObjects(zone, new SimulationDistance(0, 0), ZoneObjectsScratch); + + bool ready = true; + foreach (ZDO zdo in ZoneObjectsScratch) + { + if (ZNetScene.instance.IsPrefabZDOValid(zdo) && !ZNetScene.instance.HaveInstance(zdo)) + { + ready = false; + break; + } + } + + ZoneObjectsScratch.Clear(); + return ready; } /// @@ -445,7 +694,7 @@ private void ParkAndRetry() /// /// Spawning bails without setting placed, and Update re-enters it on the very next frame, /// so before this the spawner retried a lookup that cannot succeed **every frame for as long as - /// the player stayed near the bounty**. Back off to the same delay a failed location search uses, + /// the player stayed near the bounty**. Back off for , /// and report it once at Error rather than per-frame at Warning -- Warning is invisible at the /// default Log Level, which is why this failed silently. /// @@ -464,8 +713,7 @@ private void ReportMissingPrefab(string role, string bountyId, string monsterId) // Delay the next attempt without clearing startedPlacement -- the spawn point is fine, it is // only the prefab that is missing, so there is nothing to re-search for. - currentUpdates = 0; - updatesRequired = RetrySearchDelayFrames; + nextSearchTime = Time.time + MissingPrefabRetrySeconds; } /// From 33d13ab77b080dad67050959513ba23fecfff999 Mon Sep 17 00:00:00 2001 From: MidnightsFX Date: Wed, 16 Sep 2026 12:16:23 -0700 Subject: [PATCH 09/20] recompile fix --- EpicLoot/EpicLoot.cs | 2 +- EpicLoot/EpicLoot.csproj | 7 +++++++ EpicLoot/Package/CHANGELOG.md | 4 ++++ EpicLoot/Package/manifest.json | 2 +- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/EpicLoot/EpicLoot.cs b/EpicLoot/EpicLoot.cs index c501e3021..5c280ac0a 100644 --- a/EpicLoot/EpicLoot.cs +++ b/EpicLoot/EpicLoot.cs @@ -38,7 +38,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.14.6"; + public const string Version = "0.14.7"; 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 b9bbff998..fdee3ce98 100755 --- a/EpicLoot/EpicLoot.csproj +++ b/EpicLoot/EpicLoot.csproj @@ -86,6 +86,13 @@ + + diff --git a/EpicLoot/Package/CHANGELOG.md b/EpicLoot/Package/CHANGELOG.md index d91bd0079..7c589888c 100644 --- a/EpicLoot/Package/CHANGELOG.md +++ b/EpicLoot/Package/CHANGELOG.md @@ -1,3 +1,7 @@ +**0.14.7** +* Recompiled against current game build (fixes building destruction issues) + + **0.14.6** * Destroying an enchanting table now refunds the full upgrade cost of every unlocked feature. Amounts above an item's stack size (Surtling Cores, berries, mushrooms, fish) were previously lost * Optimizes bounty and treasure chests spawning, now spawns the target considerably earlier diff --git a/EpicLoot/Package/manifest.json b/EpicLoot/Package/manifest.json index 28ec30682..b961d2568 100644 --- a/EpicLoot/Package/manifest.json +++ b/EpicLoot/Package/manifest.json @@ -1,6 +1,6 @@ { "name": "EpicLoot", - "version_number": "0.14.6", + "version_number": "0.14.7", "website_url": "https://discord.gg/ZNhYeavv3C", "description": "Adds loot drops, magic items, and enchanting to Valheim.", "dependencies": [ From 955d6642a2902ffee4de2a84c3c63a2a6eff5a96 Mon Sep 17 00:00:00 2001 From: MidnightsFX Date: Thu, 17 Sep 2026 09:49:14 -0700 Subject: [PATCH 10/20] bounty relocation prevention, and gamepad fixes --- EpicLoot/EpicLoot.cs | 7 +- EpicLoot/Package/CHANGELOG.md | 5 + EpicLoot/Package/manifest.json | 2 +- EpicLoot/localizations/Bulgarian.json | 1 - EpicLoot/localizations/Chinese.json | 1 - EpicLoot/localizations/Chinese_Trad.json | 1 - EpicLoot/localizations/Croatian.json | 1 - EpicLoot/localizations/Czech.json | 1 - EpicLoot/localizations/Danish.json | 1 - EpicLoot/localizations/Dutch.json | 1 - EpicLoot/localizations/English.json | 1 - EpicLoot/localizations/Finnish.json | 1 - EpicLoot/localizations/French.json | 1 - EpicLoot/localizations/Georgian.json | 1 - EpicLoot/localizations/German.json | 1 - EpicLoot/localizations/Greek.json | 1 - EpicLoot/localizations/Hindi.json | 1 - EpicLoot/localizations/Hungarian.json | 1 - EpicLoot/localizations/Icelandic.json | 1 - EpicLoot/localizations/Italian.json | 1 - EpicLoot/localizations/Japanese.json | 1 - EpicLoot/localizations/Korean.json | 1 - EpicLoot/localizations/Latvian.json | 1 - EpicLoot/localizations/Lithuanian.json | 1 - EpicLoot/localizations/Macedonian.json | 1 - EpicLoot/localizations/Norwegian.json | 1 - EpicLoot/localizations/Polish.json | 1 - .../localizations/Portuguese_Brazilian.json | 1 - .../localizations/Portuguese_European.json | 1 - EpicLoot/localizations/Romanian.json | 1 - EpicLoot/localizations/Russian.json | 1 - EpicLoot/localizations/Serbian.json | 1 - EpicLoot/localizations/Slovak.json | 1 - EpicLoot/localizations/Spanish.json | 1 - EpicLoot/localizations/Swedish.json | 1 - EpicLoot/localizations/Thai.json | 1 - EpicLoot/localizations/Turkish.json | 1 - EpicLoot/localizations/Ukrainian.json | 1 - EpicLoot/src/Adventure/AdventureDataConfig.cs | 11 +- EpicLoot/src/Adventure/AdventureSaveData.cs | 93 -- .../src/Adventure/AdventureSpawnWatchdog.cs | 347 ++++++++ EpicLoot/src/Adventure/AdventureWardCheck.cs | 51 +- .../Adventure/Minimap/MinimapController.cs | 14 +- EpicLoot/src/Adventure/WorldBiomeIndex.cs | 14 +- .../bounties/AdventureSpawnController.cs | 799 ++++++++++++------ EpicLoot/src/Adventure/feature/Bounties.cs | 2 +- .../feature/BountyLocationEarlyCache.cs | 6 +- .../src/Adventure/feature/TreasureMaps.cs | 2 +- 48 files changed, 956 insertions(+), 432 deletions(-) create mode 100644 EpicLoot/src/Adventure/AdventureSpawnWatchdog.cs diff --git a/EpicLoot/EpicLoot.cs b/EpicLoot/EpicLoot.cs index 5c280ac0a..23a16af6e 100644 --- a/EpicLoot/EpicLoot.cs +++ b/EpicLoot/EpicLoot.cs @@ -38,7 +38,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.14.7"; + public const string Version = "0.14.8"; private static string ConfigFileName = PluginId + ".cfg"; private static string ConfigFileFullPath = BepInEx.Paths.ConfigPath + Path.DirectorySeparatorChar + ConfigFileName; @@ -122,6 +122,9 @@ void Awake() { MagicItemEffects.Shards.PoisonAdrenalinePulse.Create(); MagicItemEffects.Shards.StormFuryPulse.Create(); + // Logs, from the buyer's side, a bounty or treasure map that has not appeared at its map circle. + AdventureSpawnWatchdog.Create(); + // Main file config watcher SetupWatcher(); } @@ -636,7 +639,7 @@ private static void LoadCraftableItems() { } private static void LoadBountySpawner() { - GameObject bounty_spawner = EpicAssets.AssetBundle.LoadAsset("EL_SpawnController"); + GameObject bounty_spawner = EpicAssets.AssetBundle.LoadAsset(AdventureSpawnController.PrefabName); if (bounty_spawner == null) { LogErrorForce("Unable to find bounty spawner asset! This mod will not behave as expected!"); diff --git a/EpicLoot/Package/CHANGELOG.md b/EpicLoot/Package/CHANGELOG.md index 7c589888c..4581adae1 100644 --- a/EpicLoot/Package/CHANGELOG.md +++ b/EpicLoot/Package/CHANGELOG.md @@ -1,3 +1,8 @@ +**0.14.8** +* Controller update for 9/17 +* Increases placement allowance for bounties/treasures +* Removes bounty/treasure relocation due to the potential for schisms + **0.14.7** * Recompiled against current game build (fixes building destruction issues) diff --git a/EpicLoot/Package/manifest.json b/EpicLoot/Package/manifest.json index b961d2568..9bd475cae 100644 --- a/EpicLoot/Package/manifest.json +++ b/EpicLoot/Package/manifest.json @@ -1,6 +1,6 @@ { "name": "EpicLoot", - "version_number": "0.14.7", + "version_number": "0.14.8", "website_url": "https://discord.gg/ZNhYeavv3C", "description": "Adds loot drops, magic items, and enchanting to Valheim.", "dependencies": [ diff --git a/EpicLoot/localizations/Bulgarian.json b/EpicLoot/localizations/Bulgarian.json index f5d876d21..8eae2733d 100644 --- a/EpicLoot/localizations/Bulgarian.json +++ b/EpicLoot/localizations/Bulgarian.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Ярост!!", "mod_epicloot_itemtooltip_rarity": "Рядкост", "mod_epicloot_itemtooltip_effects": "Ефекти", - "mod_epicloot_adventure_spawnrelocated": "Картата ти беше преначертана – отбелязаният район беше зает.", "mod_epicloot_bounties_claimedmsg": "Наградата е взета!", "mod_epicloot_bounties_completemsg": "Баунти справено!", "mod_epicloot_bounties_locatingmsg": "Намиране на целта за баунти...", diff --git a/EpicLoot/localizations/Chinese.json b/EpicLoot/localizations/Chinese.json index a99907778..8819dce30 100644 --- a/EpicLoot/localizations/Chinese.json +++ b/EpicLoot/localizations/Chinese.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "愤怒!!", "mod_epicloot_itemtooltip_rarity": "稀有度", "mod_epicloot_itemtooltip_effects": "效果", - "mod_epicloot_adventure_spawnrelocated": "地图已重绘——标记的区域已被占用。", "mod_epicloot_bounties_claimedmsg": "赏金已领取!", "mod_epicloot_bounties_completemsg": "赏金已消灭!", "mod_epicloot_bounties_locatingmsg": "定位赏金目标...", diff --git a/EpicLoot/localizations/Chinese_Trad.json b/EpicLoot/localizations/Chinese_Trad.json index d57cbbaac..c5067dd55 100644 --- a/EpicLoot/localizations/Chinese_Trad.json +++ b/EpicLoot/localizations/Chinese_Trad.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "憤怒!!", "mod_epicloot_itemtooltip_rarity": "稀有度", "mod_epicloot_itemtooltip_effects": "效果", - "mod_epicloot_adventure_spawnrelocated": "地圖已重繪——標記的區域已被占用。", "mod_epicloot_bounties_claimedmsg": "賞金已領取!", "mod_epicloot_bounties_completemsg": "賞金已消滅!", "mod_epicloot_bounties_locatingmsg": "正在定位賞金目標...", diff --git a/EpicLoot/localizations/Croatian.json b/EpicLoot/localizations/Croatian.json index 49b96e170..cc99cd75f 100644 --- a/EpicLoot/localizations/Croatian.json +++ b/EpicLoot/localizations/Croatian.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Luuudilo!!", "mod_epicloot_itemtooltip_rarity": "Rijetkost", "mod_epicloot_itemtooltip_effects": "Učinci", - "mod_epicloot_adventure_spawnrelocated": "Tvoja je karta ponovno iscrtana – označeno područje bilo je zauzeto.", "mod_epicloot_bounties_claimedmsg": "Plijen pronađen!", "mod_epicloot_bounties_completemsg": "Plijen poražen!", "mod_epicloot_bounties_locatingmsg": "Lociranje mete plijena...", diff --git a/EpicLoot/localizations/Czech.json b/EpicLoot/localizations/Czech.json index 9b1ca6c2f..ba06e2da5 100644 --- a/EpicLoot/localizations/Czech.json +++ b/EpicLoot/localizations/Czech.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Raaaage!!", "mod_epicloot_itemtooltip_rarity": "Vzácnost", "mod_epicloot_itemtooltip_effects": "Efekty", - "mod_epicloot_adventure_spawnrelocated": "Tvá mapa byla překreslena – označená oblast byla obsazena.", "mod_epicloot_bounties_claimedmsg": "Odmena nárokována!", "mod_epicloot_bounties_completemsg": "Odmena poražena!", "mod_epicloot_bounties_locatingmsg": "Vyhledávání cíle odměny...", diff --git a/EpicLoot/localizations/Danish.json b/EpicLoot/localizations/Danish.json index 973fe86d1..cc18a449f 100644 --- a/EpicLoot/localizations/Danish.json +++ b/EpicLoot/localizations/Danish.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Raaaage!!", "mod_epicloot_itemtooltip_rarity": "Sjældenhed", "mod_epicloot_itemtooltip_effects": "Effekter", - "mod_epicloot_adventure_spawnrelocated": "Dit kort er tegnet om – det markerede område var optaget.", "mod_epicloot_bounties_claimedmsg": "Bounty Claimed!", "mod_epicloot_bounties_completemsg": "Bounty Vanquished!", "mod_epicloot_bounties_locatingmsg": "Lokalisering af Bounty-mål...", diff --git a/EpicLoot/localizations/Dutch.json b/EpicLoot/localizations/Dutch.json index 71fb3e2fb..41fd0ccf3 100644 --- a/EpicLoot/localizations/Dutch.json +++ b/EpicLoot/localizations/Dutch.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Wooede!!", "mod_epicloot_itemtooltip_rarity": "Zeldzaamheid", "mod_epicloot_itemtooltip_effects": "Effecten", - "mod_epicloot_adventure_spawnrelocated": "Je kaart is opnieuw getekend: het gemarkeerde gebied was bezet.", "mod_epicloot_bounties_claimedmsg": "Beloning Geclaimd!", "mod_epicloot_bounties_completemsg": "Beloning Verslagen!", "mod_epicloot_bounties_locatingmsg": "Op Zoek naar Doelwit...", diff --git a/EpicLoot/localizations/English.json b/EpicLoot/localizations/English.json index 3dc4100ec..c16e182a6 100644 --- a/EpicLoot/localizations/English.json +++ b/EpicLoot/localizations/English.json @@ -895,7 +895,6 @@ "mod_epicloot_adventure_locatefailed": "Could not find a suitable location in the $1.", "mod_epicloot_adventure_locateunavailable": "Still mapping the world - try again in a moment.", - "mod_epicloot_adventure_spawnrelocated": "Your map has been redrawn - the marked area was occupied.", "mod_epicloot_bounties_claimedmsg": "Bounty Claimed!", "mod_epicloot_bounties_completemsg": "Bounty Vanquished!", diff --git a/EpicLoot/localizations/Finnish.json b/EpicLoot/localizations/Finnish.json index c11d301a3..83797f136 100644 --- a/EpicLoot/localizations/Finnish.json +++ b/EpicLoot/localizations/Finnish.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Raaaaage!!", "mod_epicloot_itemtooltip_rarity": "Harvinaisuus", "mod_epicloot_itemtooltip_effects": "Vaikutukset", - "mod_epicloot_adventure_spawnrelocated": "Karttasi on piirretty uudelleen – merkitty alue oli varattu.", "mod_epicloot_bounties_claimedmsg": "Palkinto lunastettu!", "mod_epicloot_bounties_completemsg": "Palkkio voitettu!", "mod_epicloot_bounties_locatingmsg": "Etsitään palkkion kohdetta...", diff --git a/EpicLoot/localizations/French.json b/EpicLoot/localizations/French.json index 563e4f934..24bf758b6 100644 --- a/EpicLoot/localizations/French.json +++ b/EpicLoot/localizations/French.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Raaaage!!", "mod_epicloot_itemtooltip_rarity": "Rareté", "mod_epicloot_itemtooltip_effects": "Effets", - "mod_epicloot_adventure_spawnrelocated": "Votre carte a été redessinée : la zone marquée était occupée.", "mod_epicloot_bounties_claimedmsg": "Prime réclamée!", "mod_epicloot_bounties_completemsg": "Prime vaincue!", "mod_epicloot_bounties_locatingmsg": "Localisation de la cible de prime...", diff --git a/EpicLoot/localizations/Georgian.json b/EpicLoot/localizations/Georgian.json index 899537392..ca78557e6 100644 --- a/EpicLoot/localizations/Georgian.json +++ b/EpicLoot/localizations/Georgian.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "რაააჯ!!", "mod_epicloot_itemtooltip_rarity": "რარიტეტი", "mod_epicloot_itemtooltip_effects": "ეფექტები", - "mod_epicloot_adventure_spawnrelocated": "რუკა ხელახლა დაიხატა — მონიშნული ადგილი უკვე დაკავებული იყო.", "mod_epicloot_bounties_claimedmsg": "სამომავლო გადაიტანე!", "mod_epicloot_bounties_completemsg": "სამომავლო გადაიტანე!", "mod_epicloot_bounties_locatingmsg": "სამომავლოს ადგილის გელოვა...", diff --git a/EpicLoot/localizations/German.json b/EpicLoot/localizations/German.json index 13563e641..a8048295b 100644 --- a/EpicLoot/localizations/German.json +++ b/EpicLoot/localizations/German.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Raaaage!!", "mod_epicloot_itemtooltip_rarity": "Seltenheit", "mod_epicloot_itemtooltip_effects": "Effekte", - "mod_epicloot_adventure_spawnrelocated": "Deine Karte wurde neu gezeichnet – das markierte Gebiet war besetzt.", "mod_epicloot_bounties_claimedmsg": "Kopfgeld beansprucht!", "mod_epicloot_bounties_completemsg": "Kopfgeld vernichtet!", "mod_epicloot_bounties_locatingmsg": "Suche nach Kopfgeldziel...", diff --git a/EpicLoot/localizations/Greek.json b/EpicLoot/localizations/Greek.json index 270ecb860..bd86c25de 100644 --- a/EpicLoot/localizations/Greek.json +++ b/EpicLoot/localizations/Greek.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Θυμός!!", "mod_epicloot_itemtooltip_rarity": "Σπανιότητα", "mod_epicloot_itemtooltip_effects": "Εφέ", - "mod_epicloot_adventure_spawnrelocated": "Ο χάρτης σου ξανασχεδιάστηκε – η σημειωμένη περιοχή ήταν κατειλημμένη.", "mod_epicloot_bounties_claimedmsg": "Κυνήγι Ανταμοιβής!", "mod_epicloot_bounties_completemsg": "Κυνήγι Καταστράφηκε!", "mod_epicloot_bounties_locatingmsg": "Εντοπισμός Στόχου Ανταμοιβής...", diff --git a/EpicLoot/localizations/Hindi.json b/EpicLoot/localizations/Hindi.json index f93647185..9efbb1caa 100644 --- a/EpicLoot/localizations/Hindi.json +++ b/EpicLoot/localizations/Hindi.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "रेज!!", "mod_epicloot_itemtooltip_rarity": "रेयरिटी", "mod_epicloot_itemtooltip_effects": "प्रभाव", - "mod_epicloot_adventure_spawnrelocated": "मानचित्र दोबारा बनाया गया — चिह्नित क्षेत्र पहले से भरा हुआ था।", "mod_epicloot_bounties_claimedmsg": "इनाम प्राप्त किया गया!", "mod_epicloot_bounties_completemsg": "इनाम जीता गया!", "mod_epicloot_bounties_locatingmsg": "इनाम का निशाना ढूंढ़ रहा है...", diff --git a/EpicLoot/localizations/Hungarian.json b/EpicLoot/localizations/Hungarian.json index f176016a8..0d1d93878 100644 --- a/EpicLoot/localizations/Hungarian.json +++ b/EpicLoot/localizations/Hungarian.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Dühödt vagy!!", "mod_epicloot_itemtooltip_rarity": "Ritkaság", "mod_epicloot_itemtooltip_effects": "Hatások", - "mod_epicloot_adventure_spawnrelocated": "A térképedet újrarajzoltuk – a megjelölt terület foglalt volt.", "mod_epicloot_bounties_claimedmsg": "Jutalom igényelve!", "mod_epicloot_bounties_completemsg": "Jutalom elnyerve!", "mod_epicloot_bounties_locatingmsg": "Jutalom célpont lokalizálása...", diff --git a/EpicLoot/localizations/Icelandic.json b/EpicLoot/localizations/Icelandic.json index c46ed7c29..53fb36324 100644 --- a/EpicLoot/localizations/Icelandic.json +++ b/EpicLoot/localizations/Icelandic.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Raaaaage!!", "mod_epicloot_itemtooltip_rarity": "Sjaldgæfni", "mod_epicloot_itemtooltip_effects": "Áhrif", - "mod_epicloot_adventure_spawnrelocated": "Kortið þitt hefur verið teiknað upp á nýtt – merkta svæðið var upptekið.", "mod_epicloot_bounties_claimedmsg": "Bounty krækta!", "mod_epicloot_bounties_completemsg": "Bounty sigraður!", "mod_epicloot_bounties_locatingmsg": "Að finna Bounty markmið...", diff --git a/EpicLoot/localizations/Italian.json b/EpicLoot/localizations/Italian.json index 99b2a57a5..958246418 100644 --- a/EpicLoot/localizations/Italian.json +++ b/EpicLoot/localizations/Italian.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Raaaage!!", "mod_epicloot_itemtooltip_rarity": "Rarità", "mod_epicloot_itemtooltip_effects": "Effetti", - "mod_epicloot_adventure_spawnrelocated": "La tua mappa è stata ridisegnata: l'area segnata era occupata.", "mod_epicloot_bounties_claimedmsg": "Taglia Richiesta!", "mod_epicloot_bounties_completemsg": "Taglia Sconfitta!", "mod_epicloot_bounties_locatingmsg": "Localizzazione del Bersaglio della Taglia...", diff --git a/EpicLoot/localizations/Japanese.json b/EpicLoot/localizations/Japanese.json index e8bf98022..66ce0a36e 100644 --- a/EpicLoot/localizations/Japanese.json +++ b/EpicLoot/localizations/Japanese.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "怒りっ!!", "mod_epicloot_itemtooltip_rarity": "希少度", "mod_epicloot_itemtooltip_effects": "効果", - "mod_epicloot_adventure_spawnrelocated": "地図が引き直されました。指定された区域はふさがっていました。", "mod_epicloot_bounties_claimedmsg": "報酬を獲得しました!", "mod_epicloot_bounties_completemsg": "懸賞金を打ち負かしました!", "mod_epicloot_bounties_locatingmsg": "懸賞金のターゲットを特定中...", diff --git a/EpicLoot/localizations/Korean.json b/EpicLoot/localizations/Korean.json index 9b649cb05..4dfd659b3 100644 --- a/EpicLoot/localizations/Korean.json +++ b/EpicLoot/localizations/Korean.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "격노!!", "mod_epicloot_itemtooltip_rarity": "희귀도", "mod_epicloot_itemtooltip_effects": "효과", - "mod_epicloot_adventure_spawnrelocated": "지도가 다시 그려졌습니다 — 표시된 구역이 이미 차 있었습니다.", "mod_epicloot_bounties_claimedmsg": "현상금 청구됨!", "mod_epicloot_bounties_completemsg": "현상금 격파됨!", "mod_epicloot_bounties_locatingmsg": "현상금 대상 탐색 중...", diff --git a/EpicLoot/localizations/Latvian.json b/EpicLoot/localizations/Latvian.json index 1d1c2281f..0f57ca600 100644 --- a/EpicLoot/localizations/Latvian.json +++ b/EpicLoot/localizations/Latvian.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Dīvainums!!", "mod_epicloot_itemtooltip_rarity": "Retums", "mod_epicloot_itemtooltip_effects": "Efekti", - "mod_epicloot_adventure_spawnrelocated": "Tava karte pārzīmēta – atzīmētā vieta bija aizņemta.", "mod_epicloot_bounties_claimedmsg": "Nodokļu prasīts!", "mod_epicloot_bounties_completemsg": "Nodokļi uzvarēti!", "mod_epicloot_bounties_locatingmsg": "Nodokļu mērķa atrašana...", diff --git a/EpicLoot/localizations/Lithuanian.json b/EpicLoot/localizations/Lithuanian.json index 9d7505fea..96f9e6ab7 100644 --- a/EpicLoot/localizations/Lithuanian.json +++ b/EpicLoot/localizations/Lithuanian.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Raaage!!", "mod_epicloot_itemtooltip_rarity": "Retumas", "mod_epicloot_itemtooltip_effects": "Efektai", - "mod_epicloot_adventure_spawnrelocated": "Tavo žemėlapis perbrėžtas – pažymėta vieta buvo užimta.", "mod_epicloot_bounties_claimedmsg": "Išieškota užkarda!", "mod_epicloot_bounties_completemsg": "Užkarda nugalėta!", "mod_epicloot_bounties_locatingmsg": "Ieškant užkardos taikinio...", diff --git a/EpicLoot/localizations/Macedonian.json b/EpicLoot/localizations/Macedonian.json index 5a4169f3e..f48755ec1 100644 --- a/EpicLoot/localizations/Macedonian.json +++ b/EpicLoot/localizations/Macedonian.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Лудило!!", "mod_epicloot_itemtooltip_rarity": "Реткост", "mod_epicloot_itemtooltip_effects": "Ефекти", - "mod_epicloot_adventure_spawnrelocated": "Твојата карта е прецртана – означеното подрачје беше зафатено.", "mod_epicloot_bounties_claimedmsg": "Награда за борбата освоена!", "mod_epicloot_bounties_completemsg": "Наградата за борбата победена!", "mod_epicloot_bounties_locatingmsg": "Локација на целта за наградата...", diff --git a/EpicLoot/localizations/Norwegian.json b/EpicLoot/localizations/Norwegian.json index c80f57213..2cd2fdf71 100644 --- a/EpicLoot/localizations/Norwegian.json +++ b/EpicLoot/localizations/Norwegian.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Raaaage!!", "mod_epicloot_itemtooltip_rarity": "Sjeldenhet", "mod_epicloot_itemtooltip_effects": "Effekter", - "mod_epicloot_adventure_spawnrelocated": "Kartet ditt er tegnet på nytt – det markerte området var opptatt.", "mod_epicloot_bounties_claimedmsg": "Bounty Claimed!", "mod_epicloot_bounties_completemsg": "Bounty Vanquished!", "mod_epicloot_bounties_locatingmsg": "Lokalisering av Bounty-mål...", diff --git a/EpicLoot/localizations/Polish.json b/EpicLoot/localizations/Polish.json index 2eb89ba60..90e4ce6bf 100644 --- a/EpicLoot/localizations/Polish.json +++ b/EpicLoot/localizations/Polish.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Wściekłość!!", "mod_epicloot_itemtooltip_rarity": "Rzadkość", "mod_epicloot_itemtooltip_effects": "Efekty", - "mod_epicloot_adventure_spawnrelocated": "Twoja mapa została przerysowana – zaznaczony obszar był zajęty.", "mod_epicloot_bounties_claimedmsg": "Zdobycie nagrody!", "mod_epicloot_bounties_completemsg": "Pokonano nagrodę!", "mod_epicloot_bounties_locatingmsg": "Lokalizowanie celu nagrody...", diff --git a/EpicLoot/localizations/Portuguese_Brazilian.json b/EpicLoot/localizations/Portuguese_Brazilian.json index 6748c806c..6ae8662de 100644 --- a/EpicLoot/localizations/Portuguese_Brazilian.json +++ b/EpicLoot/localizations/Portuguese_Brazilian.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Raaaiva!!", "mod_epicloot_itemtooltip_rarity": "Raridade", "mod_epicloot_itemtooltip_effects": "Efeitos", - "mod_epicloot_adventure_spawnrelocated": "Seu mapa foi redesenhado: a área marcada estava ocupada.", "mod_epicloot_bounties_claimedmsg": "Recompensa Reivindicada!", "mod_epicloot_bounties_completemsg": "Recompensa Aniquilada!", "mod_epicloot_bounties_locatingmsg": "Localizando Alvo da Recompensa...", diff --git a/EpicLoot/localizations/Portuguese_European.json b/EpicLoot/localizations/Portuguese_European.json index 21a8c537d..19c5776e6 100644 --- a/EpicLoot/localizations/Portuguese_European.json +++ b/EpicLoot/localizations/Portuguese_European.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Raaage!!", "mod_epicloot_itemtooltip_rarity": "Raridade", "mod_epicloot_itemtooltip_effects": "Efeitos", - "mod_epicloot_adventure_spawnrelocated": "O teu mapa foi redesenhado: a área marcada estava ocupada.", "mod_epicloot_bounties_claimedmsg": "Recompensa Reivindicada!", "mod_epicloot_bounties_completemsg": "Recompensa Aniquilada!", "mod_epicloot_bounties_locatingmsg": "Localizando Alvo da Recompensa...", diff --git a/EpicLoot/localizations/Romanian.json b/EpicLoot/localizations/Romanian.json index 509d99049..98fd656fc 100644 --- a/EpicLoot/localizations/Romanian.json +++ b/EpicLoot/localizations/Romanian.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Furie!!", "mod_epicloot_itemtooltip_rarity": "Raritate", "mod_epicloot_itemtooltip_effects": "Efecte", - "mod_epicloot_adventure_spawnrelocated": "Harta ta a fost redesenată – zona marcată era ocupată.", "mod_epicloot_bounties_claimedmsg": "Recompensă revendicată!", "mod_epicloot_bounties_completemsg": "Recompensă învinsă!", "mod_epicloot_bounties_locatingmsg": "Localizare țintă recompensă...", diff --git a/EpicLoot/localizations/Russian.json b/EpicLoot/localizations/Russian.json index d6288b241..1c4130c34 100644 --- a/EpicLoot/localizations/Russian.json +++ b/EpicLoot/localizations/Russian.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Ярооость!!", "mod_epicloot_itemtooltip_rarity": "Редкость", "mod_epicloot_itemtooltip_effects": "Чары", - "mod_epicloot_adventure_spawnrelocated": "Ваша карта перерисована — отмеченный участок был занят.", "mod_epicloot_bounties_claimedmsg": "Награда получена!", "mod_epicloot_bounties_completemsg": "Задание выполнено!", "mod_epicloot_bounties_locatingmsg": "Сейчас я отмечу где твоя цель...", diff --git a/EpicLoot/localizations/Serbian.json b/EpicLoot/localizations/Serbian.json index 1a79c6c8b..606eafd84 100644 --- a/EpicLoot/localizations/Serbian.json +++ b/EpicLoot/localizations/Serbian.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Raaaaage!!", "mod_epicloot_itemtooltip_rarity": "Ritkost", "mod_epicloot_itemtooltip_effects": "Efekti", - "mod_epicloot_adventure_spawnrelocated": "Твоја мапа је поново исцртана – означено подручје било је заузето.", "mod_epicloot_bounties_claimedmsg": "Nagrada Prikupljena!", "mod_epicloot_bounties_completemsg": "Nagrada Uništena!", "mod_epicloot_bounties_locatingmsg": "Lociranje Cilja Nagrade...", diff --git a/EpicLoot/localizations/Slovak.json b/EpicLoot/localizations/Slovak.json index 71824f093..aef24888a 100644 --- a/EpicLoot/localizations/Slovak.json +++ b/EpicLoot/localizations/Slovak.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Raaaaage!!", "mod_epicloot_itemtooltip_rarity": "Vzácnosť", "mod_epicloot_itemtooltip_effects": "Efekty", - "mod_epicloot_adventure_spawnrelocated": "Tvoja mapa bola prekreslená – označená oblasť bola obsadená.", "mod_epicloot_bounties_claimedmsg": "Odmena získaná!", "mod_epicloot_bounties_completemsg": "Odmena premožená!", "mod_epicloot_bounties_locatingmsg": "Vyhľadávanie cieľa odmeny...", diff --git a/EpicLoot/localizations/Spanish.json b/EpicLoot/localizations/Spanish.json index c6b9510fe..5992bc40c 100644 --- a/EpicLoot/localizations/Spanish.json +++ b/EpicLoot/localizations/Spanish.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "¡Raaaabia!!", "mod_epicloot_itemtooltip_rarity": "Rareza", "mod_epicloot_itemtooltip_effects": "Efectos", - "mod_epicloot_adventure_spawnrelocated": "Tu mapa se ha redibujado: la zona marcada estaba ocupada.", "mod_epicloot_bounties_claimedmsg": "¡Recompensa reclamada!", "mod_epicloot_bounties_completemsg": "¡Recompensa aniquilada!", "mod_epicloot_bounties_locatingmsg": "Localizando objetivo de recompensa...", diff --git a/EpicLoot/localizations/Swedish.json b/EpicLoot/localizations/Swedish.json index e9b7786af..acece216e 100644 --- a/EpicLoot/localizations/Swedish.json +++ b/EpicLoot/localizations/Swedish.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Raaaage!!", "mod_epicloot_itemtooltip_rarity": "Sällsynthet", "mod_epicloot_itemtooltip_effects": "Effekter", - "mod_epicloot_adventure_spawnrelocated": "Din karta har ritats om – det markerade området var upptaget.", "mod_epicloot_bounties_claimedmsg": "Bounty Claimed!", "mod_epicloot_bounties_completemsg": "Bounty Vanquished!", "mod_epicloot_bounties_locatingmsg": "Lokalisera Bounty-mål...", diff --git a/EpicLoot/localizations/Thai.json b/EpicLoot/localizations/Thai.json index ebc0c6d03..cbf65f830 100644 --- a/EpicLoot/localizations/Thai.json +++ b/EpicLoot/localizations/Thai.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "โอ๊ะ!!", "mod_epicloot_itemtooltip_rarity": "ความหายาก", "mod_epicloot_itemtooltip_effects": "ผลกระทบ", - "mod_epicloot_adventure_spawnrelocated": "แผนที่ถูกวาดใหม่ — พื้นที่ที่ทำเครื่องหมายไว้ถูกใช้ไปแล้ว", "mod_epicloot_bounties_claimedmsg": "รับการประกาศ!", "mod_epicloot_bounties_completemsg": "ประกาศได้รับการชนะ!", "mod_epicloot_bounties_locatingmsg": "กำลังค้นหาเป้าหมายของการประกาศ...", diff --git a/EpicLoot/localizations/Turkish.json b/EpicLoot/localizations/Turkish.json index 028e46a76..ea1fc39f4 100644 --- a/EpicLoot/localizations/Turkish.json +++ b/EpicLoot/localizations/Turkish.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Öfke!!", "mod_epicloot_itemtooltip_rarity": "Nadirlik", "mod_epicloot_itemtooltip_effects": "Efektler", - "mod_epicloot_adventure_spawnrelocated": "Haritan yeniden çizildi – işaretli alan doluydu.", "mod_epicloot_bounties_claimedmsg": "Ödül Alındı!", "mod_epicloot_bounties_completemsg": "Ödül Kazanıldı!", "mod_epicloot_bounties_locatingmsg": "Ödül Hedefi Bulunuyor...", diff --git a/EpicLoot/localizations/Ukrainian.json b/EpicLoot/localizations/Ukrainian.json index 2ae74053b..889a4fc24 100644 --- a/EpicLoot/localizations/Ukrainian.json +++ b/EpicLoot/localizations/Ukrainian.json @@ -769,7 +769,6 @@ "mod_epicloot_se_berserker_startmessage": "Рррааагг!!", "mod_epicloot_itemtooltip_rarity": "Рідкість", "mod_epicloot_itemtooltip_effects": "Ефекти", - "mod_epicloot_adventure_spawnrelocated": "Твою карту перемальовано — позначена ділянка була зайнята.", "mod_epicloot_bounties_claimedmsg": "Винагорода вибрана!", "mod_epicloot_bounties_completemsg": "Місія виконана!", "mod_epicloot_bounties_locatingmsg": "Визначення цілі місії...", diff --git a/EpicLoot/src/Adventure/AdventureDataConfig.cs b/EpicLoot/src/Adventure/AdventureDataConfig.cs index c2495a633..57472c898 100644 --- a/EpicLoot/src/Adventure/AdventureDataConfig.cs +++ b/EpicLoot/src/Adventure/AdventureDataConfig.cs @@ -104,12 +104,13 @@ public class TreasureMapConfig public float RadiusInterval = 500; public float MinimapAreaRadius = 100; /// - /// How many times the adventure spawn search may push its sampling ring further out when - /// everything inside the map circle is blocked (almost always by a ward). Each band steps out - /// by one , which is the minimum that can escape a ward's - /// veto, since a ward rejects points within its own radius + MinimapAreaRadius. Set to 0 to - /// restore the old behaviour of never searching outside the circle. + /// Ignored. The adventure spawn search used to push out past the map circle when a ward covered + /// it, which needed the buyer's own client to redraw the circle - any other machine placing the + /// spawn (another player who arrived first, or the dedicated server) could not, and left the + /// bounty unplaced. Placement now never leaves the circle. Kept so existing config files and + /// patches that set it still load. /// + [Obsolete] public int MaxSpawnSearchExpansions = 5; /// diff --git a/EpicLoot/src/Adventure/AdventureSaveData.cs b/EpicLoot/src/Adventure/AdventureSaveData.cs index 8067df7b7..a69b0d5c6 100755 --- a/EpicLoot/src/Adventure/AdventureSaveData.cs +++ b/EpicLoot/src/Adventure/AdventureSaveData.cs @@ -395,55 +395,6 @@ public bool FoundTreasureChest(int interval, Heightmap.Biome biome) return false; } - /// - /// Moves a purchased-but-unfound treasure map to a new world position and drags its minimap - /// pin along with it. Called when the spawner had to search outside the original map circle - /// (almost always because a ward covered it) - without this the pin would keep pointing at a - /// spot the chest is not in. - /// - public bool RelocateTreasureMap(int interval, Heightmap.Biome biome, Vector3 newPosition) - { - var treasureMap = GetTreasureMapChestInfo(interval, biome); - if (treasureMap == null || treasureMap.State != TreasureMapState.Purchased) - { - return false; - } - - treasureMap.Position = newPosition; - // The circle is re-centred on the chest, so any old offset would just skew it back. - treasureMap.MinimapCircleOffset = Vector3.zero; - - var key = new Tuple(interval, biome); - if (MinimapController.TreasureMapPins.TryGetValue(key, out var existingPin)) - { - // The queue is drained FIFO, so remove-then-add is a move. - MinimapController.AddPinJobToQueue(new PinJob - { - Task = MinimapPinQueueTask.RemoveTreasurePin, - DebugMode = DebugMode, - TreasurePin = new KeyValuePair, AreaPinInfo>(key, existingPin) - }); - } - - var pinInfo = new AreaPinInfo - { - Position = treasureMap.Position + treasureMap.MinimapCircleOffset, - Type = EpicLoot.TreasureMapPinType, - Name = Localization.instance.Localize("$mod_epicloot_treasurechest_minimappin", - Localization.instance.Localize(BiomeDataManager.GetLocalizationToken(biome)), - (interval + 1).ToString()) - }; - - MinimapController.AddPinJobToQueue(new PinJob - { - Task = MinimapPinQueueTask.AddTreasurePin, - DebugMode = DebugMode, - TreasurePin = new KeyValuePair, AreaPinInfo>(key, pinInfo) - }); - - return true; - } - public TreasureMapChestInfo GetTreasureMapChestInfo(int interval, Heightmap.Biome biome) { return TreasureMaps.Find(x => x.Interval == interval && x.Biome == biome); @@ -534,49 +485,5 @@ public void AbandonedBounty(string bountyID) bounty.State = BountyState.Abandoned; } } - - /// - /// Bounty counterpart to : moves an in-progress bounty's - /// world position and its minimap pin when the spawner had to place the targets outside the - /// original circle. - /// - public bool RelocateBounty(string bountyID, Vector3 newPosition) - { - var bounty = GetBountyInfoByID(bountyID); - if (bounty == null || bounty.State != BountyState.InProgress) - { - return false; - } - - bounty.Position = newPosition; - bounty.MinimapCircleOffset = Vector3.zero; - - if (MinimapController.BountyPins.TryGetValue(bountyID, out var existingPin)) - { - MinimapController.AddPinJobToQueue(new PinJob - { - Task = MinimapPinQueueTask.RemoveBountyPin, - DebugMode = DebugMode, - BountyPin = new KeyValuePair(bountyID, existingPin) - }); - } - - var pinInfo = new AreaPinInfo - { - Position = bounty.Position + bounty.MinimapCircleOffset, - Type = EpicLoot.BountyPinType, - Name = Localization.instance.Localize("$mod_epicloot_bounties_minimappin", - AdventureDataManager.GetBountyName(bounty)) - }; - - MinimapController.AddPinJobToQueue(new PinJob - { - Task = MinimapPinQueueTask.AddBountyPin, - DebugMode = DebugMode, - BountyPin = new KeyValuePair(bountyID, pinInfo) - }); - - return true; - } } } diff --git a/EpicLoot/src/Adventure/AdventureSpawnWatchdog.cs b/EpicLoot/src/Adventure/AdventureSpawnWatchdog.cs new file mode 100644 index 000000000..493e33ed3 --- /dev/null +++ b/EpicLoot/src/Adventure/AdventureSpawnWatchdog.cs @@ -0,0 +1,347 @@ +using EpicLoot.Biomes; +using JetBrains.Annotations; +using System.Collections.Generic; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace EpicLoot.Adventure +{ + /// + /// Checks, from the buyer's side, that a bounty or treasure map actually appears once the buyer is + /// standing at its map circle, and logs what it can see when it does not. + /// + /// The placement itself runs on whichever machine owns the spawner (see + /// ), which is often not the buyer's. That machine logs its own + /// reason when it is slow, but that line lands in someone else's log - or the server's - while the + /// report comes from the buyer. So the buyer's log says who owns the spawner and what state it is + /// in, which is enough to tell "another machine is stuck placing it" from "nobody is placing it" + /// from "it was placed and is gone". + /// + /// Everything here reads ZDOs the client already holds: the buyer is at the circle, so the server + /// has synced the objects around it. + /// + internal class AdventureSpawnWatchdog : MonoBehaviour + { + private const float PulseSeconds = 5f; + + /// How far outside the drawn circle still counts as having reached it. + private const float ArrivalMargin = 16f; + + private static readonly int BountyIdHash = BountyTargetComponent.BountyIDKey.GetStableHashCode(); + private static readonly int ChestBiomeHash = $"{nameof(TreasureMapChest)}.{nameof(TreasureMapChest.Biome)}".GetStableHashCode(); + private static readonly int ChestIntervalHash = $"{nameof(TreasureMapChest)}.{nameof(TreasureMapChest.Interval)}".GetStableHashCode(); + private static readonly int SpawnPointHash = AdventureSpawnController.SpawnPointKey.GetStableHashCode(); + + private static AdventureSpawnWatchdog _instance; + + private sealed class Watch + { + /// Time.time the player last came within reach of the circle, or -1 while away. + public float ArrivedAt = -1f; + + /// Time.time the spawn was reported missing, or -1 if it has not been. + public float ReportedAt = -1f; + + /// Seen in place; nothing more to check this session. + public bool Resolved; + } + + private struct Snapshot + { + public ZDO Spawner; + public int Creatures; + public bool ChestFound; + + public bool Placed => Creatures > 0 || ChestFound; + } + + private readonly Dictionary _watches = new(); + private readonly HashSet _activeKeys = new(); + private readonly List _staleKeys = new(); + private readonly List _zdoScratch = new(); + private ZNet _trackedNet; + + internal static void Create() + { + if (_instance != null) + { + return; + } + + var go = new GameObject("EL_AdventureSpawnWatchdog"); + Object.DontDestroyOnLoad(go); + go.AddComponent(); + } + + [UsedImplicitly] + private void Awake() + { + _instance = this; + InvokeRepeating(nameof(Pulse), PulseSeconds, PulseSeconds); + } + + [UsedImplicitly] + private void Pulse() + { + // Keys are only unique within a world. + if (ZNet.instance != _trackedNet) + { + _trackedNet = ZNet.instance; + _watches.Clear(); + } + + Player player = Player.m_localPlayer; + if (player == null || ZNet.instance == null || ZDOMan.instance == null || ZoneSystem.instance == null || + ZNetScene.instance == null || !EpicLoot.IsAdventureModeEnabled()) + { + return; + } + + AdventureSaveData saveData = player.GetAdventureSaveData(); + if (saveData == null) + { + return; + } + + long playerID = player.GetPlayerID(); + Vector3 playerPosition = player.transform.position; + float arrivalRadius = MinimapController.AreaRadius + ArrivalMargin; + _activeKeys.Clear(); + + foreach (BountyInfo bounty in saveData.Bounties) + { + if (bounty.State != BountyState.InProgress) + { + continue; + } + + string key = "bounty:" + bounty.ID; + _activeKeys.Add(key); + Vector3 centre = AdventureSpawnController.GetCircleCentre(bounty.Position, bounty.MinimapCircleOffset); + Check(key, centre, playerPosition, arrivalRadius, bounty, null, playerID); + } + + foreach (TreasureMapChestInfo map in saveData.TreasureMaps) + { + if (map.State != TreasureMapState.Purchased) + { + continue; + } + + string key = $"treasure:{map.Interval}:{map.Biome}"; + _activeKeys.Add(key); + Vector3 centre = AdventureSpawnController.GetCircleCentre(map.Position, map.MinimapCircleOffset); + Check(key, centre, playerPosition, arrivalRadius, null, map, playerID); + } + + // Completed, claimed, abandoned and found entries drop out of the lists above. + if (_watches.Count > _activeKeys.Count) + { + _staleKeys.Clear(); + foreach (string key in _watches.Keys) + { + if (!_activeKeys.Contains(key)) + { + _staleKeys.Add(key); + } + } + + foreach (string key in _staleKeys) + { + _watches.Remove(key); + } + } + } + + private void Check(string key, Vector3 centre, Vector3 playerPosition, float arrivalRadius, + BountyInfo bounty, TreasureMapChestInfo map, long playerID) + { + if (!_watches.TryGetValue(key, out Watch watch)) + { + watch = new Watch(); + _watches[key] = watch; + } + + if (watch.Resolved) + { + return; + } + + if (Utils.DistanceXZ(playerPosition, centre) > arrivalRadius) + { + // Leaving restarts the clock. A report already made stands, and is followed up if the + // spawn turns up on a later visit. + watch.ArrivedAt = -1f; + return; + } + + if (watch.ArrivedAt < 0f) + { + watch.ArrivedAt = Time.time; + } + + float waited = Time.time - watch.ArrivedAt; + bool reported = watch.ReportedAt >= 0f; + if (!reported && waited < AdventureSpawnController.OverdueSeconds) + { + return; + } + + Snapshot snapshot = TakeSnapshot(centre, bounty, map, playerID); + if (snapshot.Placed) + { + watch.Resolved = true; + if (reported) + { + EpicLoot.LogForce($"{DescribeSpawn(bounty, map)} is now here, " + + $"{Time.time - watch.ReportedAt:0}s after it was reported missing."); + } + + return; + } + + if (!reported) + { + watch.ReportedAt = Time.time; + EpicLoot.LogWarningForce($"{DescribeSpawn(bounty, map)} has not appeared {waited:0}s after you reached " + + $"its map circle at ({centre.x:0}, {centre.z:0}). {DescribeSpawner(snapshot)}"); + } + } + + /// + /// The spawner for this bounty or map and whatever it has spawned, from the objects around the + /// circle. Two zones out, so creatures that have wandered a little are still counted. + /// + private Snapshot TakeSnapshot(Vector3 centre, BountyInfo bounty, TreasureMapChestInfo map, long playerID) + { + var snapshot = new Snapshot(); + string chestBiome = map?.Biome.ToString(); + + _zdoScratch.Clear(); + ZDOMan.instance.FindSectorObjects(ZoneSystem.GetZone(centre), new SimulationDistance(2, 0), _zdoScratch); + + foreach (ZDO zdo in _zdoScratch) + { + if (zdo.GetPrefab() == AdventureSpawnController.PrefabHash) + { + if (snapshot.Spawner == null && IsSpawnerFor(zdo, bounty, map, playerID)) + { + snapshot.Spawner = zdo; + } + + continue; + } + + if (bounty != null) + { + if (zdo.GetString(BountyIdHash) == bounty.ID) + { + snapshot.Creatures++; + } + } + else if (zdo.GetString(ChestBiomeHash) == chestBiome && + zdo.GetInt(ChestIntervalHash, -1) == map.Interval && + zdo.GetLong(ZDOVars.s_creator) == playerID) + { + snapshot.ChestFound = true; + } + } + + _zdoScratch.Clear(); + return snapshot; + } + + private static bool IsSpawnerFor(ZDO zdo, BountyInfo bounty, TreasureMapChestInfo map, long playerID) + { + if (bounty != null) + { + return AdventureSpawnController.TryReadBounty(zdo, out BountyInfo spawnerBounty) && + spawnerBounty.Target != null && spawnerBounty.ID == bounty.ID; + } + + return AdventureSpawnController.TryReadTreasure(zdo, out TreasureMapChestInfo spawnerMap) && + spawnerMap.PlayerID == playerID && spawnerMap.Interval == map.Interval && spawnerMap.Biome == map.Biome; + } + + private static string DescribeSpawn(BountyInfo bounty, TreasureMapChestInfo map) + { + if (bounty != null) + { + return $"Adventure bounty target '{bounty.Target.MonsterID}' ({BiomeDataManager.GetName(bounty.Biome)}, {bounty.ID})"; + } + + return $"Adventure treasure chest ({BiomeDataManager.GetName(map.Biome)}, interval {map.Interval})"; + } + + private static string DescribeSpawner(Snapshot snapshot) + { + if (snapshot.Spawner == null) + { + return "No spawner for it is near the circle and nothing it spawns is here, so either it placed " + + "earlier and what it spawned has since died or moved away, or the spawner was lost."; + } + + long owner = snapshot.Spawner.GetOwner(); + Vector3 spawnPoint = snapshot.Spawner.GetVec3(SpawnPointHash, AdventureSpawnController.UnsetSpawnPoint); + string progress = spawnPoint != AdventureSpawnController.UnsetSpawnPoint + ? $"has chosen a spot at ({spawnPoint.x:0}, {spawnPoint.z:0})" + : "has not chosen a spot yet"; + + string placer; + if (owner == 0L) + { + placer = "Nobody owns it, so no machine is placing it."; + } + else if (owner == ZDOMan.GetSessionID()) + { + placer = "This machine is placing it; the 'has not been placed' line in this log says what it is waiting on."; + } + else + { + placer = "That machine is placing it, and its log has the reason."; + } + + return $"Its spawner {snapshot.Spawner.m_uid} is here, owned by {DescribeOwner(owner)}, and {progress}. {placer}"; + } + + /// + /// Names the session that owns an object. A client only has the server as a peer, so other + /// players are matched through the player list, whose character ids carry each player's session. + /// + private static string DescribeOwner(long owner) + { + if (owner == 0L) + { + return "nobody"; + } + + if (owner == ZDOMan.GetSessionID()) + { + return "you"; + } + + ZNetPeer serverPeer = ZNet.instance.GetServerPeer(); + if (serverPeer != null && serverPeer.m_uid == owner) + { + return "the server"; + } + + foreach (ZNet.PlayerInfo info in ZNet.instance.GetPlayerList()) + { + if (!info.m_characterID.IsNone() && info.m_characterID.UserID == owner) + { + return $"player '{info.m_name}'"; + } + } + + // A host sees every other player as a peer of its own. + ZNetPeer peer = ZNet.instance.GetPeer(owner); + if (peer != null) + { + return $"player '{peer.m_playerName}'"; + } + + return $"session {owner}, which matches no connected player (disconnected, or dead or respawning)"; + } + } +} diff --git a/EpicLoot/src/Adventure/AdventureWardCheck.cs b/EpicLoot/src/Adventure/AdventureWardCheck.cs index eff889e93..9079c5f22 100644 --- a/EpicLoot/src/Adventure/AdventureWardCheck.cs +++ b/EpicLoot/src/Adventure/AdventureWardCheck.cs @@ -1,7 +1,20 @@ -using UnityEngine; +using UnityEngine; namespace EpicLoot.Adventure { + /// How close an adventure spawn point is to a player's ward, nearest first. + internal enum WardProximity + { + /// No enabled ward within its radius plus the buffer. + Clear, + + /// Within a ward's radius plus the buffer, but outside the area the ward protects. + NearWard, + + /// Inside the area a ward protects. + InsideWard + } + /// /// The single definition of "this adventure spawn point is too close to a player's ward". /// @@ -10,30 +23,44 @@ namespace EpicLoot.Adventure /// it can mean anything. Picking the world point is seed-only and loads nothing, so /// holds no ward from a remote zone and a check there would /// have approved every location regardless. The placement search is the one that runs with the - /// area genuinely loaded, and it expands its search band when a ward vetoes a candidate. + /// area genuinely loaded. It prefers spots clear of wards but never leaves the map circle to find + /// one, so a ward only decides where inside the circle a spawn lands. /// internal static class AdventureWardCheck { /// - /// True when an enabled ward covers once its radius is - /// grown by . The test matters: - /// a deactivated guard stone protects nothing in vanilla, so it must not veto a spawn either. - /// Every vanilla call site pairs the two the same way (PrivateArea.CheckAccess, OnObjectDamaged, - /// GetNearbyAreas). + /// How close is to an enabled ward, where "near" means inside + /// the ward's radius grown by . The + /// test matters: a deactivated guard stone protects nothing in vanilla, so it must not steer a + /// spawn either. Every vanilla call site pairs the two the same way (PrivateArea.CheckAccess, + /// OnObjectDamaged, GetNearbyAreas). is the ward responsible, or null. /// - internal static bool TryFindNearbyWard(Vector3 location, float buffer, out PrivateArea ward) + internal static WardProximity GetWardProximity(Vector3 location, float buffer, out PrivateArea ward) { + ward = null; + WardProximity proximity = WardProximity.Clear; + foreach (PrivateArea privateArea in PrivateArea.m_allAreas) { - if (privateArea != null && privateArea.IsEnabled() && privateArea.IsInside(location, buffer)) + if (privateArea == null || !privateArea.IsEnabled()) + { + continue; + } + + if (privateArea.IsInside(location, 0f)) { ward = privateArea; - return true; + return WardProximity.InsideWard; + } + + if (proximity == WardProximity.Clear && privateArea.IsInside(location, buffer)) + { + ward = privateArea; + proximity = WardProximity.NearWard; } } - ward = null; - return false; + return proximity; } /// diff --git a/EpicLoot/src/Adventure/Minimap/MinimapController.cs b/EpicLoot/src/Adventure/Minimap/MinimapController.cs index 594c5c775..d96dfa441 100644 --- a/EpicLoot/src/Adventure/Minimap/MinimapController.cs +++ b/EpicLoot/src/Adventure/Minimap/MinimapController.cs @@ -16,6 +16,18 @@ public class MinimapController : MonoBehaviour public const float AreaScale = 2.1f; + /// + /// The adventure circle's diameter in metres - Minimap.PinData.m_worldSize is a diameter, vanilla + /// sets it to an event's range * 2. + /// + public static float AreaWorldSize => Mathf.Max(0f, AdventureDataManager.Config.TreasureMap.MinimapAreaRadius) * AreaScale; + + /// + /// The radius of the circle drawn for a bounty or a treasure map. AdventureSpawnController places + /// inside it, so the map and the spawn cannot disagree about where the circle is. + /// + public static float AreaRadius => AreaWorldSize * 0.5f; + public static readonly Dictionary, AreaPinInfo> TreasureMapPins = new(); public static readonly Dictionary BountyPins = new(); public static bool DebugMode; @@ -343,7 +355,7 @@ private void AddPin(PinJob pinJob) //Add Area Pin newPin.Area = _minimap.AddPin(newPin.Position, Minimap.PinType.EventArea, string.Empty, false, false); - newPin.Area.m_worldSize = AdventureDataManager.Config.TreasureMap.MinimapAreaRadius * AreaScale; + newPin.Area.m_worldSize = AreaWorldSize; //Add Pin newPin.Pin = _minimap.AddPin(newPin.Position, newPin.Type, newPin.Name, false, false); diff --git a/EpicLoot/src/Adventure/WorldBiomeIndex.cs b/EpicLoot/src/Adventure/WorldBiomeIndex.cs index 817bee427..745d9b57b 100644 --- a/EpicLoot/src/Adventure/WorldBiomeIndex.cs +++ b/EpicLoot/src/Adventure/WorldBiomeIndex.cs @@ -503,8 +503,8 @@ internal static string Describe() /// /// Validation is seed-only on purpose: nothing here loads a zone, so nothing here can see /// terrain colliders, wards or player bases. AdventureSpawnController.DeterminespawnPoint - /// does that check with an expanding band search once the player is actually near the point, - /// and remains the authority on where a bounty or chest finally lands. + /// does that check once someone is actually near the point, and decides where inside the map + /// circle centred here a bounty or chest finally lands. /// internal static bool TryFindPoint(Heightmap.Biome biome, float minRadius, float maxRadius, bool requireBand, out Vector3 point, out int candidatesTried) @@ -609,8 +609,8 @@ internal static bool TryFindPoint(Heightmap.Biome biome, float minRadius, float } /// - /// Area-uniform sample of the annulus, so points do not bunch against its inner edge. Same - /// formula AdventureSpawnController uses for its search bands. + /// Area-uniform sample of the annulus, so points do not bunch against its inner edge. The + /// same idea AdventureSpawnController uses to sample the map circle. /// private static Vector2 SampleAnchor(float minRadius, float maxRadius) { @@ -823,9 +823,9 @@ private static bool TryCandidate(Heightmap.Biome biome, Vector2 candidate, /// the 64m zone, and Heightmap.GetBiome returns the nearest-corner-weighted winner among /// those four -- short-circuiting to one answer for the whole zone when they agree. So a /// point can sit well inside a swamp and still be reported as meadows by the GetGroundData - /// check AdventureSpawnController runs before it places anything, which would send the - /// placement search off expanding bands for no reason. Four extra GetBiome calls make it a - /// guarantee instead. + /// check AdventureSpawnController runs before it places anything, which would reject every + /// spot in the circle and leave the spawn on a fallback for no reason. Four extra GetBiome + /// calls make it a guarantee instead. /// private static bool ZoneAgreesOnBiome(WorldGenerator worldGenerator, Vector2 point, Heightmap.Biome biome) diff --git a/EpicLoot/src/Adventure/bounties/AdventureSpawnController.cs b/EpicLoot/src/Adventure/bounties/AdventureSpawnController.cs index 2048b63ab..5e1d83e4b 100644 --- a/EpicLoot/src/Adventure/bounties/AdventureSpawnController.cs +++ b/EpicLoot/src/Adventure/bounties/AdventureSpawnController.cs @@ -1,7 +1,10 @@ -using EpicLoot.Biomes; +using EpicLoot.Biomes; using EpicLoot.Data; +using System; using System.Collections; using System.Collections.Generic; +using System.IO; +using System.Runtime.Serialization.Formatters.Binary; using UnityEngine; namespace EpicLoot.Adventure @@ -23,8 +26,40 @@ internal enum WaterPlacement Surface } + /// + /// The persistent object a bounty or treasure map leaves at its map circle. It places the bounty + /// targets or the chest once someone is close enough for the area to load, then destroys itself. + /// + /// Placement runs on whichever machine owns this object, and that is decided by vanilla, not by + /// us: every couple of seconds the server hands a persistent object to any player within about a + /// zone of it, unless its current owner is that close too. So the machine placing a bounty may be + /// another player who got there first, or the dedicated server itself near the world centre. + /// Nothing about placement may therefore depend on being the buyer - which is why the spawn never + /// leaves the map circle. The circle lives in the buyer's save data, and no other machine can + /// move it. + /// internal class AdventureSpawnController : MonoBehaviour { + internal const string PrefabName = "EL_SpawnController"; + internal static readonly int PrefabHash = PrefabName.GetStableHashCode(); + + // ZDO keys. AdventureSpawnWatchdog reads them off spawners it does not own. + internal const string BountyKey = "bount_spawn"; + internal const string TreasureKey = "treasure_spawn"; + internal const string PlacedKey = "placed"; + internal const string SpawnPointKey = "spawnPoint"; + private const string IsBountyKey = "isBounty"; + private const string SearchingKey = "searchingForSpawn"; + + /// The spawn point a spawner holds before one has been chosen. + internal static readonly Vector3 UnsetSpawnPoint = new(1, 1, 1); + + /// + /// Seconds a spawner may spend owned by this machine without placing before it logs what it is + /// waiting on. The buyer-side watchdog uses the same threshold. + /// + internal const float OverdueSeconds = 30f; + protected ZNetView zNetView; private BountyInfoZNetProperty bounty { get; set; } private TreasureMapChestInfoZNetProperty treasure { get; set; } @@ -34,13 +69,6 @@ internal class AdventureSpawnController : MonoBehaviour private BoolZNetProperty isBounty { get; set; } - /// - /// Seconds to wait before re-attempting a search that found nowhere to spawn. A blocked search - /// usually stays blocked until a ward comes down, but a retry is cheap now that the search - /// itself takes frames rather than seconds. - /// - private const float RetrySearchDelaySeconds = 10f; - /// /// Seconds between lookups of a creature prefab ZNetScene does not have. Only re-installing the /// mod that adds it can fix that, so there is no point asking often. @@ -50,10 +78,15 @@ internal class AdventureSpawnController : MonoBehaviour /// How long the search waits for a world biome index that is still building. private const float IndexWaitTimeoutSeconds = 10f; - /// How often the search re-checks whether the area around the start point has loaded. + /// How often the search re-checks whether the area around the circle has loaded. private const float AreaPollSeconds = 0.25f; - private const int SpawnAttemptsPerBand = 100; + /// + /// Spots sampled inside the circle before settling for the best one seen. The search stops at + /// the first spot that meets every rule, so this budget is only spent when something - usually + /// a ward - rules out part of the circle. + /// + private const int MaxCandidates = 200; /// /// Candidates evaluated before yielding a frame. Each one is two raycasts and a walk over the @@ -61,26 +94,104 @@ internal class AdventureSpawnController : MonoBehaviour /// private const int CandidatesPerFrame = 10; + /// + /// The share of the drawn circle's radius that spots are sampled from. The rest is margin, so + /// minions placed around the target and a creature that has taken a step or two still read as + /// inside the circle. + /// + private const float SearchRadiusFraction = 0.8f; + + /// + /// The furthest a minion is placed from its bounty target, before it is clamped to the margin + /// leaves inside the circle. + /// + private const float MaxMinionSpread = 4f; + + /// How closely a placement met the rules, best first. Every tier is inside the circle. + private enum PlacementTier + { + /// Every rule met, including staying a full buffer clear of wards. + Clear, + + /// Every terrain rule met, inside a ward's buffer but outside the area it protects. + NearWard, + + /// Every terrain rule met, inside the area a ward protects. + InsideWard, + + /// No spot met the terrain rules; the least-bad spot seen, or the circle's centre. + Fallback + } + + /// The first terrain rule a candidate failed, counted for the overdue and placement logs. + private enum Rejection + { + ZoneNotLoaded, + WrongBiome, + NoFloor, + Obstructed, + Underwater, + Lava, + Count + } + + /// What the placement on this machine is currently doing, for the overdue log. + private enum PlacementStage + { + NotStarted, + WaitingForIndex, + WaitingForArea, + Searching, + Spawning + } + + private struct Candidate + { + public Vector3 Position; + + /// Met every terrain rule, so it can be placed at one of the ward tiers. + public bool Usable; + + /// The ward tier, when . + public PlacementTier Tier; + + public PrivateArea Ward; + + /// How badly it broke the terrain rules; lower is better, and MaxValue cannot be used. + public int Penalty; + } + /// Set once a missing creature prefab has been reported, so it is logged one time. private bool reportedMissingPrefab = false; + private string missingPrefab; - /// Set once an exhausted search has been reported at Force level for this instance. - private bool reportedExhausted = false; - - /// Time.time before which Update will not start (or restart) a search. + /// Time.time before which Update will not start (or restart) a spawn. private float nextSearchTime = 0f; private bool startedPlacement = false; - // Timing for the placement log line, measured from when this client started searching. + // Overdue tracking. Only time this machine actually owns the spawner counts: ownership can move + // away and back while the player walks the edge of the area, and neither resetting the clock on + // each move (it would never reach the threshold) nor counting the time away (it would report a + // search that was never running here) tells the truth. + private float ownedSeconds; + private bool reportedOverdue; + private PlacementStage stage = PlacementStage.NotStarted; + + // Timing and outcome for the placement log line, measured from when this machine started searching. private float searchStartTime; private float areaReadyTime; - private int candidatesRejected; - private int placedBand; + private Vector3 searchCentre; + private int candidatesTried; + private readonly int[] rejections = new int[(int)Rejection.Count]; + private int nearWardCandidates; + private int insideWardCandidates; + private PlacementTier placedTier; + private PrivateArea placedWard; + private float placedDistance; private static readonly List ZoneObjectsScratch = new(); private readonly Dictionary zoneReadyCache = new(); - private Vector3 defaultSpawn = new(1, 1, 1); private BountyInfo defaultBounty = new(); private TreasureMapChestInfo defaultTreasure = new(); @@ -97,12 +208,12 @@ public void Awake() if ((bool)zNetView) { - bounty = new BountyInfoZNetProperty("bount_spawn", zNetView, defaultBounty); - treasure = new TreasureMapChestInfoZNetProperty("treasure_spawn", zNetView, defaultTreasure); - isBounty = new BoolZNetProperty("isBounty", zNetView, false); - placed = new BoolZNetProperty("placed", zNetView, false); - searchingForSpawn = new BoolZNetProperty("searchingForSpawn", zNetView, false); - spawnPoint = new Vector3ZNetProperty("spawnPoint", zNetView, defaultSpawn); + bounty = new BountyInfoZNetProperty(BountyKey, zNetView, defaultBounty); + treasure = new TreasureMapChestInfoZNetProperty(TreasureKey, zNetView, defaultTreasure); + isBounty = new BoolZNetProperty(IsBountyKey, zNetView, false); + placed = new BoolZNetProperty(PlacedKey, zNetView, false); + searchingForSpawn = new BoolZNetProperty(SearchingKey, zNetView, false); + spawnPoint = new Vector3ZNetProperty(SpawnPointKey, zNetView, UnsetSpawnPoint); } } @@ -123,6 +234,8 @@ public void Update() return; } + CheckOverdue(); + if (Time.time < nextSearchTime) { return; @@ -133,24 +246,29 @@ public void Update() EpicLoot.Log("Starting search for valid spawn location..."); searchingForSpawn.Set(true); startedPlacement = true; - if (bounty.Get().PlayerID != 0) + + BountyInfo bountyInfo = bounty.Get(); + if (bountyInfo.PlayerID != 0) { - StartCoroutine(DeterminespawnPoint(bounty.Get().Position, bounty.Get().Biome, - WaterPlacement.Surface)); + StartCoroutine(DeterminespawnPoint(GetCircleCentre(bountyInfo.Position, bountyInfo.MinimapCircleOffset), + bountyInfo.Biome, WaterPlacement.Surface)); } - if (treasure.Get().PlayerID != 0) + TreasureMapChestInfo treasureInfo = treasure.Get(); + if (treasureInfo.PlayerID != 0) { - StartCoroutine(DeterminespawnPoint(treasure.Get().Position, treasure.Get().Biome, - WaterPlacement.Seabed)); + StartCoroutine(DeterminespawnPoint(GetCircleCentre(treasureInfo.Position, treasureInfo.MinimapCircleOffset), + treasureInfo.Biome, WaterPlacement.Seabed)); } } - if (searchingForSpawn.Get() == true && spawnPoint.Get() == defaultSpawn) + if (searchingForSpawn.Get() == true && spawnPoint.Get() == UnsetSpawnPoint) { return; } + stage = PlacementStage.Spawning; + if (isBounty.Get() == true) { SpawnBountyTargets(bounty.Get()); @@ -181,6 +299,16 @@ public void SetTreasure(TreasureMapChestInfo treasureInfo) treasure.ForceSet(treasureInfo); } + /// + /// The centre of the circle the buyer's map draws: MinimapController and AdventureSaveData both + /// add the offset, and a spawn searched around the bare position would drift out of a circle + /// that carries one (older saves do). + /// + internal static Vector3 GetCircleCentre(Vector3 position, Vector3 minimapCircleOffset) + { + return position + minimapCircleOffset; + } + private void SpawnBountyTargets(BountyInfo bounty) { Vector3 point = spawnPoint.Get(); @@ -211,17 +339,36 @@ private void SpawnBountyTargets(BountyInfo bounty) // biomes count as open water is measured from the world, so a custom ocean-like biome // gets swimmers too rather than a pile of drowned creatures on the seabed. bool swimmingTargets = WorldBiomeIndex.IsOpenWater(bounty.Biome); - float baseHeight = point.y; + + // Minions go around the target rather than in a chain from one to the next, which drifted + // a few metres per minion; the spread is held to the margin the search left inside the circle. + float circleRadius = MinimapController.AreaRadius; + float minionSpread = Mathf.Clamp(circleRadius * (1f - SearchRadiusFraction), 0f, MaxMinionSpread); for (var index = 0; index < prefabs.Count; index++) { var prefab = prefabs[index]; var isAdd = index > 0; + Vector3 spawnAt = point; + if (isAdd && minionSpread > 0f) + { + Vector2 offset = UnityEngine.Random.insideUnitCircle * minionSpread; + spawnAt.x += offset.x; + spawnAt.z += offset.y; + + // Cast from a little above the target so a minion on a slope finds ground that is + // higher than the target's. FindFloor reports 0 when its ray hits nothing at all, so a + // miss keeps the target's height instead of dropping the minion to y=0. + if (!swimmingTargets && ZoneSystem.instance.FindFloor(spawnAt + Vector3.up * 2f, out float floorHeight)) + { + spawnAt.y = floorHeight; + } + } + // Character.UpdateSwimming holds a swimming creature at (water line - m_swimDepth), // so starting it there means it is already buoyant rather than dropping in from // above the surface. - Vector3 spawnAt = point; if (swimmingTargets && prefab.TryGetComponent(out Character prefabCharacter)) { spawnAt.y = ZoneSystem.instance.m_waterLevel - prefabCharacter.m_swimDepth; @@ -230,20 +377,6 @@ private void SpawnBountyTargets(BountyInfo bounty) var creature = UnityEngine.Object.Instantiate(prefab, spawnAt, Quaternion.identity); var bountyTarget = creature.AddComponent(); bountyTarget.Initialize(bounty, prefab.name, isAdd); - - var randomSpacing = UnityEngine.Random.insideUnitSphere * 4f; - point += randomSpacing; - - // FindFloor reports 0 when its ray hits nothing at all, and the old code assigned - // that unconditionally -- a miss teleported the next add down to y=0. - if (!swimmingTargets && ZoneSystem.instance.FindFloor(point, out var floorHeight)) - { - point.y = floorHeight; - } - else - { - point.y = baseHeight; - } } LogPlacement($"bounty target '{bounty.Target.MonsterID}'", bounty.Biome); @@ -281,7 +414,7 @@ private void SpawnChest(TreasureMapChestInfo treasure) /// /// One always-visible line per placement. Every other line on this path is gated, which is why /// "my bounty never spawned" reports used to arrive with logs that said nothing at all. When - /// the spawn point was chosen by another client (or before this instance was loaded), there is + /// the spawn point was chosen by another machine (or before this instance was loaded), there is /// no local search to time and only the fact of placement is reported. /// private void LogPlacement(string what, Heightmap.Biome biome) @@ -294,20 +427,114 @@ private void LogPlacement(string what, Heightmap.Biome biome) } EpicLoot.LogForce($"Adventure {what} ({biomeName}) placed after {Time.time - searchStartTime:0.0}s: " + - $"area wait {areaReadyTime - searchStartTime:0.0}s, {candidatesRejected} spots rejected, " + - $"ring {placedBand}."); + $"area wait {areaReadyTime - searchStartTime:0.0}s, {candidatesTried} spots tried " + + $"({DescribeRejections()}), {placedDistance:0}m from the circle's centre, {DescribeTier()}."); + } + + private string DescribeTier() + { + string ward = AdventureWardCheck.DescribeWard(placedWard); + switch (placedTier) + { + case PlacementTier.Clear: + return "clear of wards"; + case PlacementTier.NearWard: + return $"near a ward but outside its protected area, as nothing clear of wards was available (ward {ward})"; + case PlacementTier.InsideWard: + return $"inside a ward's protected area, as nothing outside one was available (ward {ward})"; + default: + return "on the least-bad spot in the circle, as none met the placement rules"; + } + } + + private string DescribeRejections() + { + var parts = new List(); + void Add(int count, string label) + { + if (count > 0) + { + parts.Add($"{count} {label}"); + } + } + + Add(rejections[(int)Rejection.ZoneNotLoaded], "in unloaded zones"); + Add(rejections[(int)Rejection.WrongBiome], "outside the biome"); + Add(rejections[(int)Rejection.NoFloor], "with no floor"); + Add(rejections[(int)Rejection.Obstructed], "obstructed"); + Add(rejections[(int)Rejection.Underwater], "underwater"); + Add(rejections[(int)Rejection.Lava], "on lava"); + Add(nearWardCandidates, "near a ward"); + Add(insideWardCandidates, "inside a ward"); + return parts.Count == 0 ? "none rejected" : string.Join(", ", parts); + } + + /// + /// Logs, once, why a spawner this machine has owned for has still + /// not placed. Always visible: this is the line that says which step a stuck bounty is on, and + /// the machine running it is often not the buyer's. + /// + private void CheckOverdue() + { + ownedSeconds += Time.deltaTime; + if (reportedOverdue || ownedSeconds < OverdueSeconds) + { + return; + } + + reportedOverdue = true; + string machine = ZNet.instance.IsDedicated() ? "dedicated server" : ZNet.instance.IsServer() ? "host" : "client"; + EpicLoot.LogWarningForce($"{DescribeSpawn()} has not been placed after this {machine} owned its spawner " + + $"for {ownedSeconds:0}s: {DescribeStage()}."); + } + + private string DescribeSpawn() + { + if (isBounty.Get()) + { + BountyInfo bountyInfo = bounty.Get(); + return $"Adventure bounty target '{bountyInfo.Target.MonsterID}' " + + $"({BiomeDataManager.GetName(bountyInfo.Biome)}, {bountyInfo.ID})"; + } + + TreasureMapChestInfo treasureInfo = treasure.Get(); + return $"Adventure treasure chest ({BiomeDataManager.GetName(treasureInfo.Biome)}, interval {treasureInfo.Interval})"; + } + + private string DescribeStage() + { + switch (stage) + { + case PlacementStage.WaitingForIndex: + return $"waiting for the world biome index (state {WorldBiomeIndex.State})"; + case PlacementStage.WaitingForArea: + return "waiting for the area around the map circle to load - " + DescribeUnsettledArea(searchCentre); + case PlacementStage.Searching: + return $"still searching the circle, {candidatesTried} spots tried ({DescribeRejections()})"; + case PlacementStage.Spawning: + return missingPrefab != null + ? $"a spot was chosen, but the creature prefab '{missingPrefab}' does not exist" + : "a spot was chosen, but the spawn has not completed"; + default: + return "the search has not started"; + } } - internal IEnumerator DeterminespawnPoint(Vector3 startingSpawnPoint, + internal IEnumerator DeterminespawnPoint(Vector3 circleCentre, Heightmap.Biome biome, WaterPlacement waterPlacement = WaterPlacement.Reject) { searchStartTime = Time.time; - candidatesRejected = 0; + searchCentre = circleCentre; + candidatesTried = 0; + Array.Clear(rejections, 0, rejections.Length); + nearWardCandidates = 0; + insideWardCandidates = 0; zoneReadyCache.Clear(); // The owner of this spawner is not necessarily the player who bought it, so the biome // index may never have been built on this client. A build takes a fraction of a second; // IsOpenWater falls back safely if it is somehow still not ready when the wait gives up. + stage = PlacementStage.WaitingForIndex; WorldBiomeIndex.EnsureBuilt(); float indexDeadline = Time.unscaledTime + IndexWaitTimeoutSeconds; @@ -324,7 +551,8 @@ internal IEnumerator DeterminespawnPoint(Vector3 startingSpawnPoint, // This used to be a fixed 300 frames plus five seconds, then vanilla's IsAreaReady polled // once a second -- 10-15s of nothing after arriving, before the search had even begun. // IsAreaSettled waits exactly as long as loading actually takes. - while (!IsAreaSettled(startingSpawnPoint)) + stage = PlacementStage.WaitingForArea; + while (!IsAreaSettled(circleCentre)) { yield return new WaitForSeconds(AreaPollSeconds); if (!StillOwner()) @@ -344,176 +572,99 @@ internal IEnumerator DeterminespawnPoint(Vector3 startingSpawnPoint, areaReadyTime = Time.time; zoneReadyCache.Clear(); + stage = PlacementStage.Searching; - // TODO: If bounties get their own minimap area radius config this must choose the correct one - float radius = AdventureDataManager.Config.TreasureMap.MinimapAreaRadius; + float searchRadius = MinimapController.AreaRadius * SearchRadiusFraction; + // Tier 1's buffer. A ward vetoes every spot within its own radius plus this much. + float wardBuffer = Mathf.Max(0f, AdventureDataManager.Config.TreasureMap.MinimapAreaRadius); float waterSurface = ZoneSystem.instance.m_waterLevel; // An open-water biome sits below the water line everywhere -- Ocean's biome cutoff is - // roughly 25m under it. Rejecting submerged points there rejected every candidate in - // every band, which is why no ocean bounty ever placed. Asking the biome index rather - // than testing for Ocean by name extends that fix to any ocean-like biome another mod - // adds, which would otherwise hit exactly the same dead end. + // roughly 25m under it. Rejecting submerged points there rejected every candidate, which is + // why no ocean bounty ever placed. Asking the biome index rather than testing for Ocean by + // name extends that fix to any ocean-like biome another mod adds. bool spawnInOpenWater = WorldBiomeIndex.IsOpenWater(biome) && waterPlacement != WaterPlacement.Reject; - int maxExpansions = Mathf.Max(0, AdventureDataManager.Config.TreasureMap.MaxSpawnSearchExpansions); - Vector3 determinedSpawn = startingSpawnPoint; - bool foundSpawn = false; - PrivateArea blockingWard = null; + + bool haveUsable = false; + Candidate best = default; + bool haveFallback = false; + Candidate fallback = default; int candidatesThisFrame = 0; - // Band 0 is the original search disc. Every band after it is an annulus one MinimapAreaRadius - // further out - the smallest step that can escape a ward, since a ward vetoes everything - // within its own radius + MinimapAreaRadius. - for (int band = 0; band <= maxExpansions && !foundSpawn; band++) + // Sample the circle, keeping the best spot by tier and stopping at the first that meets every + // rule. Nothing here ever looks outside the circle: see the class summary for why. + for (int attempt = 0; attempt < MaxCandidates; attempt++) { - float innerRadius = band == 0 ? 0f : radius * 0.8f + (band - 1) * radius; - float outerRadius = band == 0 ? radius * 0.8f : radius * 0.8f + band * radius; - - int spawnLocationAttempts = 0; - - // Attempt to find a spawn point, valid height must be selected - while (spawnLocationAttempts < SpawnAttemptsPerBand) + // Spread the work over frames. The search used to sleep a whole second after every ten + // rejected candidates, so a cluttered or partly flooded area cost many seconds. + if (++candidatesThisFrame >= CandidatesPerFrame) { - // Area-uniform sample of the ring, so points do not bunch up against its inner edge. - // For band 0 this is identical to the old Random.insideUnitCircle * (radius * 0.8f). - float sampleRadius = Mathf.Sqrt(Mathf.Lerp(innerRadius * innerRadius, - outerRadius * outerRadius, UnityEngine.Random.value)); - float sampleAngle = UnityEngine.Random.Range(0f, Mathf.PI * 2f); - determinedSpawn = startingSpawnPoint + new Vector3( - Mathf.Cos(sampleAngle) * sampleRadius, 0, Mathf.Sin(sampleAngle) * sampleRadius); - - // Spread the work over frames. This used to sleep a whole second after every ten - // rejected candidates, so a cluttered or partly flooded area cost up to 10s per - // band -- a minute across every band -- before anything could appear. - if (++candidatesThisFrame >= CandidatesPerFrame) - { - candidatesThisFrame = 0; - yield return null; - if (!StillOwner()) - { - AbandonSearch(); - yield break; - } - } - - // A candidate whose zone has not finished creating its objects would pass the - // floor test straight through a rock that simply does not exist yet. - if (!IsCandidateZoneReady(ZoneSystem.GetZone(determinedSpawn))) - { - spawnLocationAttempts += 1; - continue; - } - - ZoneSystem.instance.GetGroundData( - ref determinedSpawn, out var normal, out var foundBiome, out var biomeArea, out var hmap); - - if (hmap == null || foundBiome != biome) + candidatesThisFrame = 0; + yield return null; + if (!StillOwner()) { - spawnLocationAttempts += 1; - continue; - } - - float terrainHeight = determinedSpawn.y; - float solidHeight = StartingHeight; - - if (ZoneSystem.instance.FindFloor(new Vector3(determinedSpawn.x, determinedSpawn.y + 100f, determinedSpawn.z), out solidHeight)) - { - float terrainDiff = solidHeight - terrainHeight; - - // Prevent spawns in objects and too high off the ground - if (terrainDiff > 0.5f) - { - spawnLocationAttempts += 1; - continue; - } - - if (terrainDiff > 0f) - { - determinedSpawn.y = solidHeight; - } - } - else - { - spawnLocationAttempts += 1; - continue; + AbandonSearch(); + yield break; } + } - // Prevents spawning in a body of water. Open-water spawns are exempt: the - // seabed is the ground there, and a surface spawn is lifted to the water line - // once a point is settled on. - if (!spawnInOpenWater && determinedSpawn.y < waterSurface - 1f) - { - spawnLocationAttempts += 1; - continue; - } + // Area-uniform sample of the disc, so points do not bunch up at its centre. + float sampleRadius = Mathf.Sqrt(UnityEngine.Random.value) * searchRadius; + float sampleAngle = UnityEngine.Random.Range(0f, Mathf.PI * 2f); + Vector3 sample = circleCentre + new Vector3( + Mathf.Cos(sampleAngle) * sampleRadius, 0, Mathf.Sin(sampleAngle) * sampleRadius); - // Prevent spawning in Lava unless a last resort. The AshLands gate stays: the - // vegetation mask is a shared channel with a different meaning per biome, and - // vanilla's own Heightmap.IsLava checks for AshLands before reading it, so lava - // is an AshLands-only concept to the engine rather than a trait a custom biome - // could carry. - if (biome == Heightmap.Biome.AshLands && - hmap.GetVegetationMask(determinedSpawn) > 0.45f) + Candidate candidate = EvaluateCandidate(sample, biome, spawnInOpenWater, waterSurface, wardBuffer); + if (candidate.Usable) + { + if (!haveUsable || candidate.Tier < best.Tier) { - spawnLocationAttempts += 1; - continue; + best = candidate; + haveUsable = true; } - // Keep the spawn out of player bases. Unlike the check made when the world point was - // first picked, the wards around here are actually loaded by now. - if (AdventureWardCheck.TryFindNearbyWard(determinedSpawn, radius, out PrivateArea ward)) + if (candidate.Tier == PlacementTier.Clear) { - blockingWard = ward; - spawnLocationAttempts += 1; - continue; + break; } - - foundSpawn = true; - placedBand = band; - break; } - - // Only failures increment the attempt counter, so it is exactly this band's rejections. - candidatesRejected += spawnLocationAttempts; - - if (!foundSpawn && band < maxExpansions) + else if (!haveUsable && (!haveFallback || candidate.Penalty < fallback.Penalty)) { - EpicLoot.LogWarning( - $"No valid adventure spawn point in search band {band} " + - $"({innerRadius:0.##}-{outerRadius:0.##}m); expanding. " + - $"Start=({startingSpawnPoint.x:0.##}, {startingSpawnPoint.y:0.##}, {startingSpawnPoint.z:0.##}), " + - $"Biome={biome}, Ward={AdventureWardCheck.DescribeWard(blockingWard)}"); + fallback = candidate; + haveFallback = true; } } - if (!foundSpawn) + Candidate chosen; + if (haveUsable) { - string message = - "Could not find a valid adventure spawn point after exhausting every search band. " + - $"Start=({startingSpawnPoint.x:0.##}, {startingSpawnPoint.y:0.##}, {startingSpawnPoint.z:0.##}), " + - $"Biome={biome}, SearchRadius={radius:0.##}, Expansions={maxExpansions}, " + - $"Ward={AdventureWardCheck.DescribeWard(blockingWard)}. " + - "Leaving the spawner in place to retry - it is not safe to discard a bounty or " + - "treasure map the player has already paid for."; - - // This is the one outcome that really does leave a paid-for spawn missing, so the first - // report must reach a default-level log. Later retries stay gated: a player waiting in - // the area would otherwise get this line every RetrySearchDelaySeconds. - if (!reportedExhausted) + chosen = best; + } + else + { + // Nothing in the circle met the terrain rules. The centre is always a candidate here: + // its zone is the one IsAreaSettled waited on, so this cannot come up empty, and a + // centre that does meet the rules beats a sampled spot that does not. + Candidate centre = EvaluateCandidate(circleCentre, biome, spawnInOpenWater, waterSurface, wardBuffer); + if (centre.Usable || !haveFallback || centre.Penalty <= fallback.Penalty) { - reportedExhausted = true; - EpicLoot.LogWarningForce(message); + chosen = centre; } else { - EpicLoot.LogWarning(message); + chosen = fallback; } - ParkAndRetry(); - yield break; + if (!chosen.Usable) + { + chosen.Tier = PlacementTier.Fallback; + chosen.Ward = null; + } } + Vector3 determinedSpawn = chosen.Position; + // Bounty targets that belong in the Ocean are swimming creatures, so put them at the // surface rather than on the seabed tens of metres below it. if (spawnInOpenWater && waterPlacement == WaterPlacement.Surface) @@ -526,34 +677,117 @@ internal IEnumerator DeterminespawnPoint(Vector3 startingSpawnPoint, determinedSpawn.y = 400f; } - // A point from an outer band no longer sits under the map marker, so the marker has to - // follow it. Pins live in per-player local save data, so only the player who bought this - // spawn can move theirs - anyone else parks and leaves it for the owner. - if (RequiresPinRelocation(startingSpawnPoint, determinedSpawn) && - !TryRelocateOwnerPin(determinedSpawn)) - { - EpicLoot.Log("Found an adventure spawn point outside the map circle, but the local " + - "player does not own this spawn; leaving it for the owner to place."); - ParkAndRetry(); - yield break; - } + placedTier = chosen.Tier; + placedWard = chosen.Ward; + placedDistance = Utils.DistanceXZ(circleCentre, determinedSpawn); EpicLoot.Log($"Selected Spawn point X {determinedSpawn.x}, Y {determinedSpawn.y}, Z {determinedSpawn.z}"); + stage = PlacementStage.Spawning; spawnPoint.ForceSet(determinedSpawn); - yield break; } /// - /// Stands the spawner back down without placing anything. Deliberately leaves both - /// placed and spawnPoint untouched: Update's searchingForSpawn gate then keeps - /// the component idle, the persistent ZDO survives, and the search runs again on the next - /// visit (or after the retry delay for a player who stays in the zone). Marking it placed - /// here would destroy a bounty or treasure map the player has already paid for. + /// Grounds and grades it: with a ward tier + /// when it meets every terrain rule, otherwise a penalty for choosing a fallback. A spot in a zone + /// that has not loaded cannot be used at all - it would pass the floor test straight through a + /// rock that does not exist yet. /// - private void ParkAndRetry() + private Candidate EvaluateCandidate(Vector3 sample, Heightmap.Biome biome, bool spawnInOpenWater, + float waterSurface, float wardBuffer) { - startedPlacement = false; - nextSearchTime = Time.time + RetrySearchDelaySeconds; + candidatesTried += 1; + var candidate = new Candidate { Position = sample, Penalty = int.MaxValue }; + + if (!IsCandidateZoneReady(ZoneSystem.GetZone(sample))) + { + rejections[(int)Rejection.ZoneNotLoaded]++; + return candidate; + } + + ZoneSystem.instance.GetGroundData(ref sample, out _, out Heightmap.Biome foundBiome, out _, out Heightmap hmap); + if (hmap == null) + { + rejections[(int)Rejection.ZoneNotLoaded]++; + return candidate; + } + + candidate.Position = sample; + float terrainHeight = sample.y; + Rejection? firstFailure = null; + int penalty = 0; + + void Fail(Rejection reason, int cost) + { + firstFailure ??= reason; + penalty += cost; + } + + if (foundBiome != biome) + { + Fail(Rejection.WrongBiome, 2); + } + + if (ZoneSystem.instance.FindFloor(new Vector3(sample.x, terrainHeight + 100f, sample.z), out float solidHeight)) + { + float terrainDiff = solidHeight - terrainHeight; + + // Something solid more than half a metre above the ground: a rock, a tree, a building. + if (terrainDiff > 0.5f) + { + Fail(Rejection.Obstructed, 4); + } + else if (terrainDiff > 0f) + { + candidate.Position.y = solidHeight; + } + } + else + { + Fail(Rejection.NoFloor, 4); + } + + // Prevents spawning in a body of water. Open-water spawns are exempt: the seabed is the + // ground there, and a surface spawn is lifted to the water line once a point is chosen. + if (!spawnInOpenWater && candidate.Position.y < waterSurface - 1f) + { + Fail(Rejection.Underwater, 3); + } + + // The AshLands gate stays: the vegetation mask is a shared channel with a different meaning + // per biome, and vanilla's own Heightmap.IsLava checks for AshLands before reading it, so lava + // is an AshLands-only concept to the engine rather than a trait a custom biome could carry. + if (biome == Heightmap.Biome.AshLands && hmap.GetVegetationMask(candidate.Position) > 0.45f) + { + Fail(Rejection.Lava, 8); + } + + if (firstFailure.HasValue) + { + rejections[(int)firstFailure.Value]++; + candidate.Penalty = penalty; + return candidate; + } + + // Keep the spawn out of player bases where the circle allows it. The wards around here are + // actually loaded by now, unlike when the world point was first picked. + candidate.Usable = true; + candidate.Penalty = 0; + switch (AdventureWardCheck.GetWardProximity(candidate.Position, wardBuffer, out candidate.Ward)) + { + case WardProximity.InsideWard: + candidate.Tier = PlacementTier.InsideWard; + insideWardCandidates++; + break; + case WardProximity.NearWard: + candidate.Tier = PlacementTier.NearWard; + nearWardCandidates++; + break; + default: + candidate.Tier = PlacementTier.Clear; + break; + } + + return candidate; } /// @@ -575,6 +809,7 @@ private void AbandonSearch() { EpicLoot.Log("Adventure spawner changed owner mid-search; leaving the search to the new owner."); startedPlacement = false; + stage = PlacementStage.NotStarted; } /// @@ -588,8 +823,9 @@ private void AbandonSearch() /// private bool IsAreaSettled(Vector3 point) { - // Nothing is "near" a dedicated server's reference position; keep vanilla's rule for - // server-side simulation mods, which are the only way a server owns this spawner. + // Nothing is "near" a dedicated server's reference position; keep vanilla's rule there. A + // dedicated server owns a spawner near the world centre, where its own reference position is, + // or under a serverside simulation mod. if (ZNet.instance.IsDedicated()) { return ZNetScene.instance.IsAreaReady(point); @@ -617,6 +853,53 @@ private bool IsAreaSettled(Vector3 point) return true; } + /// + /// Names what is still waiting on, for the overdue log: the zones + /// not yet loaded, and the first object in each loaded zone that has not been created. + /// + private static string DescribeUnsettledArea(Vector3 point) + { + Vector2s centre = ZoneSystem.GetZone(point); + bool dedicated = ZNet.instance.IsDedicated(); + + if (!dedicated && !IsZoneInLocalNearArea(centre)) + { + float distance = Utils.DistanceXZ(ZNet.instance.GetReferencePosition(), point); + return $"the circle's zone {DescribeZone(centre)} is outside this client's simulation area, {distance:0}m away"; + } + + var waiting = new List(); + for (int y = centre.y - 1; y <= centre.y + 1; y++) + { + for (int x = centre.x - 1; x <= centre.x + 1; x++) + { + var zone = new Vector2s(x, y); + if (!dedicated && !IsZoneInLocalNearArea(zone)) + { + continue; + } + + if (!ZoneSystem.instance.IsZoneLoaded(zone)) + { + waiting.Add($"zone {DescribeZone(zone)} not loaded"); + } + else if (TryFindUninstantiatedObject(zone, out ZDO waitingOn)) + { + GameObject prefab = ZNetScene.instance.GetPrefab(waitingOn.GetPrefab()); + string name = prefab != null ? prefab.name : waitingOn.GetPrefab().ToString(); + waiting.Add($"zone {DescribeZone(zone)} still creating '{name}' ({waitingOn.m_uid})"); + } + } + } + + return waiting.Count == 0 ? "it has just finished loading" : string.Join(", ", waiting); + } + + private static string DescribeZone(Vector2s zone) + { + return $"({zone.x}, {zone.y})"; + } + /// /// Whether a search candidate's zone is loaded and populated, cached for the rest of the search. /// A zone outside the near area is rejected outright, as GetGroundData would do for it anyway. @@ -666,26 +949,30 @@ private static bool IsZoneInLocalNearArea(Vector2s zone) /// private static bool IsZoneInstantiated(Vector2s zone) { - if (!ZoneSystem.instance.IsZoneLoaded(zone)) - { - return false; - } + return ZoneSystem.instance.IsZoneLoaded(zone) && !TryFindUninstantiatedObject(zone, out _); + } + /// + /// Finds the first object in with a known prefab that ZNetScene has not + /// created yet. + /// + private static bool TryFindUninstantiatedObject(Vector2s zone, out ZDO waitingOn) + { + waitingOn = null; ZoneObjectsScratch.Clear(); ZDOMan.instance.FindSectorObjects(zone, new SimulationDistance(0, 0), ZoneObjectsScratch); - bool ready = true; foreach (ZDO zdo in ZoneObjectsScratch) { if (ZNetScene.instance.IsPrefabZDOValid(zdo) && !ZNetScene.instance.HaveInstance(zdo)) { - ready = false; + waitingOn = zdo; break; } } ZoneObjectsScratch.Clear(); - return ready; + return waitingOn != null; } /// @@ -703,6 +990,7 @@ private static bool IsZoneInstantiated(Vector2s zone) /// private void ReportMissingPrefab(string role, string bountyId, string monsterId) { + missingPrefab = monsterId; if (!reportedMissingPrefab) { reportedMissingPrefab = true; @@ -717,72 +1005,41 @@ private void ReportMissingPrefab(string role, string bountyId, string monsterId) } /// - /// True when the chosen point falls outside the circle drawn on the map. The pin's - /// m_worldSize is a diameter (vanilla sets it to range * 2), and MinimapController - /// assigns MinimapAreaRadius * AreaScale, so the drawn radius is half of that. + /// Reads the bounty a spawner carries straight off its ZDO, for code that holds the ZDO but not an + /// instance (the buyer-side watchdog). False when the spawner carries no bounty. /// - private static bool RequiresPinRelocation(Vector3 pinCentre, Vector3 spawn) + internal static bool TryReadBounty(ZDO zdo, out BountyInfo bountyInfo) { - float drawnRadius = AdventureDataManager.Config.TreasureMap.MinimapAreaRadius * - MinimapController.AreaScale * 0.5f; - return Utils.DistanceXZ(pinCentre, spawn) > drawnRadius; + return TryReadBinary(zdo, BountyKey, out bountyInfo); } - /// - /// Moves the owning player's minimap pin to . - /// Returns false only when the local player is not the one who bought this spawn - a missing - /// or already-resolved save record still counts as handled, since parking forever would be - /// worse than a stale pin. - /// - private bool TryRelocateOwnerPin(Vector3 newPosition) + /// Treasure-map counterpart to . + internal static bool TryReadTreasure(ZDO zdo, out TreasureMapChestInfo treasureInfo) { - Player player = Player.m_localPlayer; - if (player == null) - { - return false; - } - - long localPlayerID = player.GetPlayerID(); - AdventureSaveData saveData = player.GetAdventureSaveData(); - - bool relocated; - string description; - - if (isBounty.Get() == true) - { - BountyInfo bountyInfo = bounty.Get(); - if (bountyInfo.PlayerID != localPlayerID) - { - return false; - } + return TryReadBinary(zdo, TreasureKey, out treasureInfo); + } - relocated = saveData != null && saveData.RelocateBounty(bountyInfo.ID, newPosition); - description = $"bountyID={bountyInfo.ID}"; - } - else + /// The same BinaryFormatter decode the ZNetProperty wrappers in CustomZNet use. + private static bool TryReadBinary(ZDO zdo, string key, out T value) where T : class + { + value = null; + byte[] stored = zdo.GetByteArray(key); + if (stored == null) { - TreasureMapChestInfo treasureInfo = treasure.Get(); - if (treasureInfo.PlayerID != localPlayerID) - { - return false; - } - - relocated = saveData != null && - saveData.RelocateTreasureMap(treasureInfo.Interval, treasureInfo.Biome, newPosition); - description = $"interval={treasureInfo.Interval} biome={treasureInfo.Biome}"; + return false; } - if (relocated) + try { - player.Message(MessageHud.MessageType.Center, "$mod_epicloot_adventure_spawnrelocated"); + using var stream = new MemoryStream(stored); + value = new BinaryFormatter().Deserialize(stream) as T; } - else + catch (Exception) { - EpicLoot.LogWarning("Moved an adventure spawn outside its map circle but could not " + - $"update the minimap pin ({description})."); + value = null; } - return true; + return value != null; } } } diff --git a/EpicLoot/src/Adventure/feature/Bounties.cs b/EpicLoot/src/Adventure/feature/Bounties.cs index 27c4ee81b..e5912f717 100755 --- a/EpicLoot/src/Adventure/feature/Bounties.cs +++ b/EpicLoot/src/Adventure/feature/Bounties.cs @@ -289,7 +289,7 @@ public IEnumerator AcceptBounty(Player player, BountyInfo bounty, Action checks all of those with an - /// expanding band search once the player is actually near the point, and stays the authority on - /// where the bounty or chest finally lands. + /// checks all of those once someone is + /// actually near the point, and decides where inside the map circle the bounty or chest lands. + /// The point picked here is the circle's centre and never moves afterwards. /// internal static class BountyLocationEarlyCache { diff --git a/EpicLoot/src/Adventure/feature/TreasureMaps.cs b/EpicLoot/src/Adventure/feature/TreasureMaps.cs index 72d65b977..f4c00722d 100755 --- a/EpicLoot/src/Adventure/feature/TreasureMaps.cs +++ b/EpicLoot/src/Adventure/feature/TreasureMaps.cs @@ -109,7 +109,7 @@ private bool CreateTreasureSpawner(Heightmap.Biome biome, Vector3 spawnPoint, A } Quaternion rotation = Quaternion.Euler(0f, UnityEngine.Random.Range(0f, 360f), 0f); - GameObject gameObject = PrefabManager.Instance.GetPrefab("EL_SpawnController"); + GameObject gameObject = PrefabManager.Instance.GetPrefab(AdventureSpawnController.PrefabName); GameObject created_go = Object.Instantiate(gameObject, spawnPoint, rotation); AdventureSpawnController asc = created_go.GetComponent(); asc.SetTreasure(treasure_details); From 1ad21531b895ad47f354d22dd1c7d0f11af19ca1 Mon Sep 17 00:00:00 2001 From: MidnightsFX Date: Fri, 18 Sep 2026 08:10:18 -0700 Subject: [PATCH 11/20] 0.14.9 --- EpicLoot/EpicLoot.cs | 2 +- EpicLoot/Package/CHANGELOG.md | 5 + EpicLoot/Package/manifest.json | 2 +- EpicLoot/src/Crafting/AugmentChoiceDialog.cs | 8 +- EpicLoot/src/Crafting/CraftSuccessDialog.cs | 7 +- EpicLoot/src/GamePatches/TraderDiagnostics.cs | 424 ++++++++++++++++++ .../src/GamePatches/TraderStoreHardening.cs | 135 ++++++ EpicLoot/src/GamePatches/UIFix_Patch.cs | 26 +- EpicLoot/src/General/GamepadScroll.cs | 71 +++ .../src/Magic/MagicItemEffects/DoubleJump.cs | 22 +- EpicLoot/src/UI/Enchanting/AugmentUI.cs | 10 +- EpicLoot/src/UI/Enchanting/EnchantUI.cs | 9 +- 12 files changed, 685 insertions(+), 36 deletions(-) create mode 100644 EpicLoot/src/GamePatches/TraderDiagnostics.cs create mode 100644 EpicLoot/src/GamePatches/TraderStoreHardening.cs create mode 100644 EpicLoot/src/General/GamepadScroll.cs diff --git a/EpicLoot/EpicLoot.cs b/EpicLoot/EpicLoot.cs index 23a16af6e..361f4baf6 100644 --- a/EpicLoot/EpicLoot.cs +++ b/EpicLoot/EpicLoot.cs @@ -38,7 +38,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.14.8"; + public const string Version = "0.14.9"; private static string ConfigFileName = PluginId + ".cfg"; private static string ConfigFileFullPath = BepInEx.Paths.ConfigPath + Path.DirectorySeparatorChar + ConfigFileName; diff --git a/EpicLoot/Package/CHANGELOG.md b/EpicLoot/Package/CHANGELOG.md index 4581adae1..6f0b5ff3f 100644 --- a/EpicLoot/Package/CHANGELOG.md +++ b/EpicLoot/Package/CHANGELOG.md @@ -1,3 +1,8 @@ +**0.14.9** +* Fix for Grappling hook +* Trader window diagnostics, please report your log if you have issues opening the trader window +* Another fix for Joystick controller issues + **0.14.8** * Controller update for 9/17 * Increases placement allowance for bounties/treasures diff --git a/EpicLoot/Package/manifest.json b/EpicLoot/Package/manifest.json index 9bd475cae..31827b398 100644 --- a/EpicLoot/Package/manifest.json +++ b/EpicLoot/Package/manifest.json @@ -1,6 +1,6 @@ { "name": "EpicLoot", - "version_number": "0.14.8", + "version_number": "0.14.9", "website_url": "https://discord.gg/ZNhYeavv3C", "description": "Adds loot drops, magic items, and enchanting to Valheim.", "dependencies": [ diff --git a/EpicLoot/src/Crafting/AugmentChoiceDialog.cs b/EpicLoot/src/Crafting/AugmentChoiceDialog.cs index ddb134c1e..e74280e6e 100755 --- a/EpicLoot/src/Crafting/AugmentChoiceDialog.cs +++ b/EpicLoot/src/Crafting/AugmentChoiceDialog.cs @@ -63,13 +63,7 @@ public void Update() ZInput.ResetButtonStatus("JoyButtonA"); } - var scrollBar = GetComponentInChildren(); - if (scrollBar != null) - { - var rightStickAxis = ZInput.GetJoyRightStickY(); - if (Mathf.Abs(rightStickAxis) > 0.5f) - scrollBar.value = Mathf.Clamp01(scrollBar.value + rightStickAxis * -0.1f); - } + GamepadScroll.ApplyRightStickY(GetComponentInChildren()); } for (var index = 0; index < EffectChoiceButtons.Count; index++) diff --git a/EpicLoot/src/Crafting/CraftSuccessDialog.cs b/EpicLoot/src/Crafting/CraftSuccessDialog.cs index 0c07f977e..2d1042359 100644 --- a/EpicLoot/src/Crafting/CraftSuccessDialog.cs +++ b/EpicLoot/src/Crafting/CraftSuccessDialog.cs @@ -33,12 +33,9 @@ public void Awake() [UsedImplicitly] public void Update() { - var scrollBar = GetComponentInChildren(); - if (scrollBar != null && ZInput.IsGamepadActive()) + if (ZInput.IsGamepadActive()) { - var rightStickAxis = ZInput.GetJoyRightStickY(); - if (Mathf.Abs(rightStickAxis) > 0.5f) - scrollBar.value = Mathf.Clamp01(scrollBar.value + rightStickAxis * -0.1f); + GamepadScroll.ApplyRightStickY(GetComponentInChildren()); } if (ZInput.GetButtonDown("Inventory") || ZInput.GetButtonDown("JoyButtonB") || diff --git a/EpicLoot/src/GamePatches/TraderDiagnostics.cs b/EpicLoot/src/GamePatches/TraderDiagnostics.cs new file mode 100644 index 000000000..052d0ddd6 --- /dev/null +++ b/EpicLoot/src/GamePatches/TraderDiagnostics.cs @@ -0,0 +1,424 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using EpicLoot.Adventure; +using HarmonyLib; +using UnityEngine; + +namespace EpicLoot +{ + /// + /// Reports what happened between a Use press aimed at a trader and the store window appearing. + /// + /// Every way that path can fail is silent by construction, which is why "the trader stopped + /// responding" arrives with nothing in the log: + /// + /// - returns without doing anything when the same trader is already + /// considered visible, and "visible" is a frame counter (m_hiddenFrames) that only advances + /// from StoreGui.Update -- so if Update stalls, Show is a permanent no-op. + /// - Show activates m_rootPanel, but SetActive on an object under an inactive ancestor, or on a + /// second StoreGui that grabbed the static instance, renders nothing and raises nothing. + /// - StoreGui.Update hides the window again on the Use press that opened it, on + /// InventoryGui.IsVisible(), on distance, and on a null trader. Hide() logs nothing. + /// - A prefix elsewhere returning false, or a blocked hover, means Trader.Interact never runs. + /// + /// This is deliberately **not** behind a config entry and deliberately uses the *Force log + /// variants: the only logs we ever get for this are the ones players attach to a report, and they + /// have not switched anything on. Cost is one line per non-held Use press on a trader, a null + /// check on every other Use press, and nothing at all per frame. Anything beyond that one line + /// means something actually went wrong. + /// + /// Reading the summary line: `trader=` did Trader.Interact run, `show=` did Show run its body, + /// `fill=` how many rows the vanilla list ended up with, `root=`/`gui=` whether the window is + /// active *and* in an active hierarchy, `inst=self` whether StoreGui.instance is the object we + /// were called on. + /// + public static class TraderDiagnostics + { + private class PressRecord + { + public int Frame; + public string TraderName = "?"; + public string HoverName = "?"; + public bool TakeInput; + public bool ReachedTrader; + public bool ShowCalled; + public bool ShowBodyRan; + public bool Repaired; + public bool SameTrader; + public bool WasVisible; + public int HiddenFramesBefore; + public bool InstanceIsSelf = true; + public bool RootActiveSelf; + public bool RootInHierarchy; + public bool GuiInHierarchy; + public int FillCount = -1; + public string FillError; + } + + private static PressRecord _press; + + /// Frame the store window was last actually opened, for the closed-too-soon check. + private static int _lastOpenedFrame = int.MinValue; + + internal static bool Recording => _press != null; + + internal static void BeginPress(GameObject hovered, bool hold) + { + // Holding Use re-enters Player.Interact every frame; only a real press is an attempt to + // open the store, and only a press is worth a line. + if (hold || hovered == null) + { + return; + } + + var trader = hovered.GetComponentInParent(); + if (trader == null) + { + _press = null; + return; + } + + var player = Player.m_localPlayer; + _press = new PressRecord + { + Frame = Time.frameCount, + TraderName = trader.m_name, + HoverName = hovered.name, + TakeInput = player != null && player.TakeInput() + }; + } + + internal static void MarkReachedTrader(Trader trader) + { + if (_press == null) + { + return; + } + + _press.ReachedTrader = true; + if (trader != null) + { + _press.TraderName = trader.m_name; + } + } + + internal static void MarkShowEntered(StoreGui storeGui, Trader trader) + { + if (_press == null || storeGui == null) + { + return; + } + + _press.ShowCalled = true; + _press.InstanceIsSelf = StoreGui.instance == storeGui; + _press.SameTrader = storeGui.m_trader == trader; + _press.WasVisible = StoreGui.IsVisible(); + _press.HiddenFramesBefore = storeGui.m_hiddenFrames; + + // Show's own condition, evaluated before it runs: this is what tells H1 apart from the rest. + _press.ShowBodyRan = !_press.SameTrader || !_press.WasVisible; + } + + /// + /// Called by the show-desync repair in , + /// which runs after this class's own Show prefix has already recorded the pre-repair decision. + /// Without this the summary would report a skipped body for a press that did open the window. + /// + internal static void NoteRepaired() + { + if (_press == null) + { + return; + } + + _press.Repaired = true; + _press.ShowBodyRan = true; + } + + internal static void MarkFilled(StoreGui storeGui, Exception exception) + { + if (exception != null) + { + // Worth a line even outside a tracked press: FillList also runs on buy and sell. + EpicLoot.LogErrorForce($"[Trader] StoreGui.FillList threw, the store list is incomplete:\n{exception}"); + } + + if (_press == null) + { + return; + } + + _press.FillCount = storeGui != null && storeGui.m_itemList != null ? storeGui.m_itemList.Count : -1; + _press.FillError = exception?.GetType().Name; + } + + internal static void MarkShowFinished(StoreGui storeGui) + { + if (_press == null || storeGui == null) + { + return; + } + + var rootPanel = storeGui.m_rootPanel; + _press.RootActiveSelf = rootPanel != null && rootPanel.activeSelf; + _press.RootInHierarchy = rootPanel != null && rootPanel.activeInHierarchy; + _press.GuiInHierarchy = storeGui.gameObject.activeInHierarchy; + + if (_press.ShowBodyRan && _press.RootActiveSelf) + { + _lastOpenedFrame = Time.frameCount; + } + } + + /// Emits the one line. Runs from a finalizer, so it happens however the press ended. + internal static void EndPress(Exception exception) + { + var press = _press; + _press = null; + if (press == null) + { + return; + } + + var problems = new List(); + if (!press.TakeInput) + { + problems.Add("Player.TakeInput() was false (a GUI thinks it is open)"); + } + + if (!press.ReachedTrader) + { + problems.Add("Trader.Interact never ran (a prefix returned false, or the hover is not the trader)"); + } + else if (!press.ShowCalled) + { + problems.Add("StoreGui.Show was never called"); + } + else if (!press.ShowBodyRan) + { + problems.Add("Show skipped its body: this trader already counts as visible, so the window will not reopen until StoreGui.Update runs again"); + } + else if (!press.RootInHierarchy) + { + problems.Add(press.RootActiveSelf + ? "the store window was activated but is under an inactive parent, so nothing is drawn" + : "the store window is not active after Show"); + } + + if (!press.InstanceIsSelf) + { + problems.Add("StoreGui.instance is a different StoreGui than the one that was shown"); + } + + if (press.FillError != null) + { + problems.Add("FillList threw " + press.FillError); + } + + if (exception != null) + { + problems.Add("Player.Interact threw " + exception.GetType().Name); + } + + var panel = StoreGui_Patch.MerchantPanel; + var line = + $"[Trader] Use on '{press.TraderName}' (hover={press.HoverName}): takeInput={press.TakeInput} " + + $"trader={(press.ReachedTrader ? "ran" : "MISSED")} " + + $"show={(press.ShowCalled ? press.Repaired ? "ran(repaired)" : press.ShowBodyRan ? "ran" : "skipped" : "MISSED")} " + + $"sameTrader={press.SameTrader} wasVisible={press.WasVisible} hidden={press.HiddenFramesBefore} " + + $"fill={press.FillCount} root={(press.RootActiveSelf ? "active" : "inactive")}" + + $"{(press.RootInHierarchy ? "+shown" : "+hidden")} gui={(press.GuiInHierarchy ? "shown" : "HIDDEN")} " + + $"inst={(press.InstanceIsSelf ? "self" : "OTHER")} " + + $"panel={(panel == null ? "none" : panel.activeSelf ? "active" : "inactive")}"; + + if (problems.Count == 0) + { + EpicLoot.LogForce(line); + return; + } + + line += "\n problems: " + string.Join("; ", problems.ToArray()); + if (press.FillError != null || exception != null) + { + EpicLoot.LogErrorForce(line); + } + else + { + EpicLoot.LogWarningForce(line); + } + } + + /// + /// The store closing within a frame or two of opening is the one failure the summary line + /// cannot see: Show did everything right and something shut the window straight back down. + /// Only that case pays for a stack trace; an ordinary close says nothing. + /// + internal static void NoteHide(StoreGui storeGui) + { + var sinceOpen = Time.frameCount - _lastOpenedFrame; + if (storeGui == null || sinceOpen < 0 || sinceOpen > 2) + { + return; + } + + _lastOpenedFrame = int.MinValue; + + var player = Player.m_localPlayer; + var distance = player != null && storeGui.m_trader != null + ? Vector3.Distance(storeGui.m_trader.transform.position, player.transform.position) + : -1f; + + EpicLoot.LogWarningForce( + $"[Trader] The store window closed {sinceOpen} frame(s) after it opened. " + + $"traderNull={storeGui.m_trader == null} distance={distance:F1}/{storeGui.m_hideDistance} " + + $"invGui={InventoryGui.IsVisible()} map={Minimap.IsOpen()} " + + $"use={ZInput.GetButtonDown("Use")} esc={ZInput.GetKeyDown(KeyCode.Escape)} " + + $"joyB={ZInput.GetButtonDown("JoyButtonB")} textViewer={(TextViewer.instance != null)}\n" + + new StackTrace(1, false)); + } + + internal static void Failed(string where, Exception e) + { + EpicLoot.LogErrorForce($"[Trader] Trader diagnostics failed in {where} (this is the diagnostic's own bug, " + + $"not the trader's):\n{e}"); + } + } + + /// + /// Opens and closes the record. Parameters are injected by index rather than by name so a + /// renamed vanilla parameter cannot stop the whole mod from patching. + /// + [HarmonyPatch(typeof(Player), nameof(Player.Interact))] + public static class Player_Interact_TraderDiagnostics + { + public static bool Prepare() + { + return AccessTools.Method(typeof(Player), nameof(Player.Interact), + new[] { typeof(GameObject), typeof(bool), typeof(bool) }) != null; + } + + [HarmonyPriority(Priority.First)] + public static void Prefix(GameObject __0, bool __1) + { + try + { + TraderDiagnostics.BeginPress(__0, __1); + } + catch (Exception e) + { + TraderDiagnostics.Failed("Player.Interact prefix", e); + } + } + + [HarmonyPriority(Priority.Last)] + public static void Finalizer(Exception __exception) + { + try + { + TraderDiagnostics.EndPress(__exception); + } + catch (Exception e) + { + TraderDiagnostics.Failed("Player.Interact finalizer", e); + } + } + } + + [HarmonyPatch(typeof(Trader), nameof(Trader.Interact))] + public static class Trader_Interact_TraderDiagnostics + { + [HarmonyPriority(Priority.First)] + public static void Prefix(Trader __instance, bool __1) + { + try + { + if (!__1) + { + TraderDiagnostics.MarkReachedTrader(__instance); + } + } + catch (Exception e) + { + TraderDiagnostics.Failed("Trader.Interact prefix", e); + } + } + } + + [HarmonyPatch(typeof(StoreGui))] + public static class StoreGui_TraderDiagnostics + { + [HarmonyPatch(nameof(StoreGui.Show))] + [HarmonyPrefix] + [HarmonyPriority(Priority.First)] + public static void Show_Prefix(StoreGui __instance, Trader __0) + { + try + { + TraderDiagnostics.MarkShowEntered(__instance, __0); + } + catch (Exception e) + { + TraderDiagnostics.Failed("StoreGui.Show prefix", e); + } + } + + // Last, so the state it reports includes whatever the adventure panel finalizer did. + [HarmonyPatch(nameof(StoreGui.Show))] + [HarmonyFinalizer] + [HarmonyPriority(Priority.Last)] + public static void Show_Finalizer(StoreGui __instance) + { + try + { + TraderDiagnostics.MarkShowFinished(__instance); + } + catch (Exception e) + { + TraderDiagnostics.Failed("StoreGui.Show finalizer", e); + } + } + + [HarmonyPatch(nameof(StoreGui.Hide))] + [HarmonyPrefix] + [HarmonyPriority(Priority.First)] + public static void Hide_Prefix(StoreGui __instance) + { + try + { + TraderDiagnostics.NoteHide(__instance); + } + catch (Exception e) + { + TraderDiagnostics.Failed("StoreGui.Hide prefix", e); + } + } + } + + /// + /// Its own class so Prepare can skip just this one: FillList is private, and a private method is + /// the kind that quietly changes shape in a game update. A missing target would otherwise throw + /// out of CreateAndPatchAll and take the whole mod with it. + /// + [HarmonyPatch(typeof(StoreGui), nameof(StoreGui.FillList))] + public static class StoreGui_FillList_TraderDiagnostics + { + public static bool Prepare() + { + return AccessTools.Method(typeof(StoreGui), nameof(StoreGui.FillList)) != null; + } + + [HarmonyFinalizer] + public static void Finalizer(StoreGui __instance, Exception __exception) + { + try + { + TraderDiagnostics.MarkFilled(__instance, __exception); + } + catch (Exception e) + { + TraderDiagnostics.Failed("StoreGui.FillList finalizer", e); + } + } + } +} diff --git a/EpicLoot/src/GamePatches/TraderStoreHardening.cs b/EpicLoot/src/GamePatches/TraderStoreHardening.cs new file mode 100644 index 000000000..6e78a7d17 --- /dev/null +++ b/EpicLoot/src/GamePatches/TraderStoreHardening.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using HarmonyLib; + +namespace EpicLoot +{ + /// + /// Vanilla's trade item is a [Serializable] class, so entries authored on the Haldor/Hildir prefab + /// get "" for their string fields from Unity's serializer. A `new Trader.TradeItem { ... }` built + /// in code leaves them null, and vanilla StoreGui.FillList reads `tradeItem.m_tooltip.Length` with + /// no guard -- one NullReferenceException, thrown after Show has already activated the window and + /// before the list is finished, and nothing in the log names the mod that supplied the item. + /// BuySelectedItem has the same hole in `m_buyPlayerEffects.Create(...)`. + /// + /// Filling those in here costs a walk over a handful of items and makes the store immune to the + /// whole class, whichever mod stocks it. It also names the item once, since the first pass is the + /// only one that finds it null. + /// + [HarmonyPatch(typeof(Trader), nameof(Trader.GetAvailableItems))] + public static class Trader_GetAvailableItems_NormalizeStock + { + [HarmonyPostfix] + public static void Postfix(Trader __instance, List __result) + { + if (__result == null) + { + return; + } + + for (var i = 0; i < __result.Count; i++) + { + var item = __result[i]; + if (item == null) + { + continue; + } + + if (item.m_tooltip == null || item.m_name == null || item.m_buyPlayerEffects == null) + { + var itemName = item.m_prefab != null ? item.m_prefab.name : item.m_name ?? "(unnamed)"; + EpicLoot.LogWarningForce( + $"[Trader] '{(__instance != null ? __instance.m_name : "?")}' is stocking '{itemName}' with " + + "null fields (m_tooltip/m_name/m_buyPlayerEffects). Whichever mod added it built the " + + "TradeItem in code without the values Unity's serializer would have given it, which " + + "crashes vanilla StoreGui.FillList. Filling them in so the store still opens."); + + item.m_tooltip = item.m_tooltip ?? ""; + item.m_name = item.m_name ?? ""; + item.m_buyPlayerEffects = item.m_buyPlayerEffects ?? new EffectList(); + } + } + } + } + + /// + /// StoreGui.FillList clamps its remembered index up to 0 and then hands it to SelectItem, which + /// does `m_trader.GetAvailableItems()[index]` -- so a trader whose entire stock is gated behind + /// keys the player does not have throws ArgumentOutOfRangeException the moment the store opens. + /// SelectItem also re-queries GetAvailableItems a third time per fill, so a mod postfixing that + /// with anything non-deterministic can hand it an index the new list does not have. + /// + /// Clamping the index leaves the normal path untouched: -1 is what vanilla itself uses for + /// "nothing selected", and its own code already handles it. + /// + [HarmonyPatch(typeof(StoreGui), nameof(StoreGui.SelectItem))] + public static class StoreGui_SelectItem_ClampIndex + { + public static bool Prepare() + { + return AccessTools.Method(typeof(StoreGui), nameof(StoreGui.SelectItem)) != null; + } + + [HarmonyPrefix] + [HarmonyPriority(Priority.First)] + public static void Prefix(StoreGui __instance, ref int __0) + { + if (__instance == null || __instance.m_trader == null || __0 < 0) + { + return; + } + + var rows = __instance.m_itemList == null ? 0 : __instance.m_itemList.Count; + var available = __instance.m_trader.GetAvailableItems(); + var max = Math.Min(rows, available == null ? 0 : available.Count); + if (__0 >= max) + { + __0 = max - 1; + } + } + } + + /// + /// StoreGui.Show does nothing at all when the same trader is already considered visible, and + /// "visible" is StoreGui.m_hiddenFrames, a counter only StoreGui.Update advances. If Update stops + /// running with that counter low -- the component disabled, the window parented under something + /// that went inactive, a second StoreGui holding the static instance -- then Show is a permanent + /// silent no-op and pressing Use on the trader does nothing at all until the player relogs. + /// + /// The counter claiming "visible" while the window is not actually in an active hierarchy is that + /// desync and nothing else, so push the counter past the threshold and let Show run its body. If + /// the window still does not appear, the diagnostic line that follows says so. + /// + [HarmonyPatch(typeof(StoreGui), nameof(StoreGui.Show))] + public static class StoreGui_Show_RecoverFromVisibleDesync + { + [HarmonyPrefix] + public static void Prefix(StoreGui __instance, Trader __0) + { + if (__instance == null || __0 == null) + { + return; + } + + // Show will run its body on its own; nothing to repair. + if (__instance.m_trader != __0 || !StoreGui.IsVisible()) + { + return; + } + + var rootPanel = __instance.m_rootPanel; + if (rootPanel != null && rootPanel.activeInHierarchy) + { + return; + } + + EpicLoot.LogWarningForce( + $"[Trader] StoreGui says it is showing '{__0.m_name}' (hiddenFrames={__instance.m_hiddenFrames}) " + + $"while its window is not in an active hierarchy (root={(rootPanel == null ? "null" : rootPanel.activeSelf ? "active" : "inactive")}, " + + $"gui={__instance.gameObject.activeInHierarchy}). Show would have done nothing; forcing it to reopen."); + + __instance.m_hiddenFrames = 2; + TraderDiagnostics.NoteRepaired(); + } + } +} diff --git a/EpicLoot/src/GamePatches/UIFix_Patch.cs b/EpicLoot/src/GamePatches/UIFix_Patch.cs index 762447195..0729dec2f 100755 --- a/EpicLoot/src/GamePatches/UIFix_Patch.cs +++ b/EpicLoot/src/GamePatches/UIFix_Patch.cs @@ -107,6 +107,17 @@ public static void AddScrollbar(GameObject tooltipObject, RectTransform hoverTra return; } + // UITooltip.m_tooltip is static and outlives a single hover -- OnPointerExit is suppressed + // above, so it survives the cursor moving between slots, and MagicTooltipPatches re-enters + // OnHoverStart through UITooltip.Set. Everything below is a one-shot migration of the prefab's + // children into a scroll view, so a second pass over the same object corrupts it: it finds the + // first pass's "Content" (already carrying the copied background) and, within the same frame, + // still finds the "Bkg" whose Destroy has not been processed yet. + if (Utils.FindChild(tooltipObject.transform, "Scroll View") != null) + { + return; + } + Transform header = Utils.FindChild(tooltipObject.transform, "Topic"); if (header == null) { @@ -137,11 +148,18 @@ public static void AddScrollbar(GameObject tooltipObject, RectTransform hoverTra if (bkgtform != null) { Image backgroundImage = bkgtform.GetComponent(); + // AddComponent returns null (after a Unity warning) if the object already carries a + // Graphic. The guard at the top of this method is what keeps that from happening; don't + // dereference the result blindly, because this runs inside a Harmony postfix and an NRE + // here unwinds all the way out of InventoryGui.Update. Image contentbkgImage = contentt.gameObject.AddComponent(); - contentbkgImage.color = backgroundImage.color; - contentbkgImage.sprite = backgroundImage.sprite; - contentbkgImage.type = backgroundImage.type; - contentbkgImage.raycastTarget = false; + if (contentbkgImage != null) + { + contentbkgImage.color = backgroundImage.color; + contentbkgImage.sprite = backgroundImage.sprite; + contentbkgImage.type = backgroundImage.type; + contentbkgImage.raycastTarget = false; + } // Remove the header background as it is no longer needed GameObject.Destroy(bkgtform.gameObject); } diff --git a/EpicLoot/src/General/GamepadScroll.cs b/EpicLoot/src/General/GamepadScroll.cs new file mode 100644 index 000000000..da03de440 --- /dev/null +++ b/EpicLoot/src/General/GamepadScroll.cs @@ -0,0 +1,71 @@ +using System; +using System.Runtime.CompilerServices; +using UnityEngine; +using UnityEngine.UI; + +namespace EpicLoot; + +/// +/// Right-stick scrolling for the crafting/enchanting panels. +/// +/// This exists as a choke point on purpose. Valheim keeps reshaping ZInput -- the 2026-09-17 patch +/// turned GetJoyRightStickY(bool smooth) into a parameterless method -- and Mono reports an +/// unresolvable callee by failing to JIT the *whole* method that contains the call, before a single +/// line of it runs (the give-away in a log is a MissingMethodException with an empty stack trace). +/// Four panels read the stick straight from their own Update, so one renamed getter killed +/// base.Update() too: the enchant/augment countdown never reached DoMainAction and the +/// craft-success dialog, whose only Close() caller is its Update, could not be +/// dismissed. The enchanting table looked dead for keyboard players as well, because the JIT failure +/// happens regardless of the IsGamepadActive guard around it. +/// +/// Routing the read through here keeps that blast radius at "stick scrolling stops working". +/// +internal static class GamepadScroll +{ + private const float Deadzone = 0.5f; + private const float Step = -0.1f; + + private static bool _stickReadUnavailable; + + /// + /// Nudge by the right stick's Y axis. No-op when the scrollbar is + /// missing, or once the game has been found to no longer expose the getter we compiled against. + /// + public static void ApplyRightStickY(Scrollbar scrollbar) + { + if (scrollbar == null || _stickReadUnavailable) + { + return; + } + + float axis; + try + { + axis = ReadRightStickY(); + } + catch (Exception e) + { + // MissingMethodException/TypeLoadException: the game changed ZInput's signature out from + // under this build. Latch it off so this costs one failed JIT, not one per frame, and say + // so unconditionally -- it means the mod needs a rebuild against the current game. + _stickReadUnavailable = true; + EpicLoot.LogWarningForce("Gamepad stick scrolling disabled: ZInput.GetJoyRightStickY() " + + $"could not be called ({e.GetType().Name}: {e.Message}). EpicLoot needs a rebuild " + + "against the current Valheim version."); + return; + } + + if (Mathf.Abs(axis) > Deadzone) + { + scrollbar.value = Mathf.Clamp01(scrollbar.value + axis * Step); + } + } + + // NoInlining is load-bearing: inlining this into ApplyRightStickY would move the unresolvable + // call site there and the JIT failure would bypass the try/catch above. + [MethodImpl(MethodImplOptions.NoInlining)] + private static float ReadRightStickY() + { + return ZInput.GetJoyRightStickY(); + } +} diff --git a/EpicLoot/src/Magic/MagicItemEffects/DoubleJump.cs b/EpicLoot/src/Magic/MagicItemEffects/DoubleJump.cs index fad9efcb8..c81ee54d9 100755 --- a/EpicLoot/src/Magic/MagicItemEffects/DoubleJump.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/DoubleJump.cs @@ -39,11 +39,19 @@ public static bool Prefix(Character __instance) MultiJumpCombo = 0; return true; } - else + + // Character.Jump() has an airborne branch of its own: it re-pulls an attached + // grappling hook. Swallowing the original method used to cost nothing (a jump in + // mid-air did nothing at all), but now it eats every grapple re-pull, so hand the + // input back to vanilla whenever that branch is the one that wants it. No combo is + // spent - a grapple pull is not a jump. + if (WantsGrapplePull(__instance)) { - MultiJumpCombo++; + return true; } + MultiJumpCombo++; + var value = Player.m_localPlayer.GetTotalActiveMagicEffectValue(MagicEffectType.DoubleJump); if (MultiJumpCombo > value) { @@ -55,6 +63,16 @@ public static bool Prefix(Character __instance) } } + // Mirrors the grappling condition in Character.Jump(). m_localGrappler is never cleared on + // break, so this leans on Unity's overloaded != to treat a destroyed hook as null. + private static bool WantsGrapplePull(Character character) + { + var grappler = GrapplingPoint.m_localGrappler; + return grappler != null + && Vector3.Distance(grappler.transform.position, character.transform.position) + > grappler.m_jumpPullMaxDist; + } + public static void MultiJump(Character player, float jumpsize) { if (player.IsEncumbered() || player.InDodge() || player.IsKnockedBack() || player.IsStaggering()) diff --git a/EpicLoot/src/UI/Enchanting/AugmentUI.cs b/EpicLoot/src/UI/Enchanting/AugmentUI.cs index 86ac3e959..9f5c48067 100644 --- a/EpicLoot/src/UI/Enchanting/AugmentUI.cs +++ b/EpicLoot/src/UI/Enchanting/AugmentUI.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; +using EpicLoot; using EpicLoot.CraftingV2; using UnityEngine; using UnityEngine.UI; @@ -89,14 +90,7 @@ public override void Update() ZInput.ResetButtonStatus("JoyButtonY"); } - if (AvailableEffectsScrollbar != null) - { - float rightStickAxis = ZInput.GetJoyRightStickY(); - if (Mathf.Abs(rightStickAxis) > 0.5f) - { - AvailableEffectsScrollbar.value = Mathf.Clamp01(AvailableEffectsScrollbar.value + rightStickAxis * -0.1f); - } - } + GamepadScroll.ApplyRightStickY(AvailableEffectsScrollbar); } if (_choiceDialog != null && !_choiceDialog.activeSelf) diff --git a/EpicLoot/src/UI/Enchanting/EnchantUI.cs b/EpicLoot/src/UI/Enchanting/EnchantUI.cs index ed20515c8..678df2957 100644 --- a/EpicLoot/src/UI/Enchanting/EnchantUI.cs +++ b/EpicLoot/src/UI/Enchanting/EnchantUI.cs @@ -117,14 +117,7 @@ public override void Update() ZInput.ResetButtonStatus("JoyButtonY"); } - if (EnchantInfoScrollbar != null) - { - float rightStickAxis = ZInput.GetJoyRightStickY(); - if (Mathf.Abs(rightStickAxis) > 0.5f) - { - EnchantInfoScrollbar.value = Mathf.Clamp01(EnchantInfoScrollbar.value + rightStickAxis * -0.1f); - } - } + GamepadScroll.ApplyRightStickY(EnchantInfoScrollbar); } if (_successDialog != null && !_successDialog.activeSelf) From 304fc2b65aa581b0ca8d1a63d63a5ad020fa53b3 Mon Sep 17 00:00:00 2001 From: MidnightsFX Date: Fri, 18 Sep 2026 16:14:13 -0700 Subject: [PATCH 12/20] 0.14.10 --- EpicLoot/EpicLoot.cs | 8 +- EpicLoot/Package/CHANGELOG.md | 18 +++ EpicLoot/config/shardstones.json | 12 +- EpicLoot/localizations/Bulgarian.json | 24 ++-- EpicLoot/localizations/Chinese.json | 24 ++-- EpicLoot/localizations/Chinese_Trad.json | 24 ++-- EpicLoot/localizations/Croatian.json | 24 ++-- EpicLoot/localizations/Czech.json | 24 ++-- EpicLoot/localizations/Danish.json | 24 ++-- EpicLoot/localizations/Dutch.json | 24 ++-- EpicLoot/localizations/English.json | 28 ++-- EpicLoot/localizations/Finnish.json | 24 ++-- EpicLoot/localizations/French.json | 24 ++-- EpicLoot/localizations/Georgian.json | 24 ++-- EpicLoot/localizations/German.json | 24 ++-- EpicLoot/localizations/Greek.json | 24 ++-- EpicLoot/localizations/Hindi.json | 24 ++-- EpicLoot/localizations/Hungarian.json | 24 ++-- EpicLoot/localizations/Icelandic.json | 24 ++-- EpicLoot/localizations/Italian.json | 24 ++-- EpicLoot/localizations/Japanese.json | 24 ++-- EpicLoot/localizations/Korean.json | 24 ++-- EpicLoot/localizations/Latvian.json | 24 ++-- EpicLoot/localizations/Lithuanian.json | 24 ++-- EpicLoot/localizations/Macedonian.json | 24 ++-- EpicLoot/localizations/Norwegian.json | 24 ++-- EpicLoot/localizations/Polish.json | 24 ++-- .../localizations/Portuguese_Brazilian.json | 24 ++-- .../localizations/Portuguese_European.json | 24 ++-- EpicLoot/localizations/Romanian.json | 24 ++-- EpicLoot/localizations/Russian.json | 24 ++-- EpicLoot/localizations/Serbian.json | 24 ++-- EpicLoot/localizations/Slovak.json | 24 ++-- EpicLoot/localizations/Spanish.json | 24 ++-- EpicLoot/localizations/Swedish.json | 24 ++-- EpicLoot/localizations/Thai.json | 24 ++-- EpicLoot/localizations/Turkish.json | 24 ++-- EpicLoot/localizations/Ukrainian.json | 24 ++-- .../Adventure/PlayerExtensions_Adventure.cs | 32 ++++- EpicLoot/src/Adventure/TreasureMapChest.cs | 1 + EpicLoot/src/Adventure/feature/Bounties.cs | 9 ++ EpicLoot/src/Adventure/feature/Gamble.cs | 1 + .../src/Adventure/feature/TreasureMaps.cs | 2 + EpicLoot/src/GamePatches/ItemDrop_Patch.cs | 27 ++++ EpicLoot/src/GamePatches/TraderDiagnostics.cs | 44 +++++- .../src/GamePatches/TraderStoreHardening.cs | 126 ++++++++++++++++++ EpicLoot/src/Loot/LootRoller.cs | 4 +- EpicLoot/src/Magic/MagicItemComponent.cs | 49 ++++++- .../src/Magic/MagicItemEffectDefinition.cs | 26 +++- .../Shards/BonemassCorpseRot.cs | 10 ++ .../Shards/EikthyrShockingCharge.cs | 55 ++++++-- .../Shards/ElderForestsAid.cs | 48 ++++--- .../Shards/FaderNecroticFire.cs | 7 + .../Shards/ModerIcyRetribution.cs | 30 ++++- .../MagicItemEffects/Shards/QueenEverflow.cs | 7 + .../src/ShardStones/ShardSocketManager.cs | 26 ++-- EpicLoot/src/Tooltip/MagicTooltipShard.cs | 8 +- 57 files changed, 904 insertions(+), 490 deletions(-) diff --git a/EpicLoot/EpicLoot.cs b/EpicLoot/EpicLoot.cs index 361f4baf6..d2b16b2f0 100644 --- a/EpicLoot/EpicLoot.cs +++ b/EpicLoot/EpicLoot.cs @@ -38,7 +38,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.14.9"; + public const string Version = "0.14.10"; private static string ConfigFileName = PluginId + ".cfg"; private static string ConfigFileFullPath = BepInEx.Paths.ConfigPath + Path.DirectorySeparatorChar + ConfigFileName; @@ -154,6 +154,12 @@ private static void RegisterMagicEffectEvents() { MagicItemEffects.Shards.LuckyLoot.RegisterDisplayValues(); MagicItemEffects.Shards.Bloodrage.RegisterDisplayValues(); MagicItemEffects.Shards.MeteorSummoner.RegisterDisplayValues(); + MagicItemEffects.Shards.EikthyrShockingCharge.RegisterDisplayValues(); + MagicItemEffects.Shards.ElderForestsAid.RegisterDisplayValues(); + MagicItemEffects.Shards.BonemassCorpseRot.RegisterDisplayValues(); + MagicItemEffects.Shards.ModerIcyRetribution.RegisterDisplayValues(); + MagicItemEffects.Shards.QueenEverflow.RegisterDisplayValues(); + MagicItemEffects.Shards.NecroticFire.RegisterDisplayValues(); MagicItemEffects.Shards.BlockAsDodgeAsBlock.RegisterDisplayValues(); MagicItemEffects.Shards.BlockAsWoodCuttingAndPickaxes.RegisterDisplayValues(); diff --git a/EpicLoot/Package/CHANGELOG.md b/EpicLoot/Package/CHANGELOG.md index 6f0b5ff3f..24f7fe127 100644 --- a/EpicLoot/Package/CHANGELOG.md +++ b/EpicLoot/Package/CHANGELOG.md @@ -1,3 +1,21 @@ +**0.14.10** +* Fix for items losing their enchantment when taken off an armor stand + * The enchantment data was still on the item, but nothing re-read it, so the item behaved as if it were mundane until it was dropped on the ground again +* Fix for the trader window not opening at all on some clients + * Vanilla closes the store on the same Use press that opened it whenever the game happens to run Player's update before StoreGui's, so the window was created and destroyed in one frame, drawing nothing and logging nothing + * The store now ignores that one press, the same way the inventory window already does +* Fixes for the bounty ledger which could reset when the server was restarted + * This could result in bounties being marked incomplete after being completed (but not turned in before a server restart) + * This could also result in bounties not being properly marked as completed if the owner was offline during the kill, and before a server restart +* Fixes for not persisting bounty or treasuremap data to the character +* Fix for magic effect values being permanently reset to 1 when an item was reloaded + * An effect was treated as valueless (a yes/no grant like Indestructible) whenever the loaded magiceffects.json had no value block for that item's rarity, and the reset was written straight back to the item + * This hit Ancient items on servers whose magiceffects.json predates the Ancient rarity, since a server replaces the client's enchantment definitions on connect + * Only effects with no values at any rarity are normalized now; a missing rarity block logs a warning naming the effect and rarity instead of destroying the rolled value +* Boss shardstones now show what their upgrade does + * Shocking Charge, Forest's Aid, Corpse Rot, Icy Retribution, Queen's Everflow and Necrotic Fire had no number in their tooltip at all, so every rarity read identically + * All 35 languages carry the new numbersw + **0.14.9** * Fix for Grappling hook * Trader window diagnostics, please report your log if you have issues opening the trader window diff --git a/EpicLoot/config/shardstones.json b/EpicLoot/config/shardstones.json index 7053f4331..10c841ec6 100644 --- a/EpicLoot/config/shardstones.json +++ b/EpicLoot/config/shardstones.json @@ -1,4 +1,4 @@ -{ +{ "Global": { "Values": { "MovementPenaltyReference": 0.4, @@ -2405,7 +2405,9 @@ "Ancient": 30 }, "Config": { - "MaxCharges": 15, + "MaxCharges": 18, + "ChargesPerValue": 0.3, + "MinCharges": 6, "DamageFraction": 0.3, "ConeLength": 4, "ConeMaxWidth": 4, @@ -2434,8 +2436,9 @@ "Ancient": 30 }, "Config": { - "Cooldown": 30, + "Cooldown": 60, "CooldownPerValue": 1.5, + "MinCooldown": 10, "BaseRadius": 6, "RadiusPerTier": 0.15 } @@ -2487,8 +2490,9 @@ "Ancient": 30 }, "Config": { - "BaseCooldown": 140, + "BaseCooldown": 200, "CooldownPerRarity": 20, + "MinCooldown": 30, "Radius": 8, "FrostPerTier": 8 } diff --git a/EpicLoot/localizations/Bulgarian.json b/EpicLoot/localizations/Bulgarian.json index 8eae2733d..74cc24c2b 100644 --- a/EpicLoot/localizations/Bulgarian.json +++ b/EpicLoot/localizations/Bulgarian.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Всяко претъркулване при отклоняване премахва {0} секунди от ефектите ти на отрова и горене.", "mod_epicloot_me_stormrider_display": "+{0} % скорост на движение при буря", "mod_epicloot_me_stormrider_desc": "+{0} % скорост на движение, докато бушува буря.", - "mod_epicloot_me_shockingcharge_display": "Поразяващ заряд", - "mod_epicloot_me_shockingcharge_desc": "Ударите ти в бой натрупват заряди. При пълен заряд следващият удар отприщва ударна вълна от мълнии напред, която нанася част от щетите на предишните ти удари.", - "mod_epicloot_me_forestsaid_display": "Помощта на гората", - "mod_epicloot_me_forestsaid_desc": "Когато те ударят, изригват оплитащи корени и обездвижват враговете наблизо.", - "mod_epicloot_me_corpserot_display": "Трупно гниене", - "mod_epicloot_me_corpserot_desc": "Враговете, които убиваш, могат да се пръснат в облак от отрова, тровейки противниците наблизо.", - "mod_epicloot_me_icyretribution_display": "Ледено възмездие", - "mod_epicloot_me_icyretribution_desc": "Когато те ударят, около теб избухва ледена нова. Презареждането се мащабира с рядкостта.", + "mod_epicloot_me_shockingcharge_display": "Поразяващ заряд: на всеки {0:0.#} удара", + "mod_epicloot_me_shockingcharge_desc": "На всеки {0:0.#} удара в бой следващият удар отприщва ударна вълна от мълнии напред, която нанася {1:0.#}% от нанесените с тях щети. По-високата рядкост изисква по-малко удари.", + "mod_epicloot_me_forestsaid_display": "Помощта на гората: {0:0.#} м, {1:0.#} с презареждане", + "mod_epicloot_me_forestsaid_desc": "Когато те ударят, изригват оплитащи корени и обездвижват враговете в радиус от {0:0.#} метра. По-високата рядкост достига по-далеч и се презарежда за {1:0.#} секунди.", + "mod_epicloot_me_corpserot_display": "Трупно гниене: {0:0.#} отрова", + "mod_epicloot_me_corpserot_desc": "Враговете, които убиваш, могат да се пръснат в облак от отрова, нанасяйки {0:0.#} отровни щети на противниците наблизо.", + "mod_epicloot_me_icyretribution_display": "Ледено възмездие: {0:0.#} мраз", + "mod_epicloot_me_icyretribution_desc": "Когато те ударят, около теб избухва ледена нова, нанасяща {0:0.#} мразовити щети. По-високата рядкост удря по-силно и се презарежда по-бързо.", "mod_epicloot_me_meteorsummoner_display": "Призовател на метеори: {0}× щетите от удара", "mod_epicloot_me_meteorsummoner_desc": "На всеки {1} удара с оръжие следващият ти удар сваля огнен метеор върху целта, който нанася {0}× щетите на този удар като огън.", - "mod_epicloot_me_everflow_display": "Вечният поток на Кралицата", - "mod_epicloot_me_everflow_desc": "Убиването на създание дава натрупващ се ефект, който подсилва възстановяването ти на здраве, издръжливост и ейтър.", - "mod_epicloot_me_necroticfire_display": "Некротичен огън", - "mod_epicloot_me_necroticfire_desc": "Премахва всички физически щети от оръжието ти и добавя цялата тази стойност и като огнени, и като отровни щети.", + "mod_epicloot_me_everflow_display": "Вечният поток на Кралицата: +{0:0.#}% възстановяване на слой", + "mod_epicloot_me_everflow_desc": "Убиването на създание дава натрупващ се ефект, който подсилва възстановяването ти на здраве, издръжливост и ейтър с {0:0.#}% на слой, до {1:0.#} слоя.", + "mod_epicloot_me_necroticfire_display": "Некротичен огън: +{0:0.#}% огън и отрова", + "mod_epicloot_me_necroticfire_desc": "Премахва всички физически щети от оръжието ти и добавя {0:0.#}% от тях като огнени щети и същото количество като отровни.", "mod_epicloot_circlet": "обръч", "mod_epicloot_belt": "колан", "mod_epicloot_remains": "остатъци", diff --git a/EpicLoot/localizations/Chinese.json b/EpicLoot/localizations/Chinese.json index 8819dce30..863985090 100644 --- a/EpicLoot/localizations/Chinese.json +++ b/EpicLoot/localizations/Chinese.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "每次翻滚闪避移除毒素与燃烧效果 {0} 秒的剩余时间。", "mod_epicloot_me_stormrider_display": "风暴中移动速度 +{0}%", "mod_epicloot_me_stormrider_desc": "风暴肆虐时移动速度 +{0}%。", - "mod_epicloot_me_shockingcharge_display": "震击充能", - "mod_epicloot_me_shockingcharge_desc": "战斗中每次命中都会积累充能。充满后,你的下一次打击会向前释放一道闪电冲击波,造成上一次命中伤害的一部分。", - "mod_epicloot_me_forestsaid_display": "森林之助", - "mod_epicloot_me_forestsaid_desc": "受到攻击时,缠绕的根须会破土而出,抓住附近的敌人。", - "mod_epicloot_me_corpserot_display": "尸腐", - "mod_epicloot_me_corpserot_desc": "被击杀的敌人可能炸开一团毒云,使附近的敌人中毒。", - "mod_epicloot_me_icyretribution_display": "寒冰惩戒", - "mod_epicloot_me_icyretribution_desc": "受到攻击时在你周围引发一场冰霜爆发。冷却时间随稀有度而变化。", + "mod_epicloot_me_shockingcharge_display": "震击充能:每 {0:0.#} 次命中", + "mod_epicloot_me_shockingcharge_desc": "战斗中每命中 {0:0.#} 次,下一次打击会向前释放一道闪电冲击波,造成这些命中所造成伤害的 {1:0.#}%。稀有度越高,所需命中次数越少。", + "mod_epicloot_me_forestsaid_display": "森林之助:{0:0.#} 米,冷却 {1:0.#} 秒", + "mod_epicloot_me_forestsaid_desc": "受到攻击时,缠绕的根须会破土而出,定身 {0:0.#} 米内的敌人。稀有度越高,范围越大,冷却时间为 {1:0.#} 秒。", + "mod_epicloot_me_corpserot_display": "尸腐:{0:0.#} 毒素", + "mod_epicloot_me_corpserot_desc": "被击杀的敌人可能炸开一团毒云,对附近的敌人造成 {0:0.#} 点毒素伤害。", + "mod_epicloot_me_icyretribution_display": "寒冰惩戒:{0:0.#} 冰霜", + "mod_epicloot_me_icyretribution_desc": "受到攻击时在你周围引发一场冰霜爆发,造成 {0:0.#} 点冰霜伤害。稀有度越高,伤害越高,冷却也越短。", "mod_epicloot_me_meteorsummoner_display": "陨石召唤者:命中伤害的 {0} 倍", "mod_epicloot_me_meteorsummoner_desc": "此武器每命中 {1} 次,下一次打击便会在目标身上召来一颗燃烧的陨石,造成该次打击 {0} 倍的火焰伤害。", - "mod_epicloot_me_everflow_display": "女王的永流", - "mod_epicloot_me_everflow_desc": "击杀生物可获得一层增益,提升生命、耐力与 Eitr 的回复。", - "mod_epicloot_me_necroticfire_display": "腐死之火", - "mod_epicloot_me_necroticfire_desc": "移除武器的全部物理伤害,并将其等量地同时追加为火焰伤害与毒素伤害。", + "mod_epicloot_me_everflow_display": "女王的永流:每层 +{0:0.#}% 回复", + "mod_epicloot_me_everflow_desc": "击杀生物可获得一层增益,每层提升生命、耐力与 Eitr 回复 {0:0.#}%,最多 {1:0.#} 层。", + "mod_epicloot_me_necroticfire_display": "腐死之火:+{0:0.#}% 火焰与毒素", + "mod_epicloot_me_necroticfire_desc": "移除武器的全部物理伤害,并将其中的 {0:0.#}% 追加为火焰伤害,同时以相同数值追加为毒素伤害。", "mod_epicloot_circlet": "头环", "mod_epicloot_belt": "腰带", "mod_epicloot_remains": "遗物", diff --git a/EpicLoot/localizations/Chinese_Trad.json b/EpicLoot/localizations/Chinese_Trad.json index c5067dd55..5d4f770c6 100644 --- a/EpicLoot/localizations/Chinese_Trad.json +++ b/EpicLoot/localizations/Chinese_Trad.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "每次翻滾閃避移除毒素與燃燒效果 {0} 秒的剩餘時間。", "mod_epicloot_me_stormrider_display": "風暴中移動速度 +{0}%", "mod_epicloot_me_stormrider_desc": "風暴肆虐時移動速度 +{0}%。", - "mod_epicloot_me_shockingcharge_display": "震擊充能", - "mod_epicloot_me_shockingcharge_desc": "戰鬥中每次命中都會累積充能。充滿後,你的下一次打擊會向前釋放一道閃電衝擊波,造成上一次命中傷害的一部分。", - "mod_epicloot_me_forestsaid_display": "森林之助", - "mod_epicloot_me_forestsaid_desc": "受到攻擊時,纏繞的根鬚會破土而出,抓住附近的敵人。", - "mod_epicloot_me_corpserot_display": "屍腐", - "mod_epicloot_me_corpserot_desc": "被擊殺的敵人可能炸開一團毒雲,使附近的敵人中毒。", - "mod_epicloot_me_icyretribution_display": "寒冰懲戒", - "mod_epicloot_me_icyretribution_desc": "受到攻擊時在你周圍引發一場冰霜爆發。冷卻時間隨稀有度而變化。", + "mod_epicloot_me_shockingcharge_display": "震擊充能:每 {0:0.#} 次命中", + "mod_epicloot_me_shockingcharge_desc": "戰鬥中每命中 {0:0.#} 次,下一次打擊會向前釋放一道閃電衝擊波,造成這些命中所造成傷害的 {1:0.#}%。稀有度越高,所需命中次數越少。", + "mod_epicloot_me_forestsaid_display": "森林之助:{0:0.#} 公尺,冷卻 {1:0.#} 秒", + "mod_epicloot_me_forestsaid_desc": "受到攻擊時,纏繞的根鬚會破土而出,定身 {0:0.#} 公尺內的敵人。稀有度越高,範圍越大,冷卻時間為 {1:0.#} 秒。", + "mod_epicloot_me_corpserot_display": "屍腐:{0:0.#} 毒素", + "mod_epicloot_me_corpserot_desc": "被擊殺的敵人可能炸開一團毒雲,對附近的敵人造成 {0:0.#} 點毒素傷害。", + "mod_epicloot_me_icyretribution_display": "寒冰懲戒:{0:0.#} 冰霜", + "mod_epicloot_me_icyretribution_desc": "受到攻擊時在你周圍引發一場冰霜爆發,造成 {0:0.#} 點冰霜傷害。稀有度越高,傷害越高,冷卻也越短。", "mod_epicloot_me_meteorsummoner_display": "隕石召喚者:命中傷害的 {0} 倍", "mod_epicloot_me_meteorsummoner_desc": "此武器每命中 {1} 次,下一次打擊便會在目標身上召來一顆燃燒的隕石,造成該次打擊 {0} 倍的火焰傷害。", - "mod_epicloot_me_everflow_display": "女王的永流", - "mod_epicloot_me_everflow_desc": "擊殺生物可獲得一層增益,提升生命、耐力與 Eitr 的回復。", - "mod_epicloot_me_necroticfire_display": "腐死之火", - "mod_epicloot_me_necroticfire_desc": "移除武器的全部物理傷害,並將其等量地同時追加為火焰傷害與毒素傷害。", + "mod_epicloot_me_everflow_display": "女王的永流:每層 +{0:0.#}% 回復", + "mod_epicloot_me_everflow_desc": "擊殺生物可獲得一層增益,每層提升生命、耐力與 Eitr 回復 {0:0.#}%,最多 {1:0.#} 層。", + "mod_epicloot_me_necroticfire_display": "腐死之火:+{0:0.#}% 火焰與毒素", + "mod_epicloot_me_necroticfire_desc": "移除武器的全部物理傷害,並將其中的 {0:0.#}% 追加為火焰傷害,同時以相同數值追加為毒素傷害。", "mod_epicloot_circlet": "頭飾", "mod_epicloot_belt": "腰帶", "mod_epicloot_remains": "殘骸", diff --git a/EpicLoot/localizations/Croatian.json b/EpicLoot/localizations/Croatian.json index cc99cd75f..33588496f 100644 --- a/EpicLoot/localizations/Croatian.json +++ b/EpicLoot/localizations/Croatian.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Svako kotrljanje za izmicanje uklanja {0} sekundi s tvojih otrovnih i gorućih učinaka.", "mod_epicloot_me_stormrider_display": "+{0} % brzine kretanja u olujama", "mod_epicloot_me_stormrider_desc": "+{0} % brzine kretanja dok bjesni oluja.", - "mod_epicloot_me_shockingcharge_display": "Šokantni naboj", - "mod_epicloot_me_shockingcharge_desc": "Tvoji borbeni pogoci skupljaju naboje. Pri punom naboju sljedeći pogodak oslobađa munjeviti udarni val prema naprijed koji nanosi dio štete tvojih prethodnih pogodaka.", - "mod_epicloot_me_forestsaid_display": "Pomoć šume", - "mod_epicloot_me_forestsaid_desc": "Kada te pogode, izbijaju sputavajuće korijenje i imobiliziraju obližnje neprijatelje.", - "mod_epicloot_me_corpserot_display": "Trupla trulež", - "mod_epicloot_me_corpserot_desc": "Neprijatelji koje ubiješ mogu prsnuti u oblak otrova, trujući obližnje protivnike.", - "mod_epicloot_me_icyretribution_display": "Ledena odmazda", - "mod_epicloot_me_icyretribution_desc": "Kada te pogode, detoniraš ledenu novu oko sebe. Vrijeme oporavka skalira se s rijetkošću.", + "mod_epicloot_me_shockingcharge_display": "Šokantni naboj: svakih {0:0.#} pogodaka", + "mod_epicloot_me_shockingcharge_desc": "Svakih {0:0.#} borbenih pogodaka sljedeći pogodak oslobađa munjeviti udarni val prema naprijed koji nanosi {1:0.#}% štete tih pogodaka. Viša rijetkost traži manje pogodaka.", + "mod_epicloot_me_forestsaid_display": "Pomoć šume: {0:0.#} m, {1:0.#} s hlađenja", + "mod_epicloot_me_forestsaid_desc": "Kada te pogode, izbija sputavajuće korijenje i imobilizira neprijatelje u krugu od {0:0.#} metara. Viša rijetkost doseže dalje i puni se za {1:0.#} sekundi.", + "mod_epicloot_me_corpserot_display": "Trupla trulež: {0:0.#} otrova", + "mod_epicloot_me_corpserot_desc": "Neprijatelji koje ubiješ mogu prsnuti u oblak otrova, nanoseći {0:0.#} štete otrovom obližnjim protivnicima.", + "mod_epicloot_me_icyretribution_display": "Ledena odmazda: {0:0.#} mraza", + "mod_epicloot_me_icyretribution_desc": "Kada te pogode, detoniraš ledenu novu oko sebe koja nanosi {0:0.#} štete mrazom. Viša rijetkost udara jače i brže se oporavlja.", "mod_epicloot_me_meteorsummoner_display": "Prizivač meteora: {0}× šteta pogotka", "mod_epicloot_me_meteorsummoner_desc": "Svakih {1} pogodaka oružjem tvoj sljedeći pogodak priziva vatreni meteor na metu koji nanosi {0}× štetu tog pogotka kao vatru.", - "mod_epicloot_me_everflow_display": "Vječni tok Kraljice", - "mod_epicloot_me_everflow_desc": "Ubijanje stvorenja daje složivi bonus koji pojačava regeneraciju zdravlja, izdržljivosti i eitra.", - "mod_epicloot_me_necroticfire_display": "Nekrotična vatra", - "mod_epicloot_me_necroticfire_desc": "Uklanja svu fizičku štetu s tvog oružja i dodaje taj cijeli iznos i kao vatrenu i kao otrovnu štetu.", + "mod_epicloot_me_everflow_display": "Vječni tok Kraljice: +{0:0.#}% regeneracije po sloju", + "mod_epicloot_me_everflow_desc": "Ubijanje stvorenja daje složivi bonus koji pojačava regeneraciju zdravlja, izdržljivosti i eitra za {0:0.#}% po sloju, do {1:0.#} slojeva.", + "mod_epicloot_me_necroticfire_display": "Nekrotična vatra: +{0:0.#}% vatre i otrova", + "mod_epicloot_me_necroticfire_desc": "Uklanja svu fizičku štetu s tvog oružja i dodaje {0:0.#}% toga kao vatrenu štetu te isto toliko kao otrovnu.", "mod_epicloot_circlet": "čelenka", "mod_epicloot_belt": "pojas", "mod_epicloot_remains": "ostaci", diff --git a/EpicLoot/localizations/Czech.json b/EpicLoot/localizations/Czech.json index ba06e2da5..b64b03168 100644 --- a/EpicLoot/localizations/Czech.json +++ b/EpicLoot/localizations/Czech.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Každý úhybový kotoul odebere {0} sekund z tvých jedových a hořících efektů.", "mod_epicloot_me_stormrider_display": "+{0} % rychlosti pohybu v bouřích", "mod_epicloot_me_stormrider_desc": "+{0} % rychlosti pohybu, dokud zuří bouře.", - "mod_epicloot_me_shockingcharge_display": "Šokující náboj", - "mod_epicloot_me_shockingcharge_desc": "Tvé zásahy v boji hromadí náboje. Při plném náboji další zásah vypustí dopřednou bleskovou rázovou vlnu, která způsobí část poškození tvých předchozích zásahů.", - "mod_epicloot_me_forestsaid_display": "Pomoc lesa", - "mod_epicloot_me_forestsaid_desc": "Když jsi zasažen, vyrazí spoutávající kořeny a znehybní nepřátele poblíž.", - "mod_epicloot_me_corpserot_display": "Mrtvolný rozklad", - "mod_epicloot_me_corpserot_desc": "Nepřátelé, které zabiješ, mohou puknout v oblak jedu a otrávit protivníky poblíž.", - "mod_epicloot_me_icyretribution_display": "Ledová odplata", - "mod_epicloot_me_icyretribution_desc": "Když jsi zasažen, odpálíš kolem sebe mrazivou novu. Doba obnovy se škáluje se vzácností.", + "mod_epicloot_me_shockingcharge_display": "Šokující náboj: každých {0:0.#} zásahů", + "mod_epicloot_me_shockingcharge_desc": "Každých {0:0.#} zásahů v boji vypustí další zásah dopřednou bleskovou rázovou vlnu, která způsobí {1:0.#} % poškození těchto zásahů. Vyšší vzácnost vyžaduje méně zásahů.", + "mod_epicloot_me_forestsaid_display": "Pomoc lesa: {0:0.#} m, {1:0.#} s nabíjení", + "mod_epicloot_me_forestsaid_desc": "Když jsi zasažen, vyrazí spoutávající kořeny a znehybní nepřátele do {0:0.#} metrů. Vyšší vzácnost dosáhne dál a nabije se za {1:0.#} sekund.", + "mod_epicloot_me_corpserot_display": "Mrtvolný rozklad: {0:0.#} jedu", + "mod_epicloot_me_corpserot_desc": "Nepřátelé, které zabiješ, mohou puknout v oblak jedu a způsobit protivníkům poblíž {0:0.#} poškození jedem.", + "mod_epicloot_me_icyretribution_display": "Ledová odplata: {0:0.#} mrazu", + "mod_epicloot_me_icyretribution_desc": "Když jsi zasažen, odpálíš kolem sebe mrazivou novu, která způsobí {0:0.#} poškození mrazem. Vyšší vzácnost udeří silněji a obnoví se dřív.", "mod_epicloot_me_meteorsummoner_display": "Přivolávač meteorů: {0}× poškození zásahu", "mod_epicloot_me_meteorsummoner_desc": "Každých {1} zásahů zbraní svolá tvůj další zásah na cíl ohnivý meteor, který způsobí {0}× poškození toho zásahu jako oheň.", - "mod_epicloot_me_everflow_display": "Věčný tok Královny", - "mod_epicloot_me_everflow_desc": "Zabití tvora udělí vrstvící se posílení, které zvyšuje regeneraci zdraví, výdrže a eitru.", - "mod_epicloot_me_necroticfire_display": "Nekrotický oheň", - "mod_epicloot_me_necroticfire_desc": "Odebere tvé zbrani veškeré fyzické poškození a přidá celou tuto hodnotu jako ohnivé i jedovaté poškození.", + "mod_epicloot_me_everflow_display": "Věčný tok Královny: +{0:0.#} % regenerace za vrstvu", + "mod_epicloot_me_everflow_desc": "Zabití tvora udělí vrstvící se posílení, které zvyšuje regeneraci zdraví, výdrže a eitru o {0:0.#} % za vrstvu, až do {1:0.#} vrstev.", + "mod_epicloot_me_necroticfire_display": "Nekrotický oheň: +{0:0.#} % ohně a jedu", + "mod_epicloot_me_necroticfire_desc": "Odebere tvé zbrani veškeré fyzické poškození a přidá {0:0.#} % z něj jako ohnivé poškození a stejně tolik jako jedovaté.", "mod_epicloot_circlet": "přilba", "mod_epicloot_belt": "pás", "mod_epicloot_remains": "zbytky", diff --git a/EpicLoot/localizations/Danish.json b/EpicLoot/localizations/Danish.json index cc18a449f..e6a5b352d 100644 --- a/EpicLoot/localizations/Danish.json +++ b/EpicLoot/localizations/Danish.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Hver undvigelsesrulning fjerner {0} sekunder fra dine gift- og brandeffekter.", "mod_epicloot_me_stormrider_display": "+{0} % bevægelsesfart i storme", "mod_epicloot_me_stormrider_desc": "+{0} % bevægelsesfart mens en storm raser.", - "mod_epicloot_me_shockingcharge_display": "Chokerende ladning", - "mod_epicloot_me_shockingcharge_desc": "Dine træf i kamp opbygger ladninger. Ved fuld ladning udløser næste træf en fremadrettet lynchokbølge, der giver en del af skaden fra dine tidligere træf.", - "mod_epicloot_me_forestsaid_display": "Skovens hjælp", - "mod_epicloot_me_forestsaid_desc": "Når du bliver ramt, bryder snærende rødder frem og binder fjender i nærheden.", - "mod_epicloot_me_corpserot_display": "Ligrådne", - "mod_epicloot_me_corpserot_desc": "Fjender du dræber kan briste i en sky af gift og forgifte modstandere i nærheden.", - "mod_epicloot_me_icyretribution_display": "Iskold gengældelse", - "mod_epicloot_me_icyretribution_desc": "Når du bliver ramt, udløser du en frostnova omkring dig. Nedkølingen skalerer med sjældenheden.", + "mod_epicloot_me_shockingcharge_display": "Chokerende ladning: hver {0:0.#} træf", + "mod_epicloot_me_shockingcharge_desc": "For hver {0:0.#} træf i kamp udløser det næste træf en fremadrettet lynchokbølge, der giver {1:0.#}% af skaden fra de træf. Højere sjældenhed kræver færre træf.", + "mod_epicloot_me_forestsaid_display": "Skovens hjælp: {0:0.#} m, {1:0.#} s nedkøling", + "mod_epicloot_me_forestsaid_desc": "Når du bliver ramt, bryder snærende rødder frem og binder fjender inden for {0:0.#} meter. Højere sjældenhed rækker længere og lades op på {1:0.#} sekunder.", + "mod_epicloot_me_corpserot_display": "Ligrådne: {0:0.#} gift", + "mod_epicloot_me_corpserot_desc": "Fjender du dræber kan briste i en sky af gift og give modstandere i nærheden {0:0.#} giftskade.", + "mod_epicloot_me_icyretribution_display": "Iskold gengældelse: {0:0.#} frost", + "mod_epicloot_me_icyretribution_desc": "Når du bliver ramt, udløser du en frostnova omkring dig, der giver {0:0.#} frostskade. Højere sjældenhed rammer hårdere og lades hurtigere op.", "mod_epicloot_me_meteorsummoner_display": "Meteortilkalder: {0}× træfskade", "mod_epicloot_me_meteorsummoner_desc": "Hvert {1} våbentræf kalder dit næste træf en brændende meteor ned på målet, som giver {0}× det træfs skade som ild.", - "mod_epicloot_me_everflow_display": "Dronningens evige strøm", - "mod_epicloot_me_everflow_desc": "At dræbe et væsen giver en stabelbar effekt, der øger din helbreds-, udholdenheds- og eitrregeneration.", - "mod_epicloot_me_necroticfire_display": "Nekrotisk ild", - "mod_epicloot_me_necroticfire_desc": "Fjerner al fysisk skade fra dit våben og tilføjer hele beløbet som både ild- og giftskade.", + "mod_epicloot_me_everflow_display": "Dronningens evige strøm: +{0:0.#}% regeneration pr. stak", + "mod_epicloot_me_everflow_desc": "At dræbe et væsen giver en stabelbar effekt, der øger din helbreds-, udholdenheds- og eitrregeneration med {0:0.#}% pr. stak, op til {1:0.#} stakke.", + "mod_epicloot_me_necroticfire_display": "Nekrotisk ild: +{0:0.#}% ild og gift", + "mod_epicloot_me_necroticfire_desc": "Fjerner al fysisk skade fra dit våben og tilføjer {0:0.#}% af den som ildskade og lige så meget som giftskade.", "mod_epicloot_circlet": "cirkel", "mod_epicloot_belt": "bælte", "mod_epicloot_remains": "rester", diff --git a/EpicLoot/localizations/Dutch.json b/EpicLoot/localizations/Dutch.json index 41fd0ccf3..9c9b09fe0 100644 --- a/EpicLoot/localizations/Dutch.json +++ b/EpicLoot/localizations/Dutch.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Elke ontwijkrol haalt {0} seconden van je gif- en brandeffecten af.", "mod_epicloot_me_stormrider_display": "+{0} % loopsnelheid tijdens stormen", "mod_epicloot_me_stormrider_desc": "+{0} % loopsnelheid terwijl er een storm woedt.", - "mod_epicloot_me_shockingcharge_display": "Schokkende lading", - "mod_epicloot_me_shockingcharge_desc": "Je gevechtstreffers bouwen ladingen op. Bij volle lading ontketent de volgende treffer een voorwaartse bliksemschokgolf die een deel van de schade van je vorige treffers toebrengt.", - "mod_epicloot_me_forestsaid_display": "Hulp van het woud", - "mod_epicloot_me_forestsaid_desc": "Wanneer je geraakt wordt, breken verstrikkende wortels los die vijanden in de buurt vastzetten.", - "mod_epicloot_me_corpserot_display": "Lijkenrot", - "mod_epicloot_me_corpserot_desc": "Vijanden die je doodt kunnen uiteenspatten in een wolk van gif en zo nabije tegenstanders vergiftigen.", - "mod_epicloot_me_icyretribution_display": "IJzige vergelding", - "mod_epicloot_me_icyretribution_desc": "Wanneer je geraakt wordt, laat je een vorstnova om je heen ontploffen. De afkoeltijd schaalt met de zeldzaamheid.", + "mod_epicloot_me_shockingcharge_display": "Schokkende lading: elke {0:0.#} treffers", + "mod_epicloot_me_shockingcharge_desc": "Elke {0:0.#} gevechtstreffers ontketent de volgende treffer een voorwaartse bliksemschokgolf die {1:0.#}% van de schade van die treffers toebrengt. Een hogere zeldzaamheid vergt minder treffers.", + "mod_epicloot_me_forestsaid_display": "Hulp van het woud: {0:0.#} m, {1:0.#} s afkoeling", + "mod_epicloot_me_forestsaid_desc": "Wanneer je geraakt wordt, breken verstrikkende wortels los die vijanden binnen {0:0.#} meter vastzetten. Een hogere zeldzaamheid reikt verder en laadt in {1:0.#} seconden op.", + "mod_epicloot_me_corpserot_display": "Lijkenrot: {0:0.#} gif", + "mod_epicloot_me_corpserot_desc": "Vijanden die je doodt kunnen uiteenspatten in een wolk van gif en nabije tegenstanders {0:0.#} gifschade toebrengen.", + "mod_epicloot_me_icyretribution_display": "IJzige vergelding: {0:0.#} vorst", + "mod_epicloot_me_icyretribution_desc": "Wanneer je geraakt wordt, laat je een vorstnova om je heen ontploffen die {0:0.#} vorstschade toebrengt. Een hogere zeldzaamheid slaat harder toe en laadt sneller op.", "mod_epicloot_me_meteorsummoner_display": "Meteoroproeper: {0}× trefferschade", "mod_epicloot_me_meteorsummoner_desc": "Elke {1} wapentreffers roept je volgende treffer een vurige meteoor op het doel op die {0}× de schade van die treffer als vuur toebrengt.", - "mod_epicloot_me_everflow_display": "Eeuwige stroom van de Koningin", - "mod_epicloot_me_everflow_desc": "Een wezen doden verleent een stapelbaar effect dat je gezondheids-, stamina- en eitrregeneratie verhoogt.", - "mod_epicloot_me_necroticfire_display": "Necrotisch vuur", - "mod_epicloot_me_necroticfire_desc": "Haalt alle fysieke schade van je wapen af en voegt dat volledige bedrag toe als zowel vuur- als gifschade.", + "mod_epicloot_me_everflow_display": "Eeuwige stroom van de Koningin: +{0:0.#}% regeneratie per stapel", + "mod_epicloot_me_everflow_desc": "Een wezen doden verleent een stapelbaar effect dat je gezondheids-, stamina- en eitrregeneratie met {0:0.#}% per stapel verhoogt, tot {1:0.#} stapels.", + "mod_epicloot_me_necroticfire_display": "Necrotisch vuur: +{0:0.#}% vuur en gif", + "mod_epicloot_me_necroticfire_desc": "Haalt alle fysieke schade van je wapen af en voegt {0:0.#}% daarvan toe als vuurschade en evenveel als gifschade.", "mod_epicloot_circlet": "hoofdband", "mod_epicloot_belt": "riem", "mod_epicloot_remains": "overblijfselen", diff --git a/EpicLoot/localizations/English.json b/EpicLoot/localizations/English.json index c16e182a6..f58063a2d 100644 --- a/EpicLoot/localizations/English.json +++ b/EpicLoot/localizations/English.json @@ -70,6 +70,10 @@ "mod_epicloot_config_baseradius": "Base Radius (m)", "mod_epicloot_config_radiuspertier": "Radius per Value (m)", "mod_epicloot_config_maxcharges": "Hits to Charge", + "mod_epicloot_me_shockingcharge_config_maxcharges": "Hits to Charge (Base)", + "mod_epicloot_config_chargespervalue": "Hits Saved per Value", + "mod_epicloot_config_mincharges": "Min Hits to Charge", + "mod_epicloot_config_mincooldown": "Min Cooldown (s)", "mod_epicloot_config_damagepertrigger": "Damage per Trigger", "mod_epicloot_config_eitrpertrigger": "Eitr per Trigger", "mod_epicloot_config_damagemultiplier": "Damage Multiplier", @@ -656,20 +660,20 @@ "mod_epicloot_me_stormrider_display": "+{0}% Movement Speed in Storms", "mod_epicloot_me_stormrider_desc": "+{0}% movement speed while a storm rages.", - "mod_epicloot_me_shockingcharge_display": "Shocking Charge", - "mod_epicloot_me_shockingcharge_desc": "Your combat hits build charges. At max charge, the next hit unleashes a forward lightning shockwave that deals a portion of your previous hits' damage.", - "mod_epicloot_me_forestsaid_display": "Forest's Aid", - "mod_epicloot_me_forestsaid_desc": "When you are hit, ensnaring roots erupt and immobilize nearby enemies.", - "mod_epicloot_me_corpserot_display": "Corpse Rot", - "mod_epicloot_me_corpserot_desc": "Enemies you kill can burst in a cloud of venom, poisoning nearby foes.", - "mod_epicloot_me_icyretribution_display": "Icy Retribution", - "mod_epicloot_me_icyretribution_desc": "When you are hit, detonate a frost nova around you. Cooldown scales with rarity.", + "mod_epicloot_me_shockingcharge_display": "Shocking Charge: Every {0:0.#} Hits", + "mod_epicloot_me_shockingcharge_desc": "Every {0:0.#} combat hits, the next hit unleashes a forward lightning shockwave dealing {1:0.#}% of the damage those hits did. A higher rarity shard needs fewer hits.", + "mod_epicloot_me_forestsaid_display": "Forest's Aid: {0:0.#}m, {1:0.#}s Cooldown", + "mod_epicloot_me_forestsaid_desc": "When you are hit, ensnaring roots erupt and immobilize enemies within {0:0.#} meters. A higher rarity shard reaches further and recharges in {1:0.#} seconds.", + "mod_epicloot_me_corpserot_display": "Corpse Rot: {0:0.#} Poison", + "mod_epicloot_me_corpserot_desc": "Enemies you kill can burst in a cloud of venom, dealing {0:0.#} poison damage to nearby foes.", + "mod_epicloot_me_icyretribution_display": "Icy Retribution: {0:0.#} Frost", + "mod_epicloot_me_icyretribution_desc": "When you are hit, detonate a frost nova around you dealing {0:0.#} frost damage. A higher rarity shard hits harder and recharges sooner.", "mod_epicloot_me_meteorsummoner_display": "Meteor Summoner: {0}x Hit Damage", "mod_epicloot_me_meteorsummoner_desc": "Every {1} weapon hits, your next hit calls down a fiery meteor on the target that deals {0}x that hit's damage as fire.", - "mod_epicloot_me_everflow_display": "Queen's Everflow", - "mod_epicloot_me_everflow_desc": "Killing a creature grants a stacking buff that boosts your health, stamina and eitr regeneration.", - "mod_epicloot_me_necroticfire_display": "Necrotic Fire", - "mod_epicloot_me_necroticfire_desc": "Strips all physical damage from your weapon and adds that full amount as both fire and poison damage.", + "mod_epicloot_me_everflow_display": "Queen's Everflow: +{0:0.#}% Regen per Stack", + "mod_epicloot_me_everflow_desc": "Killing a creature grants a stacking buff that boosts your health, stamina and eitr regeneration by {0:0.#}% per stack, up to {1:0.#} stacks.", + "mod_epicloot_me_necroticfire_display": "Necrotic Fire: +{0:0.#}% Fire and Poison", + "mod_epicloot_me_necroticfire_desc": "Strips all physical damage from your weapon and adds {0:0.#}% of it back as fire damage, and the same again as poison damage.", "mod_epicloot_circlet": "circlet", diff --git a/EpicLoot/localizations/Finnish.json b/EpicLoot/localizations/Finnish.json index 83797f136..1f69b0b08 100644 --- a/EpicLoot/localizations/Finnish.json +++ b/EpicLoot/localizations/Finnish.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Jokainen väistökierähdys poistaa {0} sekuntia myrkky- ja palovaikutuksistasi.", "mod_epicloot_me_stormrider_display": "+{0} % liikkumisnopeutta myrskyissä", "mod_epicloot_me_stormrider_desc": "+{0} % liikkumisnopeutta myrskyn raivotessa.", - "mod_epicloot_me_shockingcharge_display": "Sähköistävä lataus", - "mod_epicloot_me_shockingcharge_desc": "Taisteluosumasi keräävät latauksia. Täydellä latauksella seuraava osuma vapauttaa eteenpäin suuntautuvan salamashokkiaallon, joka aiheuttaa osan aiempien osumiesi vahingosta.", - "mod_epicloot_me_forestsaid_display": "Metsän apu", - "mod_epicloot_me_forestsaid_desc": "Kun sinuun osutaan, kietovat juuret puhkeavat esiin ja pidättelevät lähellä olevia vihollisia.", - "mod_epicloot_me_corpserot_display": "Raadonmätä", - "mod_epicloot_me_corpserot_desc": "Tappamasi viholliset voivat purkautua myrkkypilveksi ja myrkyttää lähellä olevat vastustajat.", - "mod_epicloot_me_icyretribution_display": "Jäinen kosto", - "mod_epicloot_me_icyretribution_desc": "Kun sinuun osutaan, laukaiset ympärillesi pakkasnovan. Jäähtymisaika skaalautuu harvinaisuuden mukaan.", + "mod_epicloot_me_shockingcharge_display": "Sähköistävä lataus: joka {0:0.#} osuma", + "mod_epicloot_me_shockingcharge_desc": "Joka {0:0.#} taisteluosuman jälkeen seuraava osuma vapauttaa eteenpäin suuntautuvan salamashokkiaallon, joka aiheuttaa {1:0.#} % noiden osumien vahingosta. Korkeampi harvinaisuus vaatii vähemmän osumia.", + "mod_epicloot_me_forestsaid_display": "Metsän apu: {0:0.#} m, {1:0.#} s lataus", + "mod_epicloot_me_forestsaid_desc": "Kun sinuun osutaan, kietovat juuret puhkeavat esiin ja pidättelevät vihollisia {0:0.#} metrin säteellä. Korkeampi harvinaisuus yltää kauemmas ja latautuu {1:0.#} sekunnissa.", + "mod_epicloot_me_corpserot_display": "Raadonmätä: {0:0.#} myrkkyä", + "mod_epicloot_me_corpserot_desc": "Tappamasi viholliset voivat purkautua myrkkypilveksi ja aiheuttaa lähellä oleville vastustajille {0:0.#} myrkkyvahinkoa.", + "mod_epicloot_me_icyretribution_display": "Jäinen kosto: {0:0.#} pakkasta", + "mod_epicloot_me_icyretribution_desc": "Kun sinuun osutaan, laukaiset ympärillesi pakkasnovan, joka aiheuttaa {0:0.#} pakkasvahinkoa. Korkeampi harvinaisuus iskee kovempaa ja latautuu nopeammin.", "mod_epicloot_me_meteorsummoner_display": "Meteorinkutsuja: {0}× osumavahinko", "mod_epicloot_me_meteorsummoner_desc": "Joka {1} aseosuman jälkeen seuraava osumasi kutsuu tulisen meteorin kohteeseen, joka aiheuttaa {0}× kyseisen osuman vahingon tulena.", - "mod_epicloot_me_everflow_display": "Kuningattaren ikuinen virta", - "mod_epicloot_me_everflow_desc": "Olennon tappaminen antaa pinoutuvan vaikutuksen, joka parantaa terveyden, kestävyyden ja eitrin palautumista.", - "mod_epicloot_me_necroticfire_display": "Nekroottinen tuli", - "mod_epicloot_me_necroticfire_desc": "Poistaa aseestasi kaiken fyysisen vahingon ja lisää saman määrän sekä tuli- että myrkkyvahinkona.", + "mod_epicloot_me_everflow_display": "Kuningattaren ikuinen virta: +{0:0.#} % palautumista per pino", + "mod_epicloot_me_everflow_desc": "Olennon tappaminen antaa pinoutuvan vaikutuksen, joka parantaa terveyden, kestävyyden ja eitrin palautumista {0:0.#} % per pino, enintään {1:0.#} pinoa.", + "mod_epicloot_me_necroticfire_display": "Nekroottinen tuli: +{0:0.#} % tulta ja myrkkyä", + "mod_epicloot_me_necroticfire_desc": "Poistaa aseestasi kaiken fyysisen vahingon ja lisää siitä {0:0.#} % tulivahinkona ja saman verran myrkkyvahinkona.", "mod_epicloot_circlet": "rengas", "mod_epicloot_belt": "vyö", "mod_epicloot_remains": "jäännökset", diff --git a/EpicLoot/localizations/French.json b/EpicLoot/localizations/French.json index 24bf758b6..abef0c055 100644 --- a/EpicLoot/localizations/French.json +++ b/EpicLoot/localizations/French.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Chaque roulade d'esquive retire {0} secondes à vos effets de poison et de brûlure.", "mod_epicloot_me_stormrider_display": "+{0} % de vitesse de déplacement pendant les tempêtes", "mod_epicloot_me_stormrider_desc": "+{0} % de vitesse de déplacement tant qu'une tempête fait rage.", - "mod_epicloot_me_shockingcharge_display": "Charge foudroyante", - "mod_epicloot_me_shockingcharge_desc": "Vos coups au combat accumulent des charges. À charge maximale, le coup suivant libère une onde de choc électrique vers l'avant qui inflige une partie des dégâts de vos coups précédents.", - "mod_epicloot_me_forestsaid_display": "Aide de la forêt", - "mod_epicloot_me_forestsaid_desc": "Quand vous êtes touché, des racines jaillissent et immobilisent les ennemis proches.", - "mod_epicloot_me_corpserot_display": "Pourriture cadavérique", - "mod_epicloot_me_corpserot_desc": "Les ennemis que vous tuez peuvent éclater en un nuage de venin, empoisonnant les adversaires proches.", - "mod_epicloot_me_icyretribution_display": "Rétribution glaciale", - "mod_epicloot_me_icyretribution_desc": "Quand vous êtes touché, déclenche une nova de givre autour de vous. Le temps de recharge dépend de la rareté.", + "mod_epicloot_me_shockingcharge_display": "Charge foudroyante : tous les {0:0.#} coups", + "mod_epicloot_me_shockingcharge_desc": "Tous les {0:0.#} coups au combat, le coup suivant libère une onde de choc électrique vers l'avant qui inflige {1:0.#} % des dégâts de ces coups. Une rareté supérieure demande moins de coups.", + "mod_epicloot_me_forestsaid_display": "Aide de la forêt : {0:0.#} m, {1:0.#} s de recharge", + "mod_epicloot_me_forestsaid_desc": "Quand vous êtes touché, des racines jaillissent et immobilisent les ennemis dans un rayon de {0:0.#} mètres. Une rareté supérieure porte plus loin et se recharge en {1:0.#} secondes.", + "mod_epicloot_me_corpserot_display": "Pourriture cadavérique : {0:0.#} poison", + "mod_epicloot_me_corpserot_desc": "Les ennemis que vous tuez peuvent éclater en un nuage de venin, infligeant {0:0.#} dégâts de poison aux adversaires proches.", + "mod_epicloot_me_icyretribution_display": "Rétribution glaciale : {0:0.#} givre", + "mod_epicloot_me_icyretribution_desc": "Quand vous êtes touché, déclenche une nova de givre autour de vous qui inflige {0:0.#} dégâts de givre. Une rareté supérieure frappe plus fort et se recharge plus vite.", "mod_epicloot_me_meteorsummoner_display": "Invocateur de météore : ×{0} des dégâts du coup", "mod_epicloot_me_meteorsummoner_desc": "Tous les {1} coups d'arme, votre coup suivant fait s'abattre un météore enflammé sur la cible, infligeant {0} fois les dégâts de ce coup en feu.", - "mod_epicloot_me_everflow_display": "Flux éternel de la Reine", - "mod_epicloot_me_everflow_desc": "Tuer une créature accorde un effet cumulable qui améliore votre régénération de santé, d'endurance et d'eitr.", - "mod_epicloot_me_necroticfire_display": "Feu nécrotique", - "mod_epicloot_me_necroticfire_desc": "Retire tous les dégâts physiques de votre arme et ajoute ce montant complet à la fois en dégâts de feu et de poison.", + "mod_epicloot_me_everflow_display": "Flux éternel de la Reine : +{0:0.#} % de régénération par cumul", + "mod_epicloot_me_everflow_desc": "Tuer une créature accorde un effet cumulable qui améliore votre régénération de santé, d'endurance et d'eitr de {0:0.#} % par cumul, jusqu'à {1:0.#} cumuls.", + "mod_epicloot_me_necroticfire_display": "Feu nécrotique : +{0:0.#} % de feu et de poison", + "mod_epicloot_me_necroticfire_desc": "Retire tous les dégâts physiques de votre arme et en ajoute {0:0.#} % en dégâts de feu, puis autant en dégâts de poison.", "mod_epicloot_circlet": "diadème", "mod_epicloot_belt": "ceinture", "mod_epicloot_remains": "restes", diff --git a/EpicLoot/localizations/Georgian.json b/EpicLoot/localizations/Georgian.json index ca78557e6..fc678e8b2 100644 --- a/EpicLoot/localizations/Georgian.json +++ b/EpicLoot/localizations/Georgian.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "ყოველი გადაგორება აშორებს {0} წამს შხამისა და წვის ეფექტებს.", "mod_epicloot_me_stormrider_display": "+{0}% მოძრაობის სიჩქარე ქარიშხალში", "mod_epicloot_me_stormrider_desc": "+{0}% მოძრაობის სიჩქარე, სანამ ქარიშხალი მძვინვარებს.", - "mod_epicloot_me_shockingcharge_display": "მაშოკებელი მუხტი", - "mod_epicloot_me_shockingcharge_desc": "ბრძოლაში მიყენებული დარტყმები აგროვებს მუხტს. სავსეობისას თქვენი შემდეგი დარტყმა წინ ისვრის ელვის დარტყმით ტალღას, რომელიც წინა დარტყმის ზიანის ნაწილს აყენებს.", - "mod_epicloot_me_forestsaid_display": "ტყის დახმარება", - "mod_epicloot_me_forestsaid_desc": "დარტყმის მიღებისას მჭიდრო ფესვები ამოიჭრება მიწიდან და ახლომდებარე მტრებს იჭერს.", - "mod_epicloot_me_corpserot_display": "გვამის ლპობა", - "mod_epicloot_me_corpserot_desc": "მოკლული მტრები შეიძლება შხამის ღრუბლად აფეთქდნენ და ახლომდებარე მტრები მოწამლონ.", - "mod_epicloot_me_icyretribution_display": "ყინულოვანი შურისძიება", - "mod_epicloot_me_icyretribution_desc": "დარტყმის მიღებისას თქვენს ირგვლივ ყინვის აფეთქებას იწვევს. განმუხტვის დრო იშვიათობის მიხედვით იცვლება.", + "mod_epicloot_me_shockingcharge_display": "მაშოკებელი მუხტი: ყოველ {0:0.#} დარტყმაზე", + "mod_epicloot_me_shockingcharge_desc": "ბრძოლაში ყოველ {0:0.#} დარტყმაზე შემდეგი დარტყმა წინ ისვრის ელვის დარტყმით ტალღას, რომელიც ამ დარტყმების ზიანის {1:0.#}%-ს აყენებს. უფრო მაღალი იშვიათობა ნაკლებ დარტყმას მოითხოვს.", + "mod_epicloot_me_forestsaid_display": "ტყის დახმარება: {0:0.#} მ, {1:0.#} წმ დაყოვნება", + "mod_epicloot_me_forestsaid_desc": "დარტყმის მიღებისას მჭიდრო ფესვები ამოიჭრება მიწიდან და {0:0.#} მეტრის რადიუსში მტრებს იჭერს. უფრო მაღალი იშვიათობა შორს აღწევს და {1:0.#} წამში ივსება.", + "mod_epicloot_me_corpserot_display": "გვამის ლპობა: {0:0.#} შხამი", + "mod_epicloot_me_corpserot_desc": "მოკლული მტრები შეიძლება შხამის ღრუბლად აფეთქდნენ და ახლომდებარე მტრებს {0:0.#} შხამის ზიანი მიაყენონ.", + "mod_epicloot_me_icyretribution_display": "ყინულოვანი შურისძიება: {0:0.#} ყინვა", + "mod_epicloot_me_icyretribution_desc": "დარტყმის მიღებისას თქვენს ირგვლივ ყინვის აფეთქებას იწვევს, რომელიც {0:0.#} ყინვის ზიანს აყენებს. უფრო მაღალი იშვიათობა უფრო ძლიერად ურტყამს და უფრო სწრაფად ივსება.", "mod_epicloot_me_meteorsummoner_display": "მეტეორის მომხმობი: დარტყმის ზიანის {0}-ჯერ", "mod_epicloot_me_meteorsummoner_desc": "ამ იარაღით ყოველი {1} დარტყმის შემდეგ მომდევნო დარტყმა სამიზნეზე მოგიზგიზე მეტეორს ჩამოაგდებს, რომელიც იმ დარტყმის {0}-ჯერად ცეცხლის ზიანს აყენებს.", - "mod_epicloot_me_everflow_display": "დედოფლის მარადდინება", - "mod_epicloot_me_everflow_desc": "არსებების მოკვლა გაძლევთ დამგროვებელ გაძლიერებას, რომელიც ზრდის ჯანმრთელობის, სტამინისა და ეიტრის აღდგენას.", - "mod_epicloot_me_necroticfire_display": "მკვდარი ცეცხლი", - "mod_epicloot_me_necroticfire_desc": "აშორებს იარაღს მთელ ფიზიკურ ზიანს და მთელ ამ ოდენობას ამატებს როგორც ცეცხლის, ისე შხამის ზიანში.", + "mod_epicloot_me_everflow_display": "დედოფლის მარადდინება: +{0:0.#}% აღდგენა თითო დონეზე", + "mod_epicloot_me_everflow_desc": "არსებების მოკვლა გაძლევთ დამგროვებელ გაძლიერებას, რომელიც ჯანმრთელობის, სტამინისა და ეიტრის აღდგენას {0:0.#}%-ით ზრდის თითო დონეზე, მაქსიმუმ {1:0.#} დონემდე.", + "mod_epicloot_me_necroticfire_display": "მკვდარი ცეცხლი: +{0:0.#}% ცეცხლი და შხამი", + "mod_epicloot_me_necroticfire_desc": "აშორებს იარაღს მთელ ფიზიკურ ზიანს და მისგან {0:0.#}%-ს ამატებს ცეცხლის ზიანად, იმავე ოდენობას კი შხამის ზიანად.", "mod_epicloot_circlet": "გამკლება", "mod_epicloot_belt": "ქსელი", "mod_epicloot_remains": "დარჩილები", diff --git a/EpicLoot/localizations/German.json b/EpicLoot/localizations/German.json index a8048295b..2e12f0067 100644 --- a/EpicLoot/localizations/German.json +++ b/EpicLoot/localizations/German.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Jede Ausweichrolle entfernt {0} Sekunden von deinen Gift- und Brandeffekten.", "mod_epicloot_me_stormrider_display": "+{0} % Bewegungstempo im Sturm", "mod_epicloot_me_stormrider_desc": "+{0} % Bewegungstempo, während ein Sturm tobt.", - "mod_epicloot_me_shockingcharge_display": "Schockende Ladung", - "mod_epicloot_me_shockingcharge_desc": "Deine Kampftreffer bauen Ladungen auf. Bei voller Ladung entfesselt der nächste Treffer eine nach vorn gerichtete Blitzschockwelle, die einen Teil des Schadens deiner vorherigen Treffer verursacht.", - "mod_epicloot_me_forestsaid_display": "Hilfe des Waldes", - "mod_epicloot_me_forestsaid_desc": "Wirst du getroffen, brechen fesselnde Wurzeln hervor und halten Gegner in der Nähe fest.", - "mod_epicloot_me_corpserot_display": "Leichenfäule", - "mod_epicloot_me_corpserot_desc": "Von dir getötete Gegner können in einer Giftwolke zerplatzen und Feinde in der Nähe vergiften.", - "mod_epicloot_me_icyretribution_display": "Eisige Vergeltung", - "mod_epicloot_me_icyretribution_desc": "Wirst du getroffen, zündest du eine Frostnova um dich herum. Die Abklingzeit skaliert mit der Seltenheit.", + "mod_epicloot_me_shockingcharge_display": "Schockende Ladung: alle {0:0.#} Treffer", + "mod_epicloot_me_shockingcharge_desc": "Alle {0:0.#} Kampftreffer entfesselt der nächste Treffer eine nach vorn gerichtete Blitzschockwelle, die {1:0.#} % des Schadens dieser Treffer verursacht. Eine höhere Seltenheit benötigt weniger Treffer.", + "mod_epicloot_me_forestsaid_display": "Hilfe des Waldes: {0:0.#} m, {1:0.#} s Abklingzeit", + "mod_epicloot_me_forestsaid_desc": "Wirst du getroffen, brechen fesselnde Wurzeln hervor und halten Gegner im Umkreis von {0:0.#} Metern fest. Eine höhere Seltenheit reicht weiter und lädt in {1:0.#} Sekunden nach.", + "mod_epicloot_me_corpserot_display": "Leichenfäule: {0:0.#} Gift", + "mod_epicloot_me_corpserot_desc": "Von dir getötete Gegner können in einer Giftwolke zerplatzen und Feinde in der Nähe mit {0:0.#} Giftschaden treffen.", + "mod_epicloot_me_icyretribution_display": "Eisige Vergeltung: {0:0.#} Frost", + "mod_epicloot_me_icyretribution_desc": "Wirst du getroffen, zündest du eine Frostnova um dich herum, die {0:0.#} Frostschaden verursacht. Eine höhere Seltenheit trifft härter und lädt schneller nach.", "mod_epicloot_me_meteorsummoner_display": "Meteorbeschwörer: {0}-facher Trefferschaden", "mod_epicloot_me_meteorsummoner_desc": "Alle {1} Waffentreffer ruft dein nächster Treffer einen feurigen Meteor auf das Ziel herab, der das {0}-fache des Schadens dieses Treffers als Feuerschaden verursacht.", - "mod_epicloot_me_everflow_display": "Ewiger Fluss der Königin", - "mod_epicloot_me_everflow_desc": "Das Töten einer Kreatur gewährt einen stapelbaren Effekt, der deine Gesundheits-, Ausdauer- und Eitr-Regeneration steigert.", - "mod_epicloot_me_necroticfire_display": "Nekrotisches Feuer", - "mod_epicloot_me_necroticfire_desc": "Entzieht deiner Waffe allen physischen Schaden und fügt diesen vollen Betrag sowohl als Feuer- als auch als Giftschaden hinzu.", + "mod_epicloot_me_everflow_display": "Ewiger Fluss der Königin: +{0:0.#} % Regeneration pro Stapel", + "mod_epicloot_me_everflow_desc": "Das Töten einer Kreatur gewährt einen stapelbaren Effekt, der deine Gesundheits-, Ausdauer- und Eitr-Regeneration um {0:0.#} % pro Stapel steigert, bis zu {1:0.#} Stapel.", + "mod_epicloot_me_necroticfire_display": "Nekrotisches Feuer: +{0:0.#} % Feuer und Gift", + "mod_epicloot_me_necroticfire_desc": "Entzieht deiner Waffe allen physischen Schaden und fügt {0:0.#} % davon als Feuerschaden hinzu sowie ebenso viel als Giftschaden.", "mod_epicloot_circlet": "Kreis", "mod_epicloot_belt": "Gürtel", "mod_epicloot_remains": "Überreste", diff --git a/EpicLoot/localizations/Greek.json b/EpicLoot/localizations/Greek.json index bd86c25de..85f53deec 100644 --- a/EpicLoot/localizations/Greek.json +++ b/EpicLoot/localizations/Greek.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Κάθε κύλιση αποφυγής αφαιρεί {0} δευτερόλεπτα από τα εφέ δηλητηρίου και καψίματος.", "mod_epicloot_me_stormrider_display": "+{0} % ταχύτητα κίνησης στις καταιγίδες", "mod_epicloot_me_stormrider_desc": "+{0} % ταχύτητα κίνησης όσο μαίνεται καταιγίδα.", - "mod_epicloot_me_shockingcharge_display": "Ηλεκτρισμένη φόρτιση", - "mod_epicloot_me_shockingcharge_desc": "Τα χτυπήματά σου στη μάχη συσσωρεύουν φορτίσεις. Στη μέγιστη φόρτιση, το επόμενο χτύπημα εξαπολύει ένα ωστικό κύμα κεραυνού προς τα εμπρός που προκαλεί μέρος της ζημιάς των προηγούμενων χτυπημάτων σου.", - "mod_epicloot_me_forestsaid_display": "Βοήθεια του δάσους", - "mod_epicloot_me_forestsaid_desc": "Όταν χτυπηθείς, ξεπροβάλλουν ρίζες που παγιδεύουν και ακινητοποιούν τους κοντινούς εχθρούς.", - "mod_epicloot_me_corpserot_display": "Πτωματική σήψη", - "mod_epicloot_me_corpserot_desc": "Οι εχθροί που σκοτώνεις μπορούν να σκάσουν σε ένα σύννεφο δηλητηρίου, δηλητηριάζοντας τους κοντινούς αντιπάλους.", - "mod_epicloot_me_icyretribution_display": "Παγερή ανταπόδοση", - "mod_epicloot_me_icyretribution_desc": "Όταν χτυπηθείς, πυροδοτείς μια παγερή νόβα γύρω σου. Ο χρόνος επαναφόρτισης κλιμακώνεται με τη σπανιότητα.", + "mod_epicloot_me_shockingcharge_display": "Ηλεκτρισμένη φόρτιση: κάθε {0:0.#} χτυπήματα", + "mod_epicloot_me_shockingcharge_desc": "Κάθε {0:0.#} χτυπήματα στη μάχη, το επόμενο χτύπημα εξαπολύει ένα ωστικό κύμα κεραυνού προς τα εμπρός που προκαλεί {1:0.#}% της ζημιάς αυτών των χτυπημάτων. Μεγαλύτερη σπανιότητα απαιτεί λιγότερα χτυπήματα.", + "mod_epicloot_me_forestsaid_display": "Βοήθεια του δάσους: {0:0.#} μ, {1:0.#} δευτ. αναμονή", + "mod_epicloot_me_forestsaid_desc": "Όταν χτυπηθείς, ξεπροβάλλουν ρίζες που ακινητοποιούν τους εχθρούς σε ακτίνα {0:0.#} μέτρων. Μεγαλύτερη σπανιότητα φτάνει πιο μακριά και επαναφορτίζεται σε {1:0.#} δευτερόλεπτα.", + "mod_epicloot_me_corpserot_display": "Πτωματική σήψη: {0:0.#} δηλητήριο", + "mod_epicloot_me_corpserot_desc": "Οι εχθροί που σκοτώνεις μπορούν να σκάσουν σε ένα σύννεφο δηλητηρίου, προκαλώντας {0:0.#} ζημιά δηλητηρίου στους κοντινούς αντιπάλους.", + "mod_epicloot_me_icyretribution_display": "Παγερή ανταπόδοση: {0:0.#} πάγος", + "mod_epicloot_me_icyretribution_desc": "Όταν χτυπηθείς, πυροδοτείς μια παγερή νόβα γύρω σου που προκαλεί {0:0.#} ζημιά πάγου. Μεγαλύτερη σπανιότητα χτυπά δυνατότερα και επαναφορτίζεται γρηγορότερα.", "mod_epicloot_me_meteorsummoner_display": "Επικαλεστής μετεωριτών: {0}× ζημιά χτυπήματος", "mod_epicloot_me_meteorsummoner_desc": "Κάθε {1} χτυπήματα όπλου, το επόμενο χτύπημά σου καλεί έναν φλεγόμενο μετεωρίτη στον στόχο που προκαλεί {0}× τη ζημιά εκείνου του χτυπήματος ως φωτιά.", - "mod_epicloot_me_everflow_display": "Αέναη ροή της Βασίλισσας", - "mod_epicloot_me_everflow_desc": "Η θανάτωση ενός πλάσματος δίνει μια στοιβαζόμενη ενίσχυση που αυξάνει την αναγέννηση υγείας, αντοχής και έιτρ.", - "mod_epicloot_me_necroticfire_display": "Νεκρωτική φωτιά", - "mod_epicloot_me_necroticfire_desc": "Αφαιρεί όλη τη σωματική ζημιά από το όπλο σου και προσθέτει αυτό το πλήρες ποσό τόσο ως ζημιά φωτιάς όσο και ως ζημιά δηλητηρίου.", + "mod_epicloot_me_everflow_display": "Αέναη ροή της Βασίλισσας: +{0:0.#}% αναγέννηση ανά στοίβα", + "mod_epicloot_me_everflow_desc": "Η θανάτωση ενός πλάσματος δίνει μια στοιβαζόμενη ενίσχυση που αυξάνει την αναγέννηση υγείας, αντοχής και έιτρ κατά {0:0.#}% ανά στοίβα, έως {1:0.#} στοίβες.", + "mod_epicloot_me_necroticfire_display": "Νεκρωτική φωτιά: +{0:0.#}% φωτιά και δηλητήριο", + "mod_epicloot_me_necroticfire_desc": "Αφαιρεί όλη τη σωματική ζημιά από το όπλο σου και προσθέτει {0:0.#}% από αυτή ως ζημιά φωτιάς και άλλο τόσο ως ζημιά δηλητηρίου.", "mod_epicloot_circlet": "στέμμα", "mod_epicloot_belt": "ζώνη", "mod_epicloot_remains": "παραμείνει", diff --git a/EpicLoot/localizations/Hindi.json b/EpicLoot/localizations/Hindi.json index 9efbb1caa..123a95b2d 100644 --- a/EpicLoot/localizations/Hindi.json +++ b/EpicLoot/localizations/Hindi.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "हर लोट-चकमा विष और जलन प्रभावों के {0} सेकंड हटा देता है।", "mod_epicloot_me_stormrider_display": "तूफ़ान में +{0}% गति", "mod_epicloot_me_stormrider_desc": "तूफ़ान चलते समय +{0}% गति।", - "mod_epicloot_me_shockingcharge_display": "विद्युत आवेश", - "mod_epicloot_me_shockingcharge_desc": "युद्ध में लगे प्रहार आवेश जमा करते हैं। पूरा होने पर आपका अगला प्रहार आगे की ओर एक विद्युत तरंग छोड़ता है जो पिछले प्रहार की क्षति का एक अंश करती है।", - "mod_epicloot_me_forestsaid_display": "वन की सहायता", - "mod_epicloot_me_forestsaid_desc": "चोट खाने पर उलझाने वाली जड़ें फूट पड़ती हैं और पास के शत्रुओं को जकड़ लेती हैं।", - "mod_epicloot_me_corpserot_display": "शव सड़न", - "mod_epicloot_me_corpserot_desc": "मारे गए शत्रु विष के बादल में फट सकते हैं जो पास के शत्रुओं को विषग्रस्त कर देता है।", - "mod_epicloot_me_icyretribution_display": "हिमशीत प्रतिशोध", - "mod_epicloot_me_icyretribution_desc": "चोट खाने पर आपके चारों ओर तुषार विस्फोट होता है। कूलडाउन दुर्लभता के अनुसार बदलता है।", + "mod_epicloot_me_shockingcharge_display": "विद्युत आवेश: हर {0:0.#} प्रहार पर", + "mod_epicloot_me_shockingcharge_desc": "युद्ध में हर {0:0.#} प्रहार के बाद अगला प्रहार आगे की ओर एक विद्युत तरंग छोड़ता है जो उन प्रहारों की क्षति का {1:0.#}% करती है। अधिक दुर्लभता में कम प्रहार लगते हैं।", + "mod_epicloot_me_forestsaid_display": "वन की सहायता: {0:0.#} मी, {1:0.#} से कूलडाउन", + "mod_epicloot_me_forestsaid_desc": "चोट खाने पर उलझाने वाली जड़ें फूट पड़ती हैं और {0:0.#} मीटर के भीतर शत्रुओं को जकड़ लेती हैं। अधिक दुर्लभता अधिक दूर तक पहुँचती है और {1:0.#} सेकंड में तैयार हो जाती है।", + "mod_epicloot_me_corpserot_display": "शव सड़न: {0:0.#} विष", + "mod_epicloot_me_corpserot_desc": "मारे गए शत्रु विष के बादल में फट सकते हैं जो पास के शत्रुओं को {0:0.#} विष क्षति देता है।", + "mod_epicloot_me_icyretribution_display": "हिमशीत प्रतिशोध: {0:0.#} तुषार", + "mod_epicloot_me_icyretribution_desc": "चोट खाने पर आपके चारों ओर तुषार विस्फोट होता है जो {0:0.#} तुषार क्षति करता है। अधिक दुर्लभता अधिक जोर से मारती है और जल्दी तैयार होती है।", "mod_epicloot_me_meteorsummoner_display": "उल्का आह्वानकर्ता: प्रहार क्षति का {0} गुना", "mod_epicloot_me_meteorsummoner_desc": "इस हथियार से हर {1} प्रहार के बाद अगला वार लक्ष्य पर एक जलती उल्का गिराता है जो उस वार की {0} गुना आग क्षति करती है।", - "mod_epicloot_me_everflow_display": "रानी का सदाप्रवाह", - "mod_epicloot_me_everflow_desc": "जीवों का वध एक परतदार बढ़ावा देता है जो स्वास्थ्य, स्टैमिना और ईटर पुनर्भरण बढ़ाता है।", - "mod_epicloot_me_necroticfire_display": "गलित अग्नि", - "mod_epicloot_me_necroticfire_desc": "हथियार से समस्त भौतिक क्षति हटा देता है और उतनी ही पूरी मात्रा आग क्षति तथा विष क्षति दोनों में जोड़ देता है।", + "mod_epicloot_me_everflow_display": "रानी का सदाप्रवाह: प्रति परत +{0:0.#}% पुनर्भरण", + "mod_epicloot_me_everflow_desc": "जीवों का वध एक परतदार बढ़ावा देता है जो प्रति परत स्वास्थ्य, स्टैमिना और ईटर पुनर्भरण {0:0.#}% बढ़ाता है, अधिकतम {1:0.#} परतें।", + "mod_epicloot_me_necroticfire_display": "गलित अग्नि: +{0:0.#}% आग और विष", + "mod_epicloot_me_necroticfire_desc": "हथियार से समस्त भौतिक क्षति हटा देता है और उसका {0:0.#}% आग क्षति के रूप में तथा उतना ही विष क्षति के रूप में जोड़ देता है।", "mod_epicloot_circlet": "सर्कलेट", "mod_epicloot_belt": "बेल्ट", "mod_epicloot_remains": "शेष", diff --git a/EpicLoot/localizations/Hungarian.json b/EpicLoot/localizations/Hungarian.json index 0d1d93878..96fea6be1 100644 --- a/EpicLoot/localizations/Hungarian.json +++ b/EpicLoot/localizations/Hungarian.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Minden kitérő gurulás {0} másodpercet távolít el a méreg- és égéshatásaidból.", "mod_epicloot_me_stormrider_display": "+{0} % mozgási sebesség viharban", "mod_epicloot_me_stormrider_desc": "+{0} % mozgási sebesség, amíg vihar tombol.", - "mod_epicloot_me_shockingcharge_display": "Sokkoló töltet", - "mod_epicloot_me_shockingcharge_desc": "A harci találataid töltéseket halmoznak fel. Teljes töltésnél a következő találat előrefelé villámlökéshullámot szabadít fel, amely a korábbi találataid sebzésének egy részét okozza.", - "mod_epicloot_me_forestsaid_display": "Az erdő segítsége", - "mod_epicloot_me_forestsaid_desc": "Amikor eltalálnak, gúzsba kötő gyökerek törnek elő, és mozgásképtelenné teszik a közeli ellenségeket.", - "mod_epicloot_me_corpserot_display": "Hullarothadás", - "mod_epicloot_me_corpserot_desc": "Az általad megölt ellenségek méregfelhőben robbanhatnak szét, megmérgezve a közeli ellenfeleket.", - "mod_epicloot_me_icyretribution_display": "Jeges megtorlás", - "mod_epicloot_me_icyretribution_desc": "Amikor eltalálnak, fagynóvát robbantasz magad körül. A visszatöltés a ritkasággal skálázódik.", + "mod_epicloot_me_shockingcharge_display": "Sokkoló töltet: minden {0:0.#} találat", + "mod_epicloot_me_shockingcharge_desc": "Minden {0:0.#} harci találat után a következő találat előrefelé villámlökéshullámot szabadít fel, amely e találatok sebzésének {1:0.#}%-át okozza. A magasabb ritkaság kevesebb találatot igényel.", + "mod_epicloot_me_forestsaid_display": "Az erdő segítsége: {0:0.#} m, {1:0.#} mp várakozás", + "mod_epicloot_me_forestsaid_desc": "Amikor eltalálnak, gúzsba kötő gyökerek törnek elő, és {0:0.#} méteres körzetben mozgásképtelenné teszik az ellenségeket. A magasabb ritkaság messzebbre ér, és {1:0.#} másodperc alatt töltődik fel.", + "mod_epicloot_me_corpserot_display": "Hullarothadás: {0:0.#} méreg", + "mod_epicloot_me_corpserot_desc": "Az általad megölt ellenségek méregfelhőben robbanhatnak szét, {0:0.#} méregsebzést okozva a közeli ellenfeleknek.", + "mod_epicloot_me_icyretribution_display": "Jeges megtorlás: {0:0.#} fagy", + "mod_epicloot_me_icyretribution_desc": "Amikor eltalálnak, fagynóvát robbantasz magad körül, amely {0:0.#} fagysebzést okoz. A magasabb ritkaság erősebben üt, és gyorsabban töltődik fel.", "mod_epicloot_me_meteorsummoner_display": "Meteoridéző: {0}× találati sebzés", "mod_epicloot_me_meteorsummoner_desc": "Minden {1} fegyvertalálat után a következő találatod tüzes meteort hív a célpontra, amely az adott találat sebzésének {0}-szorosát okozza tűzként.", - "mod_epicloot_me_everflow_display": "A Királynő örök áramlása", - "mod_epicloot_me_everflow_desc": "Egy lény megölése halmozódó bónuszt ad, amely növeli az életerő-, állóképesség- és eitr-regenerációdat.", - "mod_epicloot_me_necroticfire_display": "Nekrotikus tűz", - "mod_epicloot_me_necroticfire_desc": "Eltávolítja a fegyveredről az összes fizikai sebzést, és ezt a teljes értéket tűz- és méregsebzésként is hozzáadja.", + "mod_epicloot_me_everflow_display": "A Királynő örök áramlása: +{0:0.#}% regeneráció rétegenként", + "mod_epicloot_me_everflow_desc": "Egy lény megölése halmozódó bónuszt ad, amely rétegenként {0:0.#}%-kal növeli az életerő-, állóképesség- és eitr-regenerációdat, legfeljebb {1:0.#} rétegig.", + "mod_epicloot_me_necroticfire_display": "Nekrotikus tűz: +{0:0.#}% tűz és méreg", + "mod_epicloot_me_necroticfire_desc": "Eltávolítja a fegyveredről az összes fizikai sebzést, és annak {0:0.#}%-át tűzsebzésként, ugyanennyit pedig méregsebzésként ad hozzá.", "mod_epicloot_circlet": "fejdísz", "mod_epicloot_belt": "öv", "mod_epicloot_remains": "maradványok", diff --git a/EpicLoot/localizations/Icelandic.json b/EpicLoot/localizations/Icelandic.json index 53fb36324..24c211879 100644 --- a/EpicLoot/localizations/Icelandic.json +++ b/EpicLoot/localizations/Icelandic.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Hver vikningarvelta fjarlægir {0} sekúndur af eitur- og brunaáhrifum þínum.", "mod_epicloot_me_stormrider_display": "+{0} % hreyfihraði í stormum", "mod_epicloot_me_stormrider_desc": "+{0} % hreyfihraði meðan stormur geisar.", - "mod_epicloot_me_shockingcharge_display": "Lostandi hleðsla", - "mod_epicloot_me_shockingcharge_desc": "Bardagahögg þín safna hleðslum. Við fulla hleðslu leysir næsta högg úr læðingi eldingahöggbylgju fram á við sem veitir hluta af skaða fyrri högga þinna.", - "mod_epicloot_me_forestsaid_display": "Hjálp skógarins", - "mod_epicloot_me_forestsaid_desc": "Þegar þú verður fyrir höggi brjótast fram flæktar rætur sem festa óvini nálægt.", - "mod_epicloot_me_corpserot_display": "Náfúi", - "mod_epicloot_me_corpserot_desc": "Óvinir sem þú drepur geta sprungið í eiturskýi og eitrað fyrir andstæðingum nálægt.", - "mod_epicloot_me_icyretribution_display": "Ísköld hefnd", - "mod_epicloot_me_icyretribution_desc": "Þegar þú verður fyrir höggi sprengirðu frostnóvu í kringum þig. Biðtíminn skalast með fágæti.", + "mod_epicloot_me_shockingcharge_display": "Lostandi hleðsla: á hverjum {0:0.#} höggum", + "mod_epicloot_me_shockingcharge_desc": "Á hverjum {0:0.#} bardagahöggum leysir næsta högg úr læðingi eldingahöggbylgju fram á við sem veitir {1:0.#}% af skaða þeirra högga. Meiri fágæti krefst færri högga.", + "mod_epicloot_me_forestsaid_display": "Hjálp skógarins: {0:0.#} m, {1:0.#} s bið", + "mod_epicloot_me_forestsaid_desc": "Þegar þú verður fyrir höggi brjótast fram flæktar rætur sem festa óvini innan {0:0.#} metra. Meiri fágæti nær lengra og hleðst á {1:0.#} sekúndum.", + "mod_epicloot_me_corpserot_display": "Náfúi: {0:0.#} eitur", + "mod_epicloot_me_corpserot_desc": "Óvinir sem þú drepur geta sprungið í eiturskýi og veitt andstæðingum nálægt {0:0.#} eiturskaða.", + "mod_epicloot_me_icyretribution_display": "Ísköld hefnd: {0:0.#} frost", + "mod_epicloot_me_icyretribution_desc": "Þegar þú verður fyrir höggi sprengirðu frostnóvu í kringum þig sem veitir {0:0.#} frostskaða. Meiri fágæti slær fastar og hleðst hraðar.", "mod_epicloot_me_meteorsummoner_display": "Loftsteinakallari: {0}× höggskaði", "mod_epicloot_me_meteorsummoner_desc": "Á hverjum {1} vopnahöggum kallar næsta högg þitt logandi loftstein niður á skotmarkið sem veitir {0}× skaða þess höggs sem eld.", - "mod_epicloot_me_everflow_display": "Eilíft streymi Drottningarinnar", - "mod_epicloot_me_everflow_desc": "Að drepa skepnu veitir staflanleg áhrif sem auka heilsu-, þreks- og eitrendurnýjun þína.", - "mod_epicloot_me_necroticfire_display": "Drepandi eldur", - "mod_epicloot_me_necroticfire_desc": "Sviptir vopnið þitt öllum líkamlegum skaða og bætir þeirri fullu upphæð við bæði sem eld- og eiturskaða.", + "mod_epicloot_me_everflow_display": "Eilíft streymi Drottningarinnar: +{0:0.#}% endurnýjun á stafla", + "mod_epicloot_me_everflow_desc": "Að drepa skepnu veitir staflanleg áhrif sem auka heilsu-, þreks- og eitrendurnýjun þína um {0:0.#}% á stafla, allt að {1:0.#} stöflum.", + "mod_epicloot_me_necroticfire_display": "Drepandi eldur: +{0:0.#}% eldur og eitur", + "mod_epicloot_me_necroticfire_desc": "Sviptir vopnið þitt öllum líkamlegum skaða og bætir {0:0.#}% af honum við sem eldskaða og jafn miklu sem eiturskaða.", "mod_epicloot_circlet": "hringur", "mod_epicloot_belt": "belti", "mod_epicloot_remains": "leifar", diff --git a/EpicLoot/localizations/Italian.json b/EpicLoot/localizations/Italian.json index 958246418..870e78cb7 100644 --- a/EpicLoot/localizations/Italian.json +++ b/EpicLoot/localizations/Italian.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Ogni capriola di schivata rimuove {0} secondi dai tuoi effetti di veleno e bruciatura.", "mod_epicloot_me_stormrider_display": "+{0} % di velocità di movimento nelle tempeste", "mod_epicloot_me_stormrider_desc": "+{0} % di velocità di movimento mentre infuria una tempesta.", - "mod_epicloot_me_shockingcharge_display": "Carica folgorante", - "mod_epicloot_me_shockingcharge_desc": "I tuoi colpi in combattimento accumulano cariche. Al massimo della carica, il colpo successivo scatena un'onda d'urto elettrica in avanti che infligge una parte del danno dei tuoi colpi precedenti.", - "mod_epicloot_me_forestsaid_display": "Aiuto della foresta", - "mod_epicloot_me_forestsaid_desc": "Quando vieni colpito, radici avviluppanti erompono e immobilizzano i nemici vicini.", - "mod_epicloot_me_corpserot_display": "Putrefazione cadaverica", - "mod_epicloot_me_corpserot_desc": "I nemici che uccidi possono esplodere in una nube di veleno, avvelenando gli avversari vicini.", - "mod_epicloot_me_icyretribution_display": "Rappresaglia gelida", - "mod_epicloot_me_icyretribution_desc": "Quando vieni colpito, fai detonare una nova di gelo attorno a te. Il recupero scala con la rarità.", + "mod_epicloot_me_shockingcharge_display": "Carica folgorante: ogni {0:0.#} colpi", + "mod_epicloot_me_shockingcharge_desc": "Ogni {0:0.#} colpi in combattimento, il colpo successivo scatena un'onda d'urto elettrica in avanti che infligge il {1:0.#}% del danno di quei colpi. Una rarità superiore richiede meno colpi.", + "mod_epicloot_me_forestsaid_display": "Aiuto della foresta: {0:0.#} m, {1:0.#} s di ricarica", + "mod_epicloot_me_forestsaid_desc": "Quando vieni colpito, radici avviluppanti erompono e immobilizzano i nemici entro {0:0.#} metri. Una rarità superiore arriva più lontano e si ricarica in {1:0.#} secondi.", + "mod_epicloot_me_corpserot_display": "Putrefazione cadaverica: {0:0.#} veleno", + "mod_epicloot_me_corpserot_desc": "I nemici che uccidi possono esplodere in una nube di veleno, infliggendo {0:0.#} danni da veleno agli avversari vicini.", + "mod_epicloot_me_icyretribution_display": "Rappresaglia gelida: {0:0.#} gelo", + "mod_epicloot_me_icyretribution_desc": "Quando vieni colpito, fai detonare una nova di gelo attorno a te che infligge {0:0.#} danni da gelo. Una rarità superiore colpisce più forte e si ricarica prima.", "mod_epicloot_me_meteorsummoner_display": "Evocatore di meteore: ×{0} del danno del colpo", "mod_epicloot_me_meteorsummoner_desc": "Ogni {1} colpi d'arma, il tuo colpo successivo fa piombare una meteora infuocata sul bersaglio che infligge {0} volte il danno di quel colpo come fuoco.", - "mod_epicloot_me_everflow_display": "Flusso eterno della Regina", - "mod_epicloot_me_everflow_desc": "Uccidere una creatura concede un potenziamento cumulabile che aumenta la rigenerazione di salute, stamina ed eitr.", - "mod_epicloot_me_necroticfire_display": "Fuoco necrotico", - "mod_epicloot_me_necroticfire_desc": "Rimuove tutto il danno fisico dalla tua arma e aggiunge quell'intero ammontare sia come danno da fuoco sia come danno da veleno.", + "mod_epicloot_me_everflow_display": "Flusso eterno della Regina: +{0:0.#}% di rigenerazione per accumulo", + "mod_epicloot_me_everflow_desc": "Uccidere una creatura concede un potenziamento cumulabile che aumenta la rigenerazione di salute, stamina ed eitr del {0:0.#}% per accumulo, fino a {1:0.#} accumuli.", + "mod_epicloot_me_necroticfire_display": "Fuoco necrotico: +{0:0.#}% fuoco e veleno", + "mod_epicloot_me_necroticfire_desc": "Rimuove tutto il danno fisico dalla tua arma e ne aggiunge il {0:0.#}% come danno da fuoco e altrettanto come danno da veleno.", "mod_epicloot_circlet": "diadema", "mod_epicloot_belt": "cintura", "mod_epicloot_remains": "resti", diff --git a/EpicLoot/localizations/Japanese.json b/EpicLoot/localizations/Japanese.json index 66ce0a36e..f2f16e0a8 100644 --- a/EpicLoot/localizations/Japanese.json +++ b/EpicLoot/localizations/Japanese.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "回避ローリングごとに、毒と炎上の効果から {0} 秒を取り除きます。", "mod_epicloot_me_stormrider_display": "嵐の中で移動速度 +{0} %", "mod_epicloot_me_stormrider_desc": "嵐が吹き荒れている間、移動速度 +{0} %。", - "mod_epicloot_me_shockingcharge_display": "衝撃の充電", - "mod_epicloot_me_shockingcharge_desc": "戦闘での命中がチャージを蓄積します。最大チャージ時、次の一撃が前方に雷の衝撃波を放ち、それまでの命中ダメージの一部を与えます。", - "mod_epicloot_me_forestsaid_display": "森の助け", - "mod_epicloot_me_forestsaid_desc": "被弾すると、絡みつく根が噴き出して近くの敵を拘束します。", - "mod_epicloot_me_corpserot_display": "死骸の腐敗", - "mod_epicloot_me_corpserot_desc": "倒した敵が毒の雲となって弾け、近くの敵を毒に侵すことがあります。", - "mod_epicloot_me_icyretribution_display": "氷の報復", - "mod_epicloot_me_icyretribution_desc": "被弾すると、自分の周囲にフロストノヴァを発生させます。クールダウンはレア度に応じて変わります。", + "mod_epicloot_me_shockingcharge_display": "衝撃の充電:{0:0.#} 回命中ごと", + "mod_epicloot_me_shockingcharge_desc": "戦闘で {0:0.#} 回命中するごとに、次の一撃が前方に雷の衝撃波を放ち、それらの命中ダメージの {1:0.#}% を与えます。レアリティが高いほど必要な命中回数が減ります。", + "mod_epicloot_me_forestsaid_display": "森の助け:{0:0.#} m、クールダウン {1:0.#} 秒", + "mod_epicloot_me_forestsaid_desc": "被弾すると、絡みつく根が噴き出して {0:0.#} メートル以内の敵を拘束します。レアリティが高いほど範囲が広がり、クールダウンは {1:0.#} 秒です。", + "mod_epicloot_me_corpserot_display": "死骸の腐敗:{0:0.#} 毒", + "mod_epicloot_me_corpserot_desc": "倒した敵が毒の雲となって弾け、近くの敵に {0:0.#} の毒ダメージを与えることがあります。", + "mod_epicloot_me_icyretribution_display": "氷の報復:{0:0.#} 氷", + "mod_epicloot_me_icyretribution_desc": "被弾すると、自分の周囲にフロストノヴァを発生させ {0:0.#} の氷ダメージを与えます。レアリティが高いほど威力が上がり、クールダウンも短くなります。", "mod_epicloot_me_meteorsummoner_display": "隕石召喚者: 命中ダメージの {0} 倍", "mod_epicloot_me_meteorsummoner_desc": "武器で {1} 回命中するごとに、次の一撃が対象に炎の隕石を落とし、その一撃のダメージの {0} 倍を火ダメージとして与えます。", - "mod_epicloot_me_everflow_display": "女王の永久の流れ", - "mod_epicloot_me_everflow_desc": "クリーチャーを倒すと、体力・スタミナ・エイトルの回復を高める重複バフを得ます。", - "mod_epicloot_me_necroticfire_display": "壊死の炎", - "mod_epicloot_me_necroticfire_desc": "武器から物理ダメージをすべて取り除き、その全量を火ダメージと毒ダメージの両方として追加します。", + "mod_epicloot_me_everflow_display": "女王の永久の流れ:1スタックにつき回復 +{0:0.#}%", + "mod_epicloot_me_everflow_desc": "クリーチャーを倒すと、体力・スタミナ・エイトルの回復を1スタックにつき {0:0.#}% 高める重複バフを得ます(最大 {1:0.#} スタック)。", + "mod_epicloot_me_necroticfire_display": "壊死の炎:火と毒 +{0:0.#}%", + "mod_epicloot_me_necroticfire_desc": "武器から物理ダメージをすべて取り除き、その {0:0.#}% を火ダメージとして、さらに同量を毒ダメージとして追加します。", "mod_epicloot_circlet": "シルクレット", "mod_epicloot_belt": "ベルト", "mod_epicloot_remains": "遺物", diff --git a/EpicLoot/localizations/Korean.json b/EpicLoot/localizations/Korean.json index 4dfd659b3..bf9ee3444 100644 --- a/EpicLoot/localizations/Korean.json +++ b/EpicLoot/localizations/Korean.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "회피 구르기마다 독과 화상 효과에서 {0}초를 제거합니다.", "mod_epicloot_me_stormrider_display": "폭풍 속 이동 속도 +{0} %", "mod_epicloot_me_stormrider_desc": "폭풍이 몰아치는 동안 이동 속도 +{0} %.", - "mod_epicloot_me_shockingcharge_display": "충격 충전", - "mod_epicloot_me_shockingcharge_desc": "전투에서 적중할 때마다 충전이 쌓입니다. 최대 충전 시 다음 일격이 전방으로 번개 충격파를 내보내 이전 적중 피해의 일부를 입힙니다.", - "mod_epicloot_me_forestsaid_display": "숲의 도움", - "mod_epicloot_me_forestsaid_desc": "피격 시 얽어매는 뿌리가 솟구쳐 근처의 적을 붙잡습니다.", - "mod_epicloot_me_corpserot_display": "시체 부패", - "mod_epicloot_me_corpserot_desc": "처치한 적이 독 구름으로 터져 근처의 적을 중독시킬 수 있습니다.", - "mod_epicloot_me_icyretribution_display": "얼음의 응징", - "mod_epicloot_me_icyretribution_desc": "피격 시 주변에 서리 폭발을 일으킵니다. 재사용 대기시간은 희귀도에 따라 달라집니다.", + "mod_epicloot_me_shockingcharge_display": "충격 충전: {0:0.#}회 적중마다", + "mod_epicloot_me_shockingcharge_desc": "전투에서 {0:0.#}회 적중할 때마다 다음 일격이 전방으로 번개 충격파를 내보내 그 적중 피해의 {1:0.#}%를 입힙니다. 희귀도가 높을수록 필요한 적중 횟수가 줄어듭니다.", + "mod_epicloot_me_forestsaid_display": "숲의 도움: {0:0.#}m, 재사용 대기시간 {1:0.#}초", + "mod_epicloot_me_forestsaid_desc": "피격 시 얽어매는 뿌리가 솟구쳐 {0:0.#}미터 이내의 적을 붙잡습니다. 희귀도가 높을수록 범위가 넓어지며 재사용 대기시간은 {1:0.#}초입니다.", + "mod_epicloot_me_corpserot_display": "시체 부패: {0:0.#} 독", + "mod_epicloot_me_corpserot_desc": "처치한 적이 독 구름으로 터져 근처의 적에게 {0:0.#}의 독 피해를 입힐 수 있습니다.", + "mod_epicloot_me_icyretribution_display": "얼음의 응징: {0:0.#} 냉기", + "mod_epicloot_me_icyretribution_desc": "피격 시 주변에 서리 폭발을 일으켜 {0:0.#}의 냉기 피해를 입힙니다. 희귀도가 높을수록 피해가 커지고 재사용 대기시간도 짧아집니다.", "mod_epicloot_me_meteorsummoner_display": "운석 소환자: 적중 피해의 {0}배", "mod_epicloot_me_meteorsummoner_desc": "무기로 {1}회 적중할 때마다 다음 일격이 대상에게 불타는 운석을 떨어뜨려, 그 일격 피해의 {0}배를 화염 피해로 입힙니다.", - "mod_epicloot_me_everflow_display": "여왕의 영원한 흐름", - "mod_epicloot_me_everflow_desc": "생물을 처치하면 체력·스태미나·에이트르 재생을 높이는 중첩 버프를 얻습니다.", - "mod_epicloot_me_necroticfire_display": "괴사의 불꽃", - "mod_epicloot_me_necroticfire_desc": "무기에서 물리 피해를 모두 제거하고, 그 전량을 화염 피해와 독 피해 양쪽으로 추가합니다.", + "mod_epicloot_me_everflow_display": "여왕의 영원한 흐름: 중첩당 재생 +{0:0.#}%", + "mod_epicloot_me_everflow_desc": "생물을 처치하면 체력·스태미나·에이트르 재생을 중첩당 {0:0.#}% 높이는 버프를 얻습니다(최대 {1:0.#}중첩).", + "mod_epicloot_me_necroticfire_display": "괴사의 불꽃: 화염·독 +{0:0.#}%", + "mod_epicloot_me_necroticfire_desc": "무기에서 물리 피해를 모두 제거하고, 그중 {0:0.#}%를 화염 피해로, 같은 양을 독 피해로 추가합니다.", "mod_epicloot_circlet": "머리고리", "mod_epicloot_belt": "벨트", "mod_epicloot_remains": "유골", diff --git a/EpicLoot/localizations/Latvian.json b/EpicLoot/localizations/Latvian.json index 0f57ca600..5780a53bb 100644 --- a/EpicLoot/localizations/Latvian.json +++ b/EpicLoot/localizations/Latvian.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Katrs izvairīšanās veltnis noņem {0} sekundes no taviem indes un degšanas efektiem.", "mod_epicloot_me_stormrider_display": "+{0} % kustības ātruma vētrās", "mod_epicloot_me_stormrider_desc": "+{0} % kustības ātruma, kamēr plosās vētra.", - "mod_epicloot_me_shockingcharge_display": "Trieciena lādiņš", - "mod_epicloot_me_shockingcharge_desc": "Tavi triecieni kaujā krāj lādiņus. Pie pilna lādiņa nākamais trieciens atbrīvo uz priekšu vērstu zibens triecienviļni, kas nodara daļu no tavu iepriekšējo triecienu bojājuma.", - "mod_epicloot_me_forestsaid_display": "Meža palīdzība", - "mod_epicloot_me_forestsaid_desc": "Kad tevi trāpa, izlaužas savijošās saknes un imobilizē tuvumā esošos ienaidniekus.", - "mod_epicloot_me_corpserot_display": "Līķu puve", - "mod_epicloot_me_corpserot_desc": "Tavi nogalinātie ienaidnieki var uzsprāgt indes mākonī, saindējot tuvumā esošos pretiniekus.", - "mod_epicloot_me_icyretribution_display": "Ledaina atmaksa", - "mod_epicloot_me_icyretribution_desc": "Kad tevi trāpa, ap tevi uzsprāgst sala nova. Atjaunošanās laiks mērogojas ar retumu.", + "mod_epicloot_me_shockingcharge_display": "Trieciena lādiņš: ik pēc {0:0.#} triecieniem", + "mod_epicloot_me_shockingcharge_desc": "Ik pēc {0:0.#} triecieniem kaujā nākamais trieciens atbrīvo uz priekšu vērstu zibens triecienviļni, kas nodara {1:0.#}% no šo triecienu bojājuma. Augstāks retums prasa mazāk triecienu.", + "mod_epicloot_me_forestsaid_display": "Meža palīdzība: {0:0.#} m, {1:0.#} s atjaunošanās", + "mod_epicloot_me_forestsaid_desc": "Kad tevi trāpa, izlaužas savijošās saknes un imobilizē ienaidniekus {0:0.#} metru rādiusā. Augstāks retums sniedzas tālāk un atjaunojas {1:0.#} sekundēs.", + "mod_epicloot_me_corpserot_display": "Līķu puve: {0:0.#} indes", + "mod_epicloot_me_corpserot_desc": "Tavi nogalinātie ienaidnieki var uzsprāgt indes mākonī, nodarot tuvumā esošajiem pretiniekiem {0:0.#} indes bojājumu.", + "mod_epicloot_me_icyretribution_display": "Ledaina atmaksa: {0:0.#} sala", + "mod_epicloot_me_icyretribution_desc": "Kad tevi trāpa, ap tevi uzsprāgst sala nova, kas nodara {0:0.#} sala bojājumu. Augstāks retums sit spēcīgāk un atjaunojas ātrāk.", "mod_epicloot_me_meteorsummoner_display": "Meteoru izsaucējs: {0}× trieciena bojājums", "mod_epicloot_me_meteorsummoner_desc": "Ik pēc {1} ieroča triecieniem tavs nākamais trieciens nogāž uz mērķi liesmojošu meteoru, kas nodara {0}× šī trieciena bojājuma kā uguni.", - "mod_epicloot_me_everflow_display": "Karalienes mūžīgā plūsma", - "mod_epicloot_me_everflow_desc": "Radības nogalināšana piešķir krājošu uzlabojumu, kas veicina veselības, izturības un eitra atjaunošanos.", - "mod_epicloot_me_necroticfire_display": "Nekrotiska uguns", - "mod_epicloot_me_necroticfire_desc": "Noņem no tava ieroča visu fizisko bojājumu un pievieno šo pilno apjomu gan kā uguns, gan kā indes bojājumu.", + "mod_epicloot_me_everflow_display": "Karalienes mūžīgā plūsma: +{0:0.#}% atjaunošanās uz slāni", + "mod_epicloot_me_everflow_desc": "Radības nogalināšana piešķir krājošu uzlabojumu, kas par {0:0.#}% uz slāni veicina veselības, izturības un eitra atjaunošanos, līdz {1:0.#} slāņiem.", + "mod_epicloot_me_necroticfire_display": "Nekrotiska uguns: +{0:0.#}% uguns un indes", + "mod_epicloot_me_necroticfire_desc": "Noņem no tava ieroča visu fizisko bojājumu un pievieno {0:0.#}% no tā kā uguns bojājumu un tikpat daudz kā indes bojājumu.", "mod_epicloot_circlet": "aplauzums", "mod_epicloot_belt": "josta", "mod_epicloot_remains": "paliekas", diff --git a/EpicLoot/localizations/Lithuanian.json b/EpicLoot/localizations/Lithuanian.json index 96f9e6ab7..14255d544 100644 --- a/EpicLoot/localizations/Lithuanian.json +++ b/EpicLoot/localizations/Lithuanian.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Kiekvienas išsisukimo kūlvirstis pašalina {0} sekundes nuo tavo nuodų ir degimo efektų.", "mod_epicloot_me_stormrider_display": "+{0} % judėjimo greičio per audras", "mod_epicloot_me_stormrider_desc": "+{0} % judėjimo greičio, kol siautėja audra.", - "mod_epicloot_me_shockingcharge_display": "Trenkiantis krūvis", - "mod_epicloot_me_shockingcharge_desc": "Tavo smūgiai kovoje kaupia krūvius. Esant pilnam krūviui kitas smūgis paleidžia į priekį nukreiptą žaibo smūginę bangą, kuri padaro dalį tavo ankstesnių smūgių žalos.", - "mod_epicloot_me_forestsaid_display": "Miško pagalba", - "mod_epicloot_me_forestsaid_desc": "Kai į tave pataiko, iš žemės prasiveržia pinančios šaknys ir sukausto netoliese esančius priešus.", - "mod_epicloot_me_corpserot_display": "Lavonų puvinys", - "mod_epicloot_me_corpserot_desc": "Tavo nužudyti priešai gali sprogti nuodų debesyje, nuodydami netoliese esančius priešininkus.", - "mod_epicloot_me_icyretribution_display": "Ledinis atpildas", - "mod_epicloot_me_icyretribution_desc": "Kai į tave pataiko, aplink tave sprogsta šalčio nova. Atsistatymo laikas keičiasi pagal retumą.", + "mod_epicloot_me_shockingcharge_display": "Trenkiantis krūvis: kas {0:0.#} smūgius", + "mod_epicloot_me_shockingcharge_desc": "Kas {0:0.#} smūgius kovoje kitas smūgis paleidžia į priekį nukreiptą žaibo smūginę bangą, kuri padaro {1:0.#}% tų smūgių žalos. Didesnis retumas reikalauja mažiau smūgių.", + "mod_epicloot_me_forestsaid_display": "Miško pagalba: {0:0.#} m, {1:0.#} s atvėsimas", + "mod_epicloot_me_forestsaid_desc": "Kai į tave pataiko, iš žemės prasiveržia pinančios šaknys ir sukausto priešus {0:0.#} metrų spinduliu. Didesnis retumas siekia toliau ir atsistato per {1:0.#} sekundes.", + "mod_epicloot_me_corpserot_display": "Lavonų puvinys: {0:0.#} nuodų", + "mod_epicloot_me_corpserot_desc": "Tavo nužudyti priešai gali sprogti nuodų debesyje, padarydami netoliese esantiems priešininkams {0:0.#} nuodų žalos.", + "mod_epicloot_me_icyretribution_display": "Ledinis atpildas: {0:0.#} šalčio", + "mod_epicloot_me_icyretribution_desc": "Kai į tave pataiko, aplink tave sprogsta šalčio nova, padaranti {0:0.#} šalčio žalos. Didesnis retumas smogia stipriau ir atsistato greičiau.", "mod_epicloot_me_meteorsummoner_display": "Meteorų šaukėjas: {0}× smūgio žala", "mod_epicloot_me_meteorsummoner_desc": "Kas {1} ginklo smūgius tavo kitas smūgis nuleidžia ant taikinio liepsnojantį meteorą, kuris padaro {0}× to smūgio žalos kaip ugnį.", - "mod_epicloot_me_everflow_display": "Karalienės amžinas srautas", - "mod_epicloot_me_everflow_desc": "Nužudžius padarą suteikiamas besikaupiantis priedas, kuris didina tavo sveikatos, ištvermės ir eitro atsinaujinimą.", - "mod_epicloot_me_necroticfire_display": "Nekrozinė ugnis", - "mod_epicloot_me_necroticfire_desc": "Pašalina iš tavo ginklo visą fizinę žalą ir prideda visą tą kiekį ir kaip ugnies, ir kaip nuodų žalą.", + "mod_epicloot_me_everflow_display": "Karalienės amžinas srautas: +{0:0.#}% atsinaujinimo už sluoksnį", + "mod_epicloot_me_everflow_desc": "Nužudžius padarą suteikiamas besikaupiantis priedas, kuris už kiekvieną sluoksnį {0:0.#}% didina tavo sveikatos, ištvermės ir eitro atsinaujinimą, iki {1:0.#} sluoksnių.", + "mod_epicloot_me_necroticfire_display": "Nekrozinė ugnis: +{0:0.#}% ugnies ir nuodų", + "mod_epicloot_me_necroticfire_desc": "Pašalina iš tavo ginklo visą fizinę žalą ir prideda {0:0.#}% jos kaip ugnies žalą bei tiek pat kaip nuodų žalą.", "mod_epicloot_circlet": "apsiaustas", "mod_epicloot_belt": "diržas", "mod_epicloot_remains": "likučiai", diff --git a/EpicLoot/localizations/Macedonian.json b/EpicLoot/localizations/Macedonian.json index f48755ec1..484072ef6 100644 --- a/EpicLoot/localizations/Macedonian.json +++ b/EpicLoot/localizations/Macedonian.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Секое тркалање при одбегнување отстранува {0} секунди од твоите отровни и горечки ефекти.", "mod_epicloot_me_stormrider_display": "+{0} % брзина на движење при бури", "mod_epicloot_me_stormrider_desc": "+{0} % брзина на движење додека беснее бура.", - "mod_epicloot_me_shockingcharge_display": "Шокантен полнеж", - "mod_epicloot_me_shockingcharge_desc": "Твоите удари во борба собираат полнежи. При полн полнеж, следниот удар ослободува ударен бран од молњи нанапред што нанесува дел од штетата на твоите претходни удари.", - "mod_epicloot_me_forestsaid_display": "Помош од шумата", - "mod_epicloot_me_forestsaid_desc": "Кога ќе те погодат, избиваат оплеткувачки корени и ги обездвижуваат непријателите во близина.", - "mod_epicloot_me_corpserot_display": "Мршојадна гнилеж", - "mod_epicloot_me_corpserot_desc": "Непријателите што ги убиваш може да прснат во облак од отров, трујќи ги противниците во близина.", - "mod_epicloot_me_icyretribution_display": "Ледена одмазда", - "mod_epicloot_me_icyretribution_desc": "Кога ќе те погодат, детонираш ледена нова околу себе. Полнењето се скалира со реткоста.", + "mod_epicloot_me_shockingcharge_display": "Шокантен полнеж: на секои {0:0.#} удари", + "mod_epicloot_me_shockingcharge_desc": "На секои {0:0.#} удари во борба, следниот удар ослободува ударен бран од молњи нанапред што нанесува {1:0.#}% од штетата на тие удари. Повисока реткост бара помалку удари.", + "mod_epicloot_me_forestsaid_display": "Помош од шумата: {0:0.#} м, {1:0.#} с одмор", + "mod_epicloot_me_forestsaid_desc": "Кога ќе те погодат, избиваат оплеткувачки корени и ги обездвижуваат непријателите во радиус од {0:0.#} метри. Повисока реткост достига подалеку и се полни за {1:0.#} секунди.", + "mod_epicloot_me_corpserot_display": "Мршојадна гнилеж: {0:0.#} отров", + "mod_epicloot_me_corpserot_desc": "Непријателите што ги убиваш може да прснат во облак од отров, нанесувајќи {0:0.#} штета од отров на противниците во близина.", + "mod_epicloot_me_icyretribution_display": "Ледена одмазда: {0:0.#} мраз", + "mod_epicloot_me_icyretribution_desc": "Кога ќе те погодат, детонираш ледена нова околу себе што нанесува {0:0.#} штета од мраз. Повисока реткост удира посилно и се полни побрзо.", "mod_epicloot_me_meteorsummoner_display": "Повикувач на метеори: {0}× штетата од ударот", "mod_epicloot_me_meteorsummoner_desc": "На секои {1} удари со оружје, твојот следен удар повикува огнен метеор врз целта што нанесува {0}× од штетата на тој удар како оган.", - "mod_epicloot_me_everflow_display": "Вечниот тек на Кралицата", - "mod_epicloot_me_everflow_desc": "Убивањето суштество дава натрупувачки бонус што го засилува обновувањето на здравје, издржливост и еитр.", - "mod_epicloot_me_necroticfire_display": "Некротичен оган", - "mod_epicloot_me_necroticfire_desc": "Ја отстранува сета физичка штета од твоето оружје и го додава тој целосен износ и како огнена и како отровна штета.", + "mod_epicloot_me_everflow_display": "Вечниот тек на Кралицата: +{0:0.#}% обновување по слој", + "mod_epicloot_me_everflow_desc": "Убивањето суштество дава натрупувачки бонус што го засилува обновувањето на здравје, издржливост и еитр за {0:0.#}% по слој, до {1:0.#} слоеви.", + "mod_epicloot_me_necroticfire_display": "Некротичен оган: +{0:0.#}% оган и отров", + "mod_epicloot_me_necroticfire_desc": "Ја отстранува сета физичка штета од твоето оружје и додава {0:0.#}% од неа како огнена штета и исто толку како отровна.", "mod_epicloot_circlet": "обод", "mod_epicloot_belt": "колан", "mod_epicloot_remains": "останоци", diff --git a/EpicLoot/localizations/Norwegian.json b/EpicLoot/localizations/Norwegian.json index 2cd2fdf71..da79f641a 100644 --- a/EpicLoot/localizations/Norwegian.json +++ b/EpicLoot/localizations/Norwegian.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Hver unnvikelsesrull fjerner {0} sekunder fra gift- og brenneffektene dine.", "mod_epicloot_me_stormrider_display": "+{0} % bevegelsesfart i stormer", "mod_epicloot_me_stormrider_desc": "+{0} % bevegelsesfart mens en storm raser.", - "mod_epicloot_me_shockingcharge_display": "Sjokkerende ladning", - "mod_epicloot_me_shockingcharge_desc": "Treffene dine i kamp bygger opp ladninger. Ved full ladning slipper neste treff løs en fremoverrettet lynsjokkbølge som gjør en del av skaden fra de forrige treffene dine.", - "mod_epicloot_me_forestsaid_display": "Skogens hjelp", - "mod_epicloot_me_forestsaid_desc": "Når du blir truffet, bryter fangende røtter frem og binder fiender i nærheten.", - "mod_epicloot_me_corpserot_display": "Likråte", - "mod_epicloot_me_corpserot_desc": "Fiender du dreper kan sprekke i en sky av gift og forgifte motstandere i nærheten.", - "mod_epicloot_me_icyretribution_display": "Iskald gjengjeldelse", - "mod_epicloot_me_icyretribution_desc": "Når du blir truffet, utløser du en frostnova rundt deg. Nedkjølingen skalerer med sjeldenheten.", + "mod_epicloot_me_shockingcharge_display": "Sjokkerende ladning: hvert {0:0.#} treff", + "mod_epicloot_me_shockingcharge_desc": "For hvert {0:0.#} treff i kamp slipper neste treff løs en fremoverrettet lynsjokkbølge som gjør {1:0.#}% av skaden fra de treffene. Høyere sjeldenhet krever færre treff.", + "mod_epicloot_me_forestsaid_display": "Skogens hjelp: {0:0.#} m, {1:0.#} s nedkjøling", + "mod_epicloot_me_forestsaid_desc": "Når du blir truffet, bryter fangende røtter frem og binder fiender innenfor {0:0.#} meter. Høyere sjeldenhet når lenger og lades opp på {1:0.#} sekunder.", + "mod_epicloot_me_corpserot_display": "Likråte: {0:0.#} gift", + "mod_epicloot_me_corpserot_desc": "Fiender du dreper kan sprekke i en sky av gift og gi motstandere i nærheten {0:0.#} giftskade.", + "mod_epicloot_me_icyretribution_display": "Iskald gjengjeldelse: {0:0.#} frost", + "mod_epicloot_me_icyretribution_desc": "Når du blir truffet, utløser du en frostnova rundt deg som gjør {0:0.#} frostskade. Høyere sjeldenhet slår hardere og lades raskere opp.", "mod_epicloot_me_meteorsummoner_display": "Meteortilkaller: {0}× treffskade", "mod_epicloot_me_meteorsummoner_desc": "Hvert {1} våpentreff kaller neste treff ned en brennende meteor på målet som gjør {0}× det treffets skade som ild.", - "mod_epicloot_me_everflow_display": "Dronningens evige strøm", - "mod_epicloot_me_everflow_desc": "Å drepe en skapning gir en stablbar effekt som øker helse-, utholdenhets- og eitrregenereringen din.", - "mod_epicloot_me_necroticfire_display": "Nekrotisk ild", - "mod_epicloot_me_necroticfire_desc": "Fjerner all fysisk skade fra våpenet ditt og legger til hele beløpet som både ild- og giftskade.", + "mod_epicloot_me_everflow_display": "Dronningens evige strøm: +{0:0.#}% regenerering per stabel", + "mod_epicloot_me_everflow_desc": "Å drepe en skapning gir en stablbar effekt som øker helse-, utholdenhets- og eitrregenereringen din med {0:0.#}% per stabel, opptil {1:0.#} stabler.", + "mod_epicloot_me_necroticfire_display": "Nekrotisk ild: +{0:0.#}% ild og gift", + "mod_epicloot_me_necroticfire_desc": "Fjerner all fysisk skade fra våpenet ditt og legger til {0:0.#}% av den som ildskade og like mye som giftskade.", "mod_epicloot_circlet": "krans", "mod_epicloot_belt": "belte", "mod_epicloot_remains": "rester", diff --git a/EpicLoot/localizations/Polish.json b/EpicLoot/localizations/Polish.json index 90e4ce6bf..215c62f79 100644 --- a/EpicLoot/localizations/Polish.json +++ b/EpicLoot/localizations/Polish.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Każdy przewrót unikowy usuwa {0} sekund z twoich efektów trucizny i podpalenia.", "mod_epicloot_me_stormrider_display": "+{0} % prędkości ruchu podczas burz", "mod_epicloot_me_stormrider_desc": "+{0} % prędkości ruchu, gdy szaleje burza.", - "mod_epicloot_me_shockingcharge_display": "Porażający ładunek", - "mod_epicloot_me_shockingcharge_desc": "Twoje trafienia w walce gromadzą ładunki. Przy pełnym ładunku następne trafienie uwalnia skierowaną do przodu falę uderzeniową błyskawic zadającą część obrażeń twoich poprzednich trafień.", - "mod_epicloot_me_forestsaid_display": "Pomoc lasu", - "mod_epicloot_me_forestsaid_desc": "Gdy zostaniesz trafiony, wybuchają pętające korzenie unieruchamiające pobliskich wrogów.", - "mod_epicloot_me_corpserot_display": "Trupi rozkład", - "mod_epicloot_me_corpserot_desc": "Zabijani przez ciebie wrogowie mogą rozprysnąć się chmurą jadu, zatruwając pobliskich przeciwników.", - "mod_epicloot_me_icyretribution_display": "Lodowa odpłata", - "mod_epicloot_me_icyretribution_desc": "Gdy zostaniesz trafiony, wywołujesz wokół siebie mroźną nową. Czas odnowienia skaluje się z rzadkością.", + "mod_epicloot_me_shockingcharge_display": "Porażający ładunek: co {0:0.#} trafień", + "mod_epicloot_me_shockingcharge_desc": "Co {0:0.#} trafień w walce następne trafienie uwalnia skierowaną do przodu falę uderzeniową błyskawic, zadającą {1:0.#}% obrażeń tych trafień. Wyższa rzadkość wymaga mniej trafień.", + "mod_epicloot_me_forestsaid_display": "Pomoc lasu: {0:0.#} m, {1:0.#} s odnowienia", + "mod_epicloot_me_forestsaid_desc": "Gdy zostaniesz trafiony, wybuchają pętające korzenie unieruchamiające wrogów w promieniu {0:0.#} metrów. Wyższa rzadkość sięga dalej i odnawia się w {1:0.#} sekund.", + "mod_epicloot_me_corpserot_display": "Trupi rozkład: {0:0.#} trucizny", + "mod_epicloot_me_corpserot_desc": "Zabijani przez ciebie wrogowie mogą rozprysnąć się chmurą jadu, zadając pobliskim przeciwnikom {0:0.#} obrażeń od trucizny.", + "mod_epicloot_me_icyretribution_display": "Lodowa odpłata: {0:0.#} mrozu", + "mod_epicloot_me_icyretribution_desc": "Gdy zostaniesz trafiony, wywołujesz wokół siebie mroźną nową zadającą {0:0.#} obrażeń od mrozu. Wyższa rzadkość uderza mocniej i odnawia się szybciej.", "mod_epicloot_me_meteorsummoner_display": "Przyzywacz meteorów: {0}× obrażeń trafienia", "mod_epicloot_me_meteorsummoner_desc": "Co {1} trafień bronią twoje następne trafienie sprowadza na cel ognisty meteor zadający {0}× obrażeń tego trafienia jako ogień.", - "mod_epicloot_me_everflow_display": "Wieczny przepływ Królowej", - "mod_epicloot_me_everflow_desc": "Zabicie stworzenia daje kumulujące się wzmocnienie zwiększające regenerację zdrowia, wytrzymałości i eitru.", - "mod_epicloot_me_necroticfire_display": "Nekrotyczny ogień", - "mod_epicloot_me_necroticfire_desc": "Odbiera twojej broni wszystkie obrażenia fizyczne i dodaje tę pełną wartość zarówno jako obrażenia od ognia, jak i od trucizny.", + "mod_epicloot_me_everflow_display": "Wieczny przepływ Królowej: +{0:0.#}% regeneracji na warstwę", + "mod_epicloot_me_everflow_desc": "Zabicie stworzenia daje kumulujące się wzmocnienie zwiększające regenerację zdrowia, wytrzymałości i eitru o {0:0.#}% na warstwę, do {1:0.#} warstw.", + "mod_epicloot_me_necroticfire_display": "Nekrotyczny ogień: +{0:0.#}% ognia i trucizny", + "mod_epicloot_me_necroticfire_desc": "Odbiera twojej broni wszystkie obrażenia fizyczne i dodaje {0:0.#}% z nich jako obrażenia od ognia oraz tyle samo jako obrażenia od trucizny.", "mod_epicloot_circlet": "opaska", "mod_epicloot_belt": "pasek", "mod_epicloot_remains": "pozostałości", diff --git a/EpicLoot/localizations/Portuguese_Brazilian.json b/EpicLoot/localizations/Portuguese_Brazilian.json index 6ae8662de..fbdb518c2 100644 --- a/EpicLoot/localizations/Portuguese_Brazilian.json +++ b/EpicLoot/localizations/Portuguese_Brazilian.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Cada rolamento de esquiva remove {0} segundos dos seus efeitos de veneno e queimadura.", "mod_epicloot_me_stormrider_display": "+{0} % de velocidade de movimento em tempestades", "mod_epicloot_me_stormrider_desc": "+{0} % de velocidade de movimento enquanto uma tempestade ruge.", - "mod_epicloot_me_shockingcharge_display": "Carga chocante", - "mod_epicloot_me_shockingcharge_desc": "Seus golpes em combate acumulam cargas. Na carga máxima, o próximo golpe libera uma onda de choque elétrica à frente que causa uma parte do dano dos seus golpes anteriores.", - "mod_epicloot_me_forestsaid_display": "Auxílio da floresta", - "mod_epicloot_me_forestsaid_desc": "Quando você é atingido, raízes emaranhadas irrompem e imobilizam os inimigos próximos.", - "mod_epicloot_me_corpserot_display": "Podridão cadavérica", - "mod_epicloot_me_corpserot_desc": "Inimigos que você mata podem explodir em uma nuvem de veneno, envenenando os adversários próximos.", - "mod_epicloot_me_icyretribution_display": "Retribuição gélida", - "mod_epicloot_me_icyretribution_desc": "Quando você é atingido, detona uma nova gélida ao seu redor. A recarga escala com a raridade.", + "mod_epicloot_me_shockingcharge_display": "Carga chocante: a cada {0:0.#} golpes", + "mod_epicloot_me_shockingcharge_desc": "A cada {0:0.#} golpes em combate, o próximo golpe libera uma onda de choque elétrica à frente que causa {1:0.#}% do dano desses golpes. Uma raridade maior exige menos golpes.", + "mod_epicloot_me_forestsaid_display": "Auxílio da floresta: {0:0.#} m, {1:0.#} s de recarga", + "mod_epicloot_me_forestsaid_desc": "Quando você é atingido, raízes emaranhadas irrompem e imobilizam os inimigos a até {0:0.#} metros. Uma raridade maior alcança mais longe e recarrega em {1:0.#} segundos.", + "mod_epicloot_me_corpserot_display": "Podridão cadavérica: {0:0.#} de veneno", + "mod_epicloot_me_corpserot_desc": "Inimigos que você mata podem explodir em uma nuvem de veneno, causando {0:0.#} de dano de veneno aos adversários próximos.", + "mod_epicloot_me_icyretribution_display": "Retribuição gélida: {0:0.#} de gelo", + "mod_epicloot_me_icyretribution_desc": "Quando você é atingido, detona uma nova gélida ao seu redor que causa {0:0.#} de dano de gelo. Uma raridade maior bate mais forte e recarrega mais rápido.", "mod_epicloot_me_meteorsummoner_display": "Invocador de meteoros: ×{0} do dano do golpe", "mod_epicloot_me_meteorsummoner_desc": "A cada {1} golpes de arma, seu próximo golpe faz cair um meteoro flamejante sobre o alvo, causando {0} vezes o dano daquele golpe como fogo.", - "mod_epicloot_me_everflow_display": "Fluxo eterno da Rainha", - "mod_epicloot_me_everflow_desc": "Matar uma criatura concede um bônus acumulável que aumenta sua regeneração de vida, estamina e eitr.", - "mod_epicloot_me_necroticfire_display": "Fogo necrótico", - "mod_epicloot_me_necroticfire_desc": "Retira todo o dano físico da sua arma e adiciona esse valor integral tanto como dano de fogo quanto de veneno.", + "mod_epicloot_me_everflow_display": "Fluxo eterno da Rainha: +{0:0.#}% de regeneração por acúmulo", + "mod_epicloot_me_everflow_desc": "Matar uma criatura concede um bônus acumulável que aumenta sua regeneração de vida, estamina e eitr em {0:0.#}% por acúmulo, até {1:0.#} acúmulos.", + "mod_epicloot_me_necroticfire_display": "Fogo necrótico: +{0:0.#}% de fogo e veneno", + "mod_epicloot_me_necroticfire_desc": "Retira todo o dano físico da sua arma e adiciona {0:0.#}% dele como dano de fogo e o mesmo tanto como dano de veneno.", "mod_epicloot_circlet": "diadema", "mod_epicloot_belt": "cinto", "mod_epicloot_remains": "restos", diff --git a/EpicLoot/localizations/Portuguese_European.json b/EpicLoot/localizations/Portuguese_European.json index 19c5776e6..307a692de 100644 --- a/EpicLoot/localizations/Portuguese_European.json +++ b/EpicLoot/localizations/Portuguese_European.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Cada rolamento de esquiva remove {0} segundos dos teus efeitos de veneno e queimadura.", "mod_epicloot_me_stormrider_display": "+{0} % de velocidade de movimento em tempestades", "mod_epicloot_me_stormrider_desc": "+{0} % de velocidade de movimento enquanto uma tempestade ruge.", - "mod_epicloot_me_shockingcharge_display": "Carga fulminante", - "mod_epicloot_me_shockingcharge_desc": "Os teus golpes em combate acumulam cargas. Na carga máxima, o golpe seguinte liberta uma onda de choque elétrica em frente que causa uma parte do dano dos teus golpes anteriores.", - "mod_epicloot_me_forestsaid_display": "Auxílio da floresta", - "mod_epicloot_me_forestsaid_desc": "Quando és atingido, raízes emaranhadas irrompem e imobilizam os inimigos próximos.", - "mod_epicloot_me_corpserot_display": "Podridão cadavérica", - "mod_epicloot_me_corpserot_desc": "Os inimigos que matas podem rebentar numa nuvem de veneno, envenenando os adversários próximos.", - "mod_epicloot_me_icyretribution_display": "Retribuição gélida", - "mod_epicloot_me_icyretribution_desc": "Quando és atingido, detonas uma nova gélida à tua volta. A recarga escala com a raridade.", + "mod_epicloot_me_shockingcharge_display": "Carga fulminante: a cada {0:0.#} golpes", + "mod_epicloot_me_shockingcharge_desc": "A cada {0:0.#} golpes em combate, o golpe seguinte liberta uma onda de choque elétrica em frente que causa {1:0.#}% do dano desses golpes. Uma raridade maior exige menos golpes.", + "mod_epicloot_me_forestsaid_display": "Auxílio da floresta: {0:0.#} m, {1:0.#} s de recarga", + "mod_epicloot_me_forestsaid_desc": "Quando és atingido, raízes emaranhadas irrompem e imobilizam os inimigos a até {0:0.#} metros. Uma raridade maior alcança mais longe e recarrega em {1:0.#} segundos.", + "mod_epicloot_me_corpserot_display": "Podridão cadavérica: {0:0.#} de veneno", + "mod_epicloot_me_corpserot_desc": "Os inimigos que matas podem rebentar numa nuvem de veneno, causando {0:0.#} de dano de veneno aos adversários próximos.", + "mod_epicloot_me_icyretribution_display": "Retribuição gélida: {0:0.#} de gelo", + "mod_epicloot_me_icyretribution_desc": "Quando és atingido, detonas uma nova gélida à tua volta que causa {0:0.#} de dano de gelo. Uma raridade maior bate mais forte e recarrega mais depressa.", "mod_epicloot_me_meteorsummoner_display": "Invocador de meteoros: ×{0} do dano do golpe", "mod_epicloot_me_meteorsummoner_desc": "A cada {1} golpes de arma, o teu golpe seguinte faz cair um meteoro flamejante sobre o alvo, causando {0} vezes o dano desse golpe como fogo.", - "mod_epicloot_me_everflow_display": "Fluxo eterno da Rainha", - "mod_epicloot_me_everflow_desc": "Matar uma criatura concede um bónus acumulável que aumenta a tua regeneração de vida, vigor e eitr.", - "mod_epicloot_me_necroticfire_display": "Fogo necrótico", - "mod_epicloot_me_necroticfire_desc": "Retira todo o dano físico da tua arma e adiciona esse valor integral tanto como dano de fogo como de veneno.", + "mod_epicloot_me_everflow_display": "Fluxo eterno da Rainha: +{0:0.#}% de regeneração por acumulação", + "mod_epicloot_me_everflow_desc": "Matar uma criatura concede um bónus acumulável que aumenta a tua regeneração de vida, vigor e eitr em {0:0.#}% por acumulação, até {1:0.#} acumulações.", + "mod_epicloot_me_necroticfire_display": "Fogo necrótico: +{0:0.#}% de fogo e veneno", + "mod_epicloot_me_necroticfire_desc": "Retira todo o dano físico da tua arma e adiciona {0:0.#}% dele como dano de fogo e o mesmo tanto como dano de veneno.", "mod_epicloot_circlet": "coroa", "mod_epicloot_belt": "cinto", "mod_epicloot_remains": "restos", diff --git a/EpicLoot/localizations/Romanian.json b/EpicLoot/localizations/Romanian.json index 98fd656fc..202e311c7 100644 --- a/EpicLoot/localizations/Romanian.json +++ b/EpicLoot/localizations/Romanian.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Fiecare rostogolire de eschivă elimină {0} secunde din efectele tale de otravă și ardere.", "mod_epicloot_me_stormrider_display": "+{0} % viteză de deplasare în furtuni", "mod_epicloot_me_stormrider_desc": "+{0} % viteză de deplasare cât timp bântuie o furtună.", - "mod_epicloot_me_shockingcharge_display": "Încărcătură șocantă", - "mod_epicloot_me_shockingcharge_desc": "Loviturile tale în luptă acumulează încărcături. La încărcătură maximă, următoarea lovitură eliberează o undă de șoc electrică înainte, care provoacă o parte din daunele loviturilor tale anterioare.", - "mod_epicloot_me_forestsaid_display": "Ajutorul pădurii", - "mod_epicloot_me_forestsaid_desc": "Când ești lovit, rădăcini încâlcite izbucnesc și imobilizează inamicii din apropiere.", - "mod_epicloot_me_corpserot_display": "Putrezire cadaverică", - "mod_epicloot_me_corpserot_desc": "Inamicii pe care îi ucizi pot exploda într-un nor de venin, otrăvind adversarii din apropiere.", - "mod_epicloot_me_icyretribution_display": "Răzbunare înghețată", - "mod_epicloot_me_icyretribution_desc": "Când ești lovit, detonezi o novă de ger în jurul tău. Reîncărcarea se scalează cu raritatea.", + "mod_epicloot_me_shockingcharge_display": "Încărcătură șocantă: la fiecare {0:0.#} lovituri", + "mod_epicloot_me_shockingcharge_desc": "La fiecare {0:0.#} lovituri în luptă, următoarea lovitură eliberează o undă de șoc electrică înainte, care provoacă {1:0.#}% din daunele acelor lovituri. O raritate mai mare cere mai puține lovituri.", + "mod_epicloot_me_forestsaid_display": "Ajutorul pădurii: {0:0.#} m, {1:0.#} s reîncărcare", + "mod_epicloot_me_forestsaid_desc": "Când ești lovit, rădăcini încâlcite izbucnesc și imobilizează inamicii pe o rază de {0:0.#} metri. O raritate mai mare ajunge mai departe și se reîncarcă în {1:0.#} secunde.", + "mod_epicloot_me_corpserot_display": "Putrezire cadaverică: {0:0.#} otravă", + "mod_epicloot_me_corpserot_desc": "Inamicii pe care îi ucizi pot exploda într-un nor de venin, provocând {0:0.#} daune de otravă adversarilor din apropiere.", + "mod_epicloot_me_icyretribution_display": "Răzbunare înghețată: {0:0.#} ger", + "mod_epicloot_me_icyretribution_desc": "Când ești lovit, detonezi o novă de ger în jurul tău care provoacă {0:0.#} daune de ger. O raritate mai mare lovește mai tare și se reîncarcă mai repede.", "mod_epicloot_me_meteorsummoner_display": "Invocator de meteori: ×{0} daunele loviturii", "mod_epicloot_me_meteorsummoner_desc": "La fiecare {1} lovituri cu arma, următoarea ta lovitură cheamă un meteor de foc asupra țintei, care provoacă de {0} ori daunele acelei lovituri sub formă de foc.", - "mod_epicloot_me_everflow_display": "Curgerea veșnică a Reginei", - "mod_epicloot_me_everflow_desc": "Uciderea unei creaturi acordă un bonus cumulativ care sporește regenerarea vieții, staminei și eitrului.", - "mod_epicloot_me_necroticfire_display": "Foc necrotic", - "mod_epicloot_me_necroticfire_desc": "Îndepărtează toate daunele fizice ale armei tale și adaugă întreaga valoare atât ca daune de foc, cât și de otravă.", + "mod_epicloot_me_everflow_display": "Curgerea veșnică a Reginei: +{0:0.#}% regenerare pe strat", + "mod_epicloot_me_everflow_desc": "Uciderea unei creaturi acordă un bonus cumulativ care sporește regenerarea vieții, staminei și eitrului cu {0:0.#}% pe strat, până la {1:0.#} straturi.", + "mod_epicloot_me_necroticfire_display": "Foc necrotic: +{0:0.#}% foc și otravă", + "mod_epicloot_me_necroticfire_desc": "Îndepărtează toate daunele fizice ale armei tale și adaugă {0:0.#}% din ele ca daune de foc și tot atât ca daune de otravă.", "mod_epicloot_circlet": "cercel", "mod_epicloot_belt": "curea", "mod_epicloot_remains": "rămășițe", diff --git a/EpicLoot/localizations/Russian.json b/EpicLoot/localizations/Russian.json index 1c4130c34..092837436 100644 --- a/EpicLoot/localizations/Russian.json +++ b/EpicLoot/localizations/Russian.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Каждый перекат уклонения снимает {0} секунд с ваших эффектов яда и горения.", "mod_epicloot_me_stormrider_display": "+{0} % к скорости передвижения в бурю", "mod_epicloot_me_stormrider_desc": "+{0} % к скорости передвижения, пока бушует буря.", - "mod_epicloot_me_shockingcharge_display": "Разящий заряд", - "mod_epicloot_me_shockingcharge_desc": "Ваши удары в бою накапливают заряды. При полном заряде следующий удар выпускает вперёд ударную волну молний, наносящую часть урона ваших предыдущих ударов.", - "mod_epicloot_me_forestsaid_display": "Помощь леса", - "mod_epicloot_me_forestsaid_desc": "Когда вас бьют, из земли вырываются опутывающие корни и обездвиживают врагов поблизости.", - "mod_epicloot_me_corpserot_display": "Трупная гниль", - "mod_epicloot_me_corpserot_desc": "Убитые вами враги могут лопнуть облаком яда, отравляя противников поблизости.", - "mod_epicloot_me_icyretribution_display": "Ледяное возмездие", - "mod_epicloot_me_icyretribution_desc": "Когда вас бьют, вокруг вас взрывается ледяная нова. Перезарядка зависит от редкости.", + "mod_epicloot_me_shockingcharge_display": "Разящий заряд: каждые {0:0.#} ударов", + "mod_epicloot_me_shockingcharge_desc": "Каждые {0:0.#} ударов в бою следующий удар выпускает вперёд ударную волну молний, наносящую {1:0.#}% урона этих ударов. Более высокая редкость требует меньше ударов.", + "mod_epicloot_me_forestsaid_display": "Помощь леса: {0:0.#} м, перезарядка {1:0.#} с", + "mod_epicloot_me_forestsaid_desc": "Когда вас бьют, из земли вырываются опутывающие корни и обездвиживают врагов в радиусе {0:0.#} метров. Более высокая редкость бьёт дальше и перезаряжается за {1:0.#} секунд.", + "mod_epicloot_me_corpserot_display": "Трупная гниль: {0:0.#} яда", + "mod_epicloot_me_corpserot_desc": "Убитые вами враги могут лопнуть облаком яда, нанося противникам поблизости {0:0.#} урона ядом.", + "mod_epicloot_me_icyretribution_display": "Ледяное возмездие: {0:0.#} холода", + "mod_epicloot_me_icyretribution_desc": "Когда вас бьют, вокруг вас взрывается ледяная нова, наносящая {0:0.#} урона холодом. Более высокая редкость бьёт сильнее и перезаряжается быстрее.", "mod_epicloot_me_meteorsummoner_display": "Призыватель метеоров: {0}× урона удара", "mod_epicloot_me_meteorsummoner_desc": "Каждые {1} ударов оружием ваш следующий удар обрушивает на цель огненный метеор, наносящий {0}× урона того удара в виде огня.", - "mod_epicloot_me_everflow_display": "Вечный поток Королевы", - "mod_epicloot_me_everflow_desc": "Убийство существа даёт складывающийся эффект, повышающий восстановление здоровья, выносливости и эйтра.", - "mod_epicloot_me_necroticfire_display": "Некротический огонь", - "mod_epicloot_me_necroticfire_desc": "Снимает с вашего оружия весь физический урон и добавляет эту полную величину и как урон огнём, и как урон ядом.", + "mod_epicloot_me_everflow_display": "Вечный поток Королевы: +{0:0.#}% восстановления за стак", + "mod_epicloot_me_everflow_desc": "Убийство существа даёт складывающийся эффект, повышающий восстановление здоровья, выносливости и эйтра на {0:0.#}% за стак, до {1:0.#} стаков.", + "mod_epicloot_me_necroticfire_display": "Некротический огонь: +{0:0.#}% огня и яда", + "mod_epicloot_me_necroticfire_desc": "Снимает с вашего оружия весь физический урон и добавляет {0:0.#}% от него как урон огнём и столько же как урон ядом.", "mod_epicloot_circlet": "венец", "mod_epicloot_belt": "пояс", "mod_epicloot_remains": "останки", diff --git a/EpicLoot/localizations/Serbian.json b/EpicLoot/localizations/Serbian.json index 606eafd84..6ec8b87c5 100644 --- a/EpicLoot/localizations/Serbian.json +++ b/EpicLoot/localizations/Serbian.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Свако котрљање за измицање уклања {0} секунди са твојих отровних и горућих ефеката.", "mod_epicloot_me_stormrider_display": "+{0} % брзине кретања у олујама", "mod_epicloot_me_stormrider_desc": "+{0} % брзине кретања док бесни олуја.", - "mod_epicloot_me_shockingcharge_display": "Шокантни набој", - "mod_epicloot_me_shockingcharge_desc": "Твоји борбени поготци скупљају набоје. При пуном набоју следећи погодак ослобађа муњевити ударни талас унапред који наноси део штете твојих претходних погодака.", - "mod_epicloot_me_forestsaid_display": "Помоћ шуме", - "mod_epicloot_me_forestsaid_desc": "Када те погоде, избијају спутавајуће корење и имобилишу оближње непријатеље.", - "mod_epicloot_me_corpserot_display": "Лешна трулеж", - "mod_epicloot_me_corpserot_desc": "Непријатељи које убијеш могу да прсну у облак отрова, тровајући оближње противнике.", - "mod_epicloot_me_icyretribution_display": "Ледена одмазда", - "mod_epicloot_me_icyretribution_desc": "Када те погоде, детонираш ледену нову око себе. Време опоравка скалира се са реткошћу.", + "mod_epicloot_me_shockingcharge_display": "Шокантни набој: на сваких {0:0.#} погодака", + "mod_epicloot_me_shockingcharge_desc": "На сваких {0:0.#} борбених погодака следећи погодак ослобађа муњевити ударни талас унапред који наноси {1:0.#}% штете тих погодака. Виша реткост тражи мање погодака.", + "mod_epicloot_me_forestsaid_display": "Помоћ шуме: {0:0.#} м, {1:0.#} с одмора", + "mod_epicloot_me_forestsaid_desc": "Када те погоде, избија спутавајуће корење и имобилише непријатеље у кругу од {0:0.#} метара. Виша реткост досеже даље и пуни се за {1:0.#} секунди.", + "mod_epicloot_me_corpserot_display": "Лешна трулеж: {0:0.#} отрова", + "mod_epicloot_me_corpserot_desc": "Непријатељи које убијеш могу да прсну у облак отрова, наносећи {0:0.#} штете отровом оближњим противницима.", + "mod_epicloot_me_icyretribution_display": "Ледена одмазда: {0:0.#} мраза", + "mod_epicloot_me_icyretribution_desc": "Када те погоде, детонираш ледену нову око себе која наноси {0:0.#} штете мразом. Виша реткост удара јаче и брже се опоравља.", "mod_epicloot_me_meteorsummoner_display": "Призивач метеора: {0}× штета поготка", "mod_epicloot_me_meteorsummoner_desc": "Сваких {1} погодака оружјем твој следећи погодак призива ватрени метеор на мету који наноси {0}× штету тог поготка као ватру.", - "mod_epicloot_me_everflow_display": "Вечни ток Краљице", - "mod_epicloot_me_everflow_desc": "Убијање створења даје сложиви бонус који појачава регенерацију здравља, издржљивости и еитра.", - "mod_epicloot_me_necroticfire_display": "Некротична ватра", - "mod_epicloot_me_necroticfire_desc": "Уклања сву физичку штету са твог оружја и додаје тај цео износ и као ватрену и као отровну штету.", + "mod_epicloot_me_everflow_display": "Вечни ток Краљице: +{0:0.#}% регенерације по слоју", + "mod_epicloot_me_everflow_desc": "Убијање створења даје сложиви бонус који појачава регенерацију здравља, издржљивости и еитра за {0:0.#}% по слоју, до {1:0.#} слојева.", + "mod_epicloot_me_necroticfire_display": "Некротична ватра: +{0:0.#}% ватре и отрова", + "mod_epicloot_me_necroticfire_desc": "Уклања сву физичку штету са твог оружја и додаје {0:0.#}% од ње као ватрену штету и исто толико као отровну.", "mod_epicloot_circlet": "обод", "mod_epicloot_belt": "каиш", "mod_epicloot_remains": "остаци", diff --git a/EpicLoot/localizations/Slovak.json b/EpicLoot/localizations/Slovak.json index aef24888a..0cacae028 100644 --- a/EpicLoot/localizations/Slovak.json +++ b/EpicLoot/localizations/Slovak.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Každý úhybový kotúľ odoberie {0} sekúnd z tvojich jedových a horiacich efektov.", "mod_epicloot_me_stormrider_display": "+{0} % rýchlosti pohybu v búrkach", "mod_epicloot_me_stormrider_desc": "+{0} % rýchlosti pohybu, kým zúri búrka.", - "mod_epicloot_me_shockingcharge_display": "Šokujúci náboj", - "mod_epicloot_me_shockingcharge_desc": "Tvoje zásahy v boji hromadia náboje. Pri plnom náboji ďalší zásah vypustí dopredu smerujúcu bleskovú rázovú vlnu, ktorá spôsobí časť poškodenia tvojich predchádzajúcich zásahov.", - "mod_epicloot_me_forestsaid_display": "Pomoc lesa", - "mod_epicloot_me_forestsaid_desc": "Keď si zasiahnutý, vyrazia spútavajúce korene a znehybnia nepriateľov nablízku.", - "mod_epicloot_me_corpserot_display": "Mŕtvolný rozklad", - "mod_epicloot_me_corpserot_desc": "Nepriatelia, ktorých zabiješ, môžu prasknúť v oblak jedu a otráviť protivníkov nablízku.", - "mod_epicloot_me_icyretribution_display": "Ľadová odplata", - "mod_epicloot_me_icyretribution_desc": "Keď si zasiahnutý, odpáliš okolo seba mrazivú novu. Doba obnovy sa škáluje so vzácnosťou.", + "mod_epicloot_me_shockingcharge_display": "Šokujúci náboj: každých {0:0.#} zásahov", + "mod_epicloot_me_shockingcharge_desc": "Každých {0:0.#} zásahov v boji ďalší zásah vypustí dopredu smerujúcu bleskovú rázovú vlnu, ktorá spôsobí {1:0.#} % poškodenia týchto zásahov. Vyššia vzácnosť vyžaduje menej zásahov.", + "mod_epicloot_me_forestsaid_display": "Pomoc lesa: {0:0.#} m, {1:0.#} s nabíjania", + "mod_epicloot_me_forestsaid_desc": "Keď si zasiahnutý, vyrazia spútavajúce korene a znehybnia nepriateľov do {0:0.#} metrov. Vyššia vzácnosť dosiahne ďalej a nabije sa za {1:0.#} sekúnd.", + "mod_epicloot_me_corpserot_display": "Mŕtvolný rozklad: {0:0.#} jedu", + "mod_epicloot_me_corpserot_desc": "Nepriatelia, ktorých zabiješ, môžu prasknúť v oblak jedu a spôsobiť protivníkom nablízku {0:0.#} poškodenia jedom.", + "mod_epicloot_me_icyretribution_display": "Ľadová odplata: {0:0.#} mrazu", + "mod_epicloot_me_icyretribution_desc": "Keď si zasiahnutý, odpáliš okolo seba mrazivú novu, ktorá spôsobí {0:0.#} poškodenia mrazom. Vyššia vzácnosť udrie silnejšie a obnoví sa skôr.", "mod_epicloot_me_meteorsummoner_display": "Privolávač meteorov: {0}× poškodenie zásahu", "mod_epicloot_me_meteorsummoner_desc": "Každých {1} zásahov zbraňou zvolá tvoj ďalší zásah na cieľ ohnivý meteor, ktorý spôsobí {0}× poškodenie toho zásahu ako oheň.", - "mod_epicloot_me_everflow_display": "Večný tok Kráľovnej", - "mod_epicloot_me_everflow_desc": "Zabitie tvora udelí vrstviace sa posilnenie, ktoré zvyšuje regeneráciu zdravia, výdrže a eitru.", - "mod_epicloot_me_necroticfire_display": "Nekrotický oheň", - "mod_epicloot_me_necroticfire_desc": "Odoberie tvojej zbrani všetko fyzické poškodenie a pridá celú túto hodnotu ako ohnivé aj jedovaté poškodenie.", + "mod_epicloot_me_everflow_display": "Večný tok Kráľovnej: +{0:0.#} % regenerácie za vrstvu", + "mod_epicloot_me_everflow_desc": "Zabitie tvora udelí vrstviace sa posilnenie, ktoré zvyšuje regeneráciu zdravia, výdrže a eitru o {0:0.#} % za vrstvu, až do {1:0.#} vrstiev.", + "mod_epicloot_me_necroticfire_display": "Nekrotický oheň: +{0:0.#} % ohňa a jedu", + "mod_epicloot_me_necroticfire_desc": "Odoberie tvojej zbrani všetko fyzické poškodenie a pridá {0:0.#} % z neho ako ohnivé poškodenie a rovnako toľko ako jedovaté.", "mod_epicloot_circlet": "korunka", "mod_epicloot_belt": "opasok", "mod_epicloot_remains": "zvyšky", diff --git a/EpicLoot/localizations/Spanish.json b/EpicLoot/localizations/Spanish.json index 5992bc40c..67291faf4 100644 --- a/EpicLoot/localizations/Spanish.json +++ b/EpicLoot/localizations/Spanish.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Cada voltereta de esquiva elimina {0} segundos de tus efectos de veneno y quemadura.", "mod_epicloot_me_stormrider_display": "+{0} % de velocidad de movimiento en tormentas", "mod_epicloot_me_stormrider_desc": "+{0} % de velocidad de movimiento mientras ruge una tormenta.", - "mod_epicloot_me_shockingcharge_display": "Carga electrizante", - "mod_epicloot_me_shockingcharge_desc": "Tus golpes en combate acumulan cargas. Al máximo, el siguiente golpe libera una onda de choque eléctrica hacia delante que inflige una parte del daño de tus golpes anteriores.", - "mod_epicloot_me_forestsaid_display": "Ayuda del bosque", - "mod_epicloot_me_forestsaid_desc": "Cuando te golpean, brotan raíces que inmovilizan a los enemigos cercanos.", - "mod_epicloot_me_corpserot_display": "Putrefacción cadavérica", - "mod_epicloot_me_corpserot_desc": "Los enemigos que matas pueden estallar en una nube de veneno, envenenando a los adversarios cercanos.", - "mod_epicloot_me_icyretribution_display": "Represalia gélida", - "mod_epicloot_me_icyretribution_desc": "Cuando te golpean, detonas una nova de escarcha a tu alrededor. El tiempo de reutilización escala con la rareza.", + "mod_epicloot_me_shockingcharge_display": "Carga electrizante: cada {0:0.#} golpes", + "mod_epicloot_me_shockingcharge_desc": "Cada {0:0.#} golpes en combate, el siguiente golpe libera una onda de choque eléctrica hacia delante que inflige el {1:0.#}% del daño de esos golpes. Una rareza mayor exige menos golpes.", + "mod_epicloot_me_forestsaid_display": "Ayuda del bosque: {0:0.#} m, {1:0.#} s de recarga", + "mod_epicloot_me_forestsaid_desc": "Cuando te golpean, brotan raíces que inmovilizan a los enemigos en {0:0.#} metros. Una rareza mayor llega más lejos y se recarga en {1:0.#} segundos.", + "mod_epicloot_me_corpserot_display": "Putrefacción cadavérica: {0:0.#} de veneno", + "mod_epicloot_me_corpserot_desc": "Los enemigos que matas pueden estallar en una nube de veneno, infligiendo {0:0.#} de daño de veneno a los adversarios cercanos.", + "mod_epicloot_me_icyretribution_display": "Represalia gélida: {0:0.#} de escarcha", + "mod_epicloot_me_icyretribution_desc": "Cuando te golpean, detonas una nova de escarcha a tu alrededor que inflige {0:0.#} de daño de escarcha. Una rareza mayor golpea más fuerte y se recarga antes.", "mod_epicloot_me_meteorsummoner_display": "Invocador de meteoros: ×{0} del daño del golpe", "mod_epicloot_me_meteorsummoner_desc": "Cada {1} golpes de arma, tu siguiente golpe hace caer un meteoro ardiente sobre el objetivo que inflige {0} veces el daño de ese golpe como fuego.", - "mod_epicloot_me_everflow_display": "Flujo eterno de la Reina", - "mod_epicloot_me_everflow_desc": "Matar a una criatura otorga una mejora acumulable que potencia tu regeneración de salud, resistencia y eitr.", - "mod_epicloot_me_necroticfire_display": "Fuego necrótico", - "mod_epicloot_me_necroticfire_desc": "Elimina todo el daño físico de tu arma y añade esa cantidad completa como daño de fuego y de veneno a la vez.", + "mod_epicloot_me_everflow_display": "Flujo eterno de la Reina: +{0:0.#}% de regeneración por acumulación", + "mod_epicloot_me_everflow_desc": "Matar a una criatura otorga una mejora acumulable que potencia tu regeneración de salud, resistencia y eitr en un {0:0.#}% por acumulación, hasta {1:0.#} acumulaciones.", + "mod_epicloot_me_necroticfire_display": "Fuego necrótico: +{0:0.#}% de fuego y veneno", + "mod_epicloot_me_necroticfire_desc": "Elimina todo el daño físico de tu arma y añade un {0:0.#}% de este como daño de fuego y otro tanto como daño de veneno.", "mod_epicloot_circlet": "diadema", "mod_epicloot_belt": "cinturón", "mod_epicloot_remains": "restos", diff --git a/EpicLoot/localizations/Swedish.json b/EpicLoot/localizations/Swedish.json index acece216e..1bf1c1f83 100644 --- a/EpicLoot/localizations/Swedish.json +++ b/EpicLoot/localizations/Swedish.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Varje undanrullning tar bort {0} sekunder från dina gift- och brinneffekter.", "mod_epicloot_me_stormrider_display": "+{0} % rörelsehastighet i stormar", "mod_epicloot_me_stormrider_desc": "+{0} % rörelsehastighet medan en storm rasar.", - "mod_epicloot_me_shockingcharge_display": "Chockande laddning", - "mod_epicloot_me_shockingcharge_desc": "Dina träffar i strid bygger upp laddningar. Vid full laddning släpper nästa träff loss en framåtriktad blixtchockvåg som gör en del av dina tidigare träffars skada.", - "mod_epicloot_me_forestsaid_display": "Skogens hjälp", - "mod_epicloot_me_forestsaid_desc": "När du träffas bryter snärjande rötter fram och binder fiender i närheten.", - "mod_epicloot_me_corpserot_display": "Likröta", - "mod_epicloot_me_corpserot_desc": "Fiender du dödar kan brista i ett moln av gift och förgifta närliggande motståndare.", - "mod_epicloot_me_icyretribution_display": "Isig vedergällning", - "mod_epicloot_me_icyretribution_desc": "När du träffas utlöser du en frostnova omkring dig. Nedkylningen skalar med sällsyntheten.", + "mod_epicloot_me_shockingcharge_display": "Chockande laddning: var {0:0.#} träff", + "mod_epicloot_me_shockingcharge_desc": "Var {0:0.#} träff i strid släpper nästa träff loss en framåtriktad blixtchockvåg som gör {1:0.#}% av de träffarnas skada. Högre sällsynthet kräver färre träffar.", + "mod_epicloot_me_forestsaid_display": "Skogens hjälp: {0:0.#} m, {1:0.#} s nedkylning", + "mod_epicloot_me_forestsaid_desc": "När du träffas bryter snärjande rötter fram och binder fiender inom {0:0.#} meter. Högre sällsynthet når längre och laddas på {1:0.#} sekunder.", + "mod_epicloot_me_corpserot_display": "Likröta: {0:0.#} gift", + "mod_epicloot_me_corpserot_desc": "Fiender du dödar kan brista i ett moln av gift och ge närliggande motståndare {0:0.#} giftskada.", + "mod_epicloot_me_icyretribution_display": "Isig vedergällning: {0:0.#} frost", + "mod_epicloot_me_icyretribution_desc": "När du träffas utlöser du en frostnova omkring dig som gör {0:0.#} frostskada. Högre sällsynthet slår hårdare och laddas snabbare.", "mod_epicloot_me_meteorsummoner_display": "Meteorbesvärjare: {0}× träffskada", "mod_epicloot_me_meteorsummoner_desc": "Var {1} vapenträff kallar din nästa träff ned en brinnande meteor på målet som gör {0}× den träffens skada som eld.", - "mod_epicloot_me_everflow_display": "Drottningens eviga flöde", - "mod_epicloot_me_everflow_desc": "Att döda en varelse ger en staplingsbar effekt som ökar din hälso-, uthållighets- och eitrregenerering.", - "mod_epicloot_me_necroticfire_display": "Nekrotisk eld", - "mod_epicloot_me_necroticfire_desc": "Tar bort all fysisk skada från ditt vapen och lägger till hela beloppet som både eld- och giftskada.", + "mod_epicloot_me_everflow_display": "Drottningens eviga flöde: +{0:0.#}% regenerering per stack", + "mod_epicloot_me_everflow_desc": "Att döda en varelse ger en staplingsbar effekt som ökar din hälso-, uthållighets- och eitrregenerering med {0:0.#}% per stack, upp till {1:0.#} stackar.", + "mod_epicloot_me_necroticfire_display": "Nekrotisk eld: +{0:0.#}% eld och gift", + "mod_epicloot_me_necroticfire_desc": "Tar bort all fysisk skada från ditt vapen och lägger till {0:0.#}% av den som eldskada och lika mycket som giftskada.", "mod_epicloot_circlet": "cirkel", "mod_epicloot_belt": "bälte", "mod_epicloot_remains": "rester", diff --git a/EpicLoot/localizations/Thai.json b/EpicLoot/localizations/Thai.json index cbf65f830..525872c31 100644 --- a/EpicLoot/localizations/Thai.json +++ b/EpicLoot/localizations/Thai.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "การกลิ้งหลบแต่ละครั้งลบเอฟเฟกต์พิษและไฟไหม้ออกไป {0} วินาที", "mod_epicloot_me_stormrider_display": "+{0}% ความเร็วเคลื่อนที่ในพายุ", "mod_epicloot_me_stormrider_desc": "+{0}% ความเร็วเคลื่อนที่ขณะที่พายุกระหน่ำ", - "mod_epicloot_me_shockingcharge_display": "ประจุช็อก", - "mod_epicloot_me_shockingcharge_desc": "การโจมตีโดนในการต่อสู้จะสะสมประจุ เมื่อเต็ม การฟาดครั้งถัดไปจะปล่อยคลื่นช็อกสายฟ้าไปข้างหน้า สร้างความเสียหายส่วนหนึ่งของการโจมตีครั้งก่อน", - "mod_epicloot_me_forestsaid_display": "ความช่วยเหลือของป่า", - "mod_epicloot_me_forestsaid_desc": "เมื่อถูกโจมตี รากไม้ที่พันเกี่ยวจะผุดขึ้นมาจับศัตรูที่อยู่ใกล้", - "mod_epicloot_me_corpserot_display": "ซากเน่า", - "mod_epicloot_me_corpserot_desc": "ศัตรูที่ถูกสังหารอาจระเบิดเป็นกลุ่มควันพิษที่ทำให้ศัตรูใกล้เคียงติดพิษ", - "mod_epicloot_me_icyretribution_display": "การชำระแค้นเยือกแข็ง", - "mod_epicloot_me_icyretribution_desc": "จุดชนวนการระเบิดน้ำแข็งรอบตัวคุณเมื่อถูกโจมตี คูลดาวน์แปรผันตามความหายาก", + "mod_epicloot_me_shockingcharge_display": "ประจุช็อก: ทุก {0:0.#} ครั้งที่โจมตีโดน", + "mod_epicloot_me_shockingcharge_desc": "ทุก {0:0.#} ครั้งที่โจมตีโดนในการต่อสู้ การฟาดครั้งถัดไปจะปล่อยคลื่นช็อกสายฟ้าไปข้างหน้า สร้างความเสียหาย {1:0.#}% ของความเสียหายจากการโจมตีเหล่านั้น ความหายากที่สูงกว่าต้องการจำนวนครั้งน้อยลง", + "mod_epicloot_me_forestsaid_display": "ความช่วยเหลือของป่า: {0:0.#} ม. คูลดาวน์ {1:0.#} วิ", + "mod_epicloot_me_forestsaid_desc": "เมื่อถูกโจมตี รากไม้ที่พันเกี่ยวจะผุดขึ้นมาจับศัตรูในระยะ {0:0.#} เมตร ความหายากที่สูงกว่าจะเข้าถึงได้ไกลขึ้นและฟื้นตัวใน {1:0.#} วินาที", + "mod_epicloot_me_corpserot_display": "ซากเน่า: พิษ {0:0.#}", + "mod_epicloot_me_corpserot_desc": "ศัตรูที่ถูกสังหารอาจระเบิดเป็นกลุ่มควันพิษ สร้างความเสียหายพิษ {0:0.#} แก่ศัตรูใกล้เคียง", + "mod_epicloot_me_icyretribution_display": "การชำระแค้นเยือกแข็ง: น้ำแข็ง {0:0.#}", + "mod_epicloot_me_icyretribution_desc": "จุดชนวนการระเบิดน้ำแข็งรอบตัวคุณเมื่อถูกโจมตี สร้างความเสียหายน้ำแข็ง {0:0.#} ความหายากที่สูงกว่าจะแรงกว่าและฟื้นตัวเร็วกว่า", "mod_epicloot_me_meteorsummoner_display": "ผู้เรียกอุกกาบาต: {0} เท่าของความเสียหายที่โจมตีโดน", "mod_epicloot_me_meteorsummoner_desc": "ทุก {1} ครั้งที่โจมตีโดนด้วยอาวุธนี้ การฟาดครั้งถัดไปจะเรียกอุกกาบาตลุกโชนลงมาใส่เป้าหมาย สร้างความเสียหายไฟ {0} เท่าของการฟาดครั้งนั้น", - "mod_epicloot_me_everflow_display": "การไหลนิรันดร์ของราชินี", - "mod_epicloot_me_everflow_desc": "การสังหารสิ่งมีชีวิตให้บัฟที่ซ้อนชั้นได้ ซึ่งเพิ่มการฟื้นพลังชีวิต สตามินา และเอียเทอร์", - "mod_epicloot_me_necroticfire_display": "เพลิงเน่าตาย", - "mod_epicloot_me_necroticfire_desc": "ลบความเสียหายกายภาพทั้งหมดออกจากอาวุธ แล้วเพิ่มปริมาณเต็มนั้นเข้าเป็นทั้งความเสียหายไฟและความเสียหายพิษ", + "mod_epicloot_me_everflow_display": "การไหลนิรันดร์ของราชินี: ฟื้นฟู +{0:0.#}% ต่อชั้น", + "mod_epicloot_me_everflow_desc": "การสังหารสิ่งมีชีวิตให้บัฟที่ซ้อนชั้นได้ ซึ่งเพิ่มการฟื้นพลังชีวิต สตามินา และเอียเทอร์ {0:0.#}% ต่อชั้น สูงสุด {1:0.#} ชั้น", + "mod_epicloot_me_necroticfire_display": "เพลิงเน่าตาย: ไฟและพิษ +{0:0.#}%", + "mod_epicloot_me_necroticfire_desc": "ลบความเสียหายกายภาพทั้งหมดออกจากอาวุธ แล้วเพิ่ม {0:0.#}% ของค่านั้นเป็นความเสียหายไฟ และเท่ากันอีกครั้งเป็นความเสียหายพิษ", "mod_epicloot_circlet": "วงกลม", "mod_epicloot_belt": "เข็มขัด", "mod_epicloot_remains": "สิ่งที่เหลือ", diff --git a/EpicLoot/localizations/Turkish.json b/EpicLoot/localizations/Turkish.json index ea1fc39f4..446117640 100644 --- a/EpicLoot/localizations/Turkish.json +++ b/EpicLoot/localizations/Turkish.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Her kaçınma taklası zehir ve yanma etkilerinden {0} saniye siler.", "mod_epicloot_me_stormrider_display": "Fırtınalarda +%{0} hareket hızı", "mod_epicloot_me_stormrider_desc": "Bir fırtına sürdüğü sürece +%{0} hareket hızı.", - "mod_epicloot_me_shockingcharge_display": "Çarpıcı yük", - "mod_epicloot_me_shockingcharge_desc": "Savaştaki vuruşların yük biriktirir. Yük dolduğunda sonraki vuruş, önceki vuruşlarının hasarının bir kısmını veren ileriye doğru bir yıldırım şok dalgası salar.", - "mod_epicloot_me_forestsaid_display": "Ormanın yardımı", - "mod_epicloot_me_forestsaid_desc": "Vurulduğunda, dolanan kökler fışkırır ve yakındaki düşmanları hareketsiz bırakır.", - "mod_epicloot_me_corpserot_display": "Ceset çürümesi", - "mod_epicloot_me_corpserot_desc": "Öldürdüğün düşmanlar bir zehir bulutu halinde patlayarak yakındaki rakipleri zehirleyebilir.", - "mod_epicloot_me_icyretribution_display": "Buzlu misilleme", - "mod_epicloot_me_icyretribution_desc": "Vurulduğunda çevrende bir buz novası patlatırsın. Bekleme süresi nadirlikle ölçeklenir.", + "mod_epicloot_me_shockingcharge_display": "Çarpıcı yük: Her {0:0.#} vuruşta", + "mod_epicloot_me_shockingcharge_desc": "Her {0:0.#} savaş vuruşunda sonraki vuruşun, o vuruşların verdiği hasarın %{1:0.#} kadarını veren ileriye doğru bir yıldırım şok dalgası salar. Daha nadir bir kıymık daha az vuruş gerektirir.", + "mod_epicloot_me_forestsaid_display": "Ormanın yardımı: {0:0.#}m, {1:0.#}sn bekleme", + "mod_epicloot_me_forestsaid_desc": "Vurulduğunda, dolanan kökler fışkırır ve {0:0.#} metre içindeki düşmanları hareketsiz bırakır. Daha nadir bir kıymık daha uzağa ulaşır ve {1:0.#} saniyede dolar.", + "mod_epicloot_me_corpserot_display": "Ceset çürümesi: {0:0.#} zehir", + "mod_epicloot_me_corpserot_desc": "Öldürdüğün düşmanlar bir zehir bulutu halinde patlayarak yakındaki rakiplere {0:0.#} zehir hasarı verebilir.", + "mod_epicloot_me_icyretribution_display": "Buzlu misilleme: {0:0.#} buz", + "mod_epicloot_me_icyretribution_desc": "Vurulduğunda çevrende {0:0.#} buz hasarı veren bir buz novası patlatırsın. Daha nadir bir kıymık daha sert vurur ve daha çabuk dolar.", "mod_epicloot_me_meteorsummoner_display": "Meteor çağırıcı: {0}× vuruş hasarı", "mod_epicloot_me_meteorsummoner_desc": "Her {1} silah vuruşunda, sonraki vuruşun hedefe alevli bir meteor indirir ve o vuruşun hasarının {0} katını ateş olarak verir.", - "mod_epicloot_me_everflow_display": "Kraliçe'nin sonsuz akışı", - "mod_epicloot_me_everflow_desc": "Bir yaratığı öldürmek, sağlık, dayanıklılık ve eitr yenilenmeni artıran yığılabilir bir güçlendirme verir.", - "mod_epicloot_me_necroticfire_display": "Nekrotik ateş", - "mod_epicloot_me_necroticfire_desc": "Silahındaki tüm fiziksel hasarı alır ve bu tam miktarı hem ateş hem de zehir hasarı olarak ekler.", + "mod_epicloot_me_everflow_display": "Kraliçe'nin sonsuz akışı: Yığın başına +%{0:0.#} yenilenme", + "mod_epicloot_me_everflow_desc": "Bir yaratığı öldürmek, sağlık, dayanıklılık ve eitr yenilenmeni yığın başına %{0:0.#} artıran, en fazla {1:0.#} yığına kadar birikebilen bir güçlendirme verir.", + "mod_epicloot_me_necroticfire_display": "Nekrotik ateş: +%{0:0.#} ateş ve zehir", + "mod_epicloot_me_necroticfire_desc": "Silahındaki tüm fiziksel hasarı alır ve bunun %{0:0.#} kadarını ateş hasarı, aynı miktarı da zehir hasarı olarak ekler.", "mod_epicloot_circlet": "taç", "mod_epicloot_belt": "kemer", "mod_epicloot_remains": "kalıntılar", diff --git a/EpicLoot/localizations/Ukrainian.json b/EpicLoot/localizations/Ukrainian.json index 889a4fc24..7253c0a94 100644 --- a/EpicLoot/localizations/Ukrainian.json +++ b/EpicLoot/localizations/Ukrainian.json @@ -560,20 +560,20 @@ "mod_epicloot_me_rollcleanse_desc": "Кожен перекид ухилення знімає {0} секунд із твоїх ефектів отрути та горіння.", "mod_epicloot_me_stormrider_display": "+{0} % до швидкості пересування в бурю", "mod_epicloot_me_stormrider_desc": "+{0} % до швидкості пересування, поки вирує буря.", - "mod_epicloot_me_shockingcharge_display": "Разючий заряд", - "mod_epicloot_me_shockingcharge_desc": "Твої удари в бою накопичують заряди. При повному заряді наступний удар випускає вперед ударну хвилю блискавок, що завдає частини шкоди твоїх попередніх ударів.", - "mod_epicloot_me_forestsaid_display": "Допомога лісу", - "mod_epicloot_me_forestsaid_desc": "Коли тебе б'ють, із землі виривається обплутуюче коріння й знерухомлює ворогів поблизу.", - "mod_epicloot_me_corpserot_display": "Трупна гниль", - "mod_epicloot_me_corpserot_desc": "Убиті тобою вороги можуть лопнути хмарою отрути, труячи супротивників поблизу.", - "mod_epicloot_me_icyretribution_display": "Крижана відплата", - "mod_epicloot_me_icyretribution_desc": "Коли тебе б'ють, навколо тебе вибухає крижана нова. Перезарядка залежить від рідкісності.", + "mod_epicloot_me_shockingcharge_display": "Разючий заряд: кожні {0:0.#} ударів", + "mod_epicloot_me_shockingcharge_desc": "Кожні {0:0.#} ударів у бою наступний удар випускає вперед ударну хвилю блискавок, що завдає {1:0.#}% шкоди цих ударів. Вища рідкість потребує менше ударів.", + "mod_epicloot_me_forestsaid_display": "Допомога лісу: {0:0.#} м, перезарядка {1:0.#} с", + "mod_epicloot_me_forestsaid_desc": "Коли тебе б'ють, із землі виривається обплутуюче коріння й знерухомлює ворогів у радіусі {0:0.#} метрів. Вища рідкість сягає далі й перезаряджається за {1:0.#} секунд.", + "mod_epicloot_me_corpserot_display": "Трупна гниль: {0:0.#} отрути", + "mod_epicloot_me_corpserot_desc": "Убиті тобою вороги можуть лопнути хмарою отрути, завдаючи супротивникам поблизу {0:0.#} шкоди отрутою.", + "mod_epicloot_me_icyretribution_display": "Крижана відплата: {0:0.#} холоду", + "mod_epicloot_me_icyretribution_desc": "Коли тебе б'ють, навколо тебе вибухає крижана нова, що завдає {0:0.#} шкоди холодом. Вища рідкість б'є сильніше й перезаряджається швидше.", "mod_epicloot_me_meteorsummoner_display": "Прикликач метеорів: {0}× шкоди удару", "mod_epicloot_me_meteorsummoner_desc": "Кожні {1} ударів зброєю твій наступний удар обрушує на ціль вогняний метеор, що завдає {0}× шкоди того удару у вигляді вогню.", - "mod_epicloot_me_everflow_display": "Вічний потік Королеви", - "mod_epicloot_me_everflow_desc": "Убивство істоти дає складаний ефект, що підвищує відновлення здоров'я, витривалості та ейтру.", - "mod_epicloot_me_necroticfire_display": "Некротичний вогонь", - "mod_epicloot_me_necroticfire_desc": "Знімає з твоєї зброї всю фізичну шкоду й додає цю повну величину і як шкоду вогнем, і як шкоду отрутою.", + "mod_epicloot_me_everflow_display": "Вічний потік Королеви: +{0:0.#}% відновлення за стак", + "mod_epicloot_me_everflow_desc": "Убивство істоти дає складаний ефект, що підвищує відновлення здоров'я, витривалості та ейтру на {0:0.#}% за стак, до {1:0.#} стаків.", + "mod_epicloot_me_necroticfire_display": "Некротичний вогонь: +{0:0.#}% вогню та отрути", + "mod_epicloot_me_necroticfire_desc": "Знімає з твоєї зброї всю фізичну шкоду й додає {0:0.#}% від неї як шкоду вогнем і стільки ж як шкоду отрутою.", "mod_epicloot_circlet": "обруч", "mod_epicloot_belt": "пояс", "mod_epicloot_remains": "залишки", diff --git a/EpicLoot/src/Adventure/PlayerExtensions_Adventure.cs b/EpicLoot/src/Adventure/PlayerExtensions_Adventure.cs index faf750040..3d717d811 100644 --- a/EpicLoot/src/Adventure/PlayerExtensions_Adventure.cs +++ b/EpicLoot/src/Adventure/PlayerExtensions_Adventure.cs @@ -29,11 +29,41 @@ public static AdventureSaveData GetAdventureSaveData(this Player player) return saveData; } - private static void SaveAdventureSaveData(this Player player) + public static void SaveAdventureSaveData(this Player player) { GetAdventureComponent(player).Save(); } + /// + /// Writes the local player's adventure state into Player.m_customData right now, instead of + /// waiting for the next Player.Save. + /// + /// Every adventure state change -- a bounty target slain, a reward claimed, a map bought -- + /// used to live only in the in-memory AdventureComponent until vanilla got around to saving + /// the profile, which is up to Game.m_saveInterval (20 minutes) later. For that whole window + /// m_customData described a past that no longer existed, and anything else reading it saw + /// that past: server-authoritative character mods (ValheimEnforcer) snapshot the dictionary + /// on their own schedule and replay their snapshot back onto the player on the next login, + /// which silently rolled completed bounties back to InProgress -- permanently, since their + /// targets are already dead. The kill ledger makes a loss worse still: the server deletes a + /// player's queued kill logs the moment it sends them, so a replayed offline kill that is + /// not written down is gone for good. + /// + /// Serializing the blob is a few kilobytes and these events are rare, so the cheap fix is to + /// keep m_customData continuously true rather than to guess who else is reading it. The + /// Player.Save prefix below stays as the backstop. + /// + public static void PersistLocalPlayerAdventureData() + { + var player = Player.m_localPlayer; + if (player == null) + { + return; + } + + player.SaveAdventureSaveData(); + } + /// /// Adds the adventure data to the player custom data before a player save. /// diff --git a/EpicLoot/src/Adventure/TreasureMapChest.cs b/EpicLoot/src/Adventure/TreasureMapChest.cs index d6fa5a542..b4fb67cb5 100755 --- a/EpicLoot/src/Adventure/TreasureMapChest.cs +++ b/EpicLoot/src/Adventure/TreasureMapChest.cs @@ -158,6 +158,7 @@ public static void Postfix(Container __instance, long uid, bool granted) EpicLoot.Log($"Player is opening treasure map chest ({treasureMapChest.Biome}, {treasureMapChest.Interval})!"); var saveData = player.GetAdventureSaveData(); saveData.FoundTreasureChest(treasureMapChest.Interval, treasureMapChest.Biome); + player.SaveAdventureSaveData(); zdo.Set("TreasureMapChest.HasBeenFound", true); diff --git a/EpicLoot/src/Adventure/feature/Bounties.cs b/EpicLoot/src/Adventure/feature/Bounties.cs index e5912f717..f6661d425 100755 --- a/EpicLoot/src/Adventure/feature/Bounties.cs +++ b/EpicLoot/src/Adventure/feature/Bounties.cs @@ -277,6 +277,7 @@ public IEnumerator AcceptBounty(Player player, BountyInfo bounty, Action diff --git a/EpicLoot/src/Adventure/feature/TreasureMaps.cs b/EpicLoot/src/Adventure/feature/TreasureMaps.cs index f4c00722d..2a7560fe6 100755 --- a/EpicLoot/src/Adventure/feature/TreasureMaps.cs +++ b/EpicLoot/src/Adventure/feature/TreasureMaps.cs @@ -108,6 +108,8 @@ private bool CreateTreasureSpawner(Heightmap.Biome biome, Vector3 spawnPoint, A return false; } + PlayerExtensions_Adventure.PersistLocalPlayerAdventureData(); + Quaternion rotation = Quaternion.Euler(0f, UnityEngine.Random.Range(0f, 360f), 0f); GameObject gameObject = PrefabManager.Instance.GetPrefab(AdventureSpawnController.PrefabName); GameObject created_go = Object.Instantiate(gameObject, spawnPoint, rotation); diff --git a/EpicLoot/src/GamePatches/ItemDrop_Patch.cs b/EpicLoot/src/GamePatches/ItemDrop_Patch.cs index 8f319d1ed..6c8ef38ac 100644 --- a/EpicLoot/src/GamePatches/ItemDrop_Patch.cs +++ b/EpicLoot/src/GamePatches/ItemDrop_Patch.cs @@ -59,6 +59,33 @@ public static void Postfix(ItemDrop __instance) } } + // ArmorStand cannot use LoadFromExternalZDO: it stores one item per slot, so it needs the indexed + // overload and calls the static LoadFromZDO directly, which means the patch above never fires for + // it. The breakage is the same one -- the stand re-instantiates the item from its prefab, so + // Awake/InitializeCustomData caches an empty MagicItemComponent, and only afterwards does + // LoadFromZDO swap in the saved m_customData. ItemInfo.Get returns that cached component ahead of + // re-reading m_customData, so the enchantment data sits on the item while its MagicItem stays null + // and the item reads as mundane -- until something rebuilds the ItemData (dropping it again clones + // it, which is why that "fixes" it). + // Only the indexed form needs this; index < 0 is ItemDrop.Load and LoadFromExternalZDO, both covered. + [HarmonyPatch(typeof(ItemDrop), nameof(ItemDrop.LoadFromZDO))] + public static class ItemDrop_LoadFromZDO_Patch + { + public static void Postfix(ItemDrop.ItemData itemData, int index) + { + if (index < 0 || itemData?.m_shared == null) + { + return; + } + + itemData.Data().Get()?.Load(); + + // LoadFromZDO clears m_customData before repopulating it, so a shard taken off a stand can + // land here with its magic data gone. Its identity is in m_shared, so rebuild from that. + Shards.EnsureShardMetadata(itemData); + } + } + [HarmonyPatch(typeof(Inventory), nameof(Inventory.Load), typeof(ZPackage))] public static class Inventory_Load_Patch { diff --git a/EpicLoot/src/GamePatches/TraderDiagnostics.cs b/EpicLoot/src/GamePatches/TraderDiagnostics.cs index 052d0ddd6..4c2f239cd 100644 --- a/EpicLoot/src/GamePatches/TraderDiagnostics.cs +++ b/EpicLoot/src/GamePatches/TraderDiagnostics.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using EpicLoot.Adventure; @@ -61,6 +61,30 @@ private class PressRecord /// Frame the store window was last actually opened, for the closed-too-soon check. private static int _lastOpenedFrame = int.MinValue; + /// + /// The three inputs StoreGui.Update closes on, as they stood at the top of Update. Reading + /// them from the Hide prefix instead is quietly wrong: vanilla calls + /// ResetButtonStatus("JoyButtonB") on the line directly above its own Hide() call, so a + /// gamepad close always reported joyB=False and looked like it had been something else. + /// + private static int _closeInputFrame = int.MinValue; + private static bool _closeInputUse; + private static bool _closeInputEsc; + private static bool _closeInputJoyB; + + /// + /// Called from the StoreGui.Update prefix in , + /// which only gets this far while the window is open -- so this costs nothing on a frame + /// where there is no store to close. + /// + internal static void NoteUpdateCloseInputs(bool use, bool esc, bool joyB) + { + _closeInputFrame = Time.frameCount; + _closeInputUse = use; + _closeInputEsc = esc; + _closeInputJoyB = joyB; + } + internal static bool Recording => _press != null; internal static void BeginPress(GameObject hovered, bool hold) @@ -269,12 +293,26 @@ internal static void NoteHide(StoreGui storeGui) ? Vector3.Distance(storeGui.m_trader.transform.position, player.transform.position) : -1f; + // Hide is also called from outside Update, where there is no snapshot to use. Say so + // rather than printing post-reset reads as if they were what Update acted on. + var fromUpdate = _closeInputFrame == Time.frameCount; + var use = fromUpdate ? _closeInputUse : ZInput.GetButtonDown("Use"); + var esc = fromUpdate ? _closeInputEsc : ZInput.GetKeyDown(KeyCode.Escape); + var joyB = fromUpdate ? _closeInputJoyB : ZInput.GetButtonDown("JoyButtonB"); + + // Update's condition needs the viewer to exist *and* be hidden, so "is it null" on its + // own never said which of the two we were looking at. + var textViewer = TextViewer.instance == null ? "none" + : TextViewer.instance.IsVisible() ? "visible" + : "hidden"; + EpicLoot.LogWarningForce( $"[Trader] The store window closed {sinceOpen} frame(s) after it opened. " + $"traderNull={storeGui.m_trader == null} distance={distance:F1}/{storeGui.m_hideDistance} " + $"invGui={InventoryGui.IsVisible()} map={Minimap.IsOpen()} " + - $"use={ZInput.GetButtonDown("Use")} esc={ZInput.GetKeyDown(KeyCode.Escape)} " + - $"joyB={ZInput.GetButtonDown("JoyButtonB")} textViewer={(TextViewer.instance != null)}\n" + + $"use={use} esc={esc} joyB={joyB}" + + $"{(fromUpdate ? "" : " (read at Hide, not at the top of Update)")} " + + $"textViewer={textViewer}\n" + new StackTrace(1, false)); } diff --git a/EpicLoot/src/GamePatches/TraderStoreHardening.cs b/EpicLoot/src/GamePatches/TraderStoreHardening.cs index 6e78a7d17..c0b9a5906 100644 --- a/EpicLoot/src/GamePatches/TraderStoreHardening.cs +++ b/EpicLoot/src/GamePatches/TraderStoreHardening.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using HarmonyLib; +using UnityEngine; namespace EpicLoot { @@ -132,4 +133,129 @@ public static void Prefix(StoreGui __instance, Trader __0) TraderDiagnostics.NoteRepaired(); } } + + /// + /// Vanilla StoreGui.Update closes the window on `ZInput.GetButtonDown("Use")` -- the very press + /// that just opened it. Nothing consumes that press: GetButtonDown is a frame-latched flag that + /// any number of callers read, and Player.Interact leaves it set. So whether the store survives + /// its own opening frame comes down to which of Player.Update and StoreGui.Update Unity happens + /// to run first, and neither script declares an execution order: + /// + /// StoreGui.Update first -- the panel is still inactive, Update returns at the top. Fine, and + /// this is the order the game normally ends up in. + /// Player.Update first -- Show activates the panel, then Update sees it active with the press + /// still latched and hides it again in the same frame. + /// + /// The second order draws nothing at all (activated and deactivated between two renders) and + /// Hide() logs nothing, so it reaches us as "Use on the trader does nothing, and there is no + /// error in the log". Relogging rebuilds both objects and can put the order back; a zone reload + /// cannot, which is exactly the shape of the reports. + /// + /// Vanilla already knows about this hazard: InventoryGui guards the identical branch with + /// `(m_shownFrames > 1) & flag2`, refusing to close on the frame it opened. StoreGui has no + /// m_shownFrames at all. This gives it that guard the way InventoryGui itself does it, with + /// ZInput.ResetButtonStatus. Player.Interact has already consumed the press to open the store by + /// the time this runs, so nothing later in the frame is left owing it. + /// + /// On a client that wins the ordering race this is inert: Show has not run yet when the prefix + /// does, so the opening frame is never the current one. + /// + [HarmonyPatch(typeof(StoreGui))] + public static class StoreGui_KeepOpenOnItsOpeningFrame + { + /// Frame the window last went from closed to open. Never Time.frameCount otherwise. + private static int _openedFrame = int.MinValue; + + /// Was the window already open when the Show now running started? + private static bool _wasOpenBeforeShow; + + /// One line per session: the Update order does not change once it has been seen. + private static bool _reported; + + /// + /// Update is private, which is the kind of member a game update quietly renames. Gating the + /// whole class on it keeps a miss from throwing out of CreateAndPatchAll, and leaves the Show + /// bookkeeping off too -- it is of no use on its own. + /// + public static bool Prepare() + { + return AccessTools.Method(typeof(StoreGui), nameof(StoreGui.Update)) != null; + } + + [HarmonyPatch(nameof(StoreGui.Show))] + [HarmonyPrefix] + [HarmonyPriority(Priority.First)] + public static void Show_Prefix(StoreGui __instance) + { + _wasOpenBeforeShow = IsOpen(__instance); + } + + /// + /// A finalizer rather than a postfix, for the same reason the adventure panel uses one: + /// FillList can throw after Show has already activated the window, and a half-filled window + /// still on screen is the one worth protecting -- letting the same press close it again is + /// how that throw went unreported in the first place. + /// + /// Only the closed-to-open transition counts. Show also runs its body for a press aimed at a + /// different trader while the window is up, and pressing Use again on the same trader is the + /// player asking to close it; recording either of those would swallow a close the player + /// meant. + /// + [HarmonyPatch(nameof(StoreGui.Show))] + [HarmonyFinalizer] + [HarmonyPriority(Priority.Last)] + public static void Show_Finalizer(StoreGui __instance) + { + if (!_wasOpenBeforeShow && IsOpen(__instance)) + { + _openedFrame = Time.frameCount; + } + } + + [HarmonyPatch(nameof(StoreGui.Update))] + [HarmonyPrefix] + [HarmonyPriority(Priority.First)] + public static void Update_Prefix(StoreGui __instance) + { + // Vanilla's own first act is to return here, and every Hide it can reach is below that + // line, so a closed store costs one bool per frame and nothing else. + if (!IsOpen(__instance)) + { + return; + } + + var use = ZInput.GetButtonDown("Use"); + + // Read here, before vanilla's ResetButtonStatus("JoyButtonB") on the line above its own + // Hide() call makes the gamepad close unreadable from the Hide prefix. + TraderDiagnostics.NoteUpdateCloseInputs(use, ZInput.GetKeyDown(KeyCode.Escape), + ZInput.GetButtonDown("JoyButtonB")); + + if (!use || Time.frameCount != _openedFrame) + { + return; + } + + ZInput.ResetButtonStatus("Use"); + + if (_reported) + { + return; + } + + _reported = true; + EpicLoot.LogWarningForce( + "[Trader] This client runs Player.Update before StoreGui.Update, so the Use press that " + + "opens the store is still latched when StoreGui.Update reads it, and vanilla would have " + + "closed the window in the same frame it opened -- with nothing drawn and nothing logged. " + + "Swallowing that one press so the window stays open, which is the guard InventoryGui " + + "already has and StoreGui does not. If the trader looked like it was ignoring you, this " + + "was why."); + } + + private static bool IsOpen(StoreGui storeGui) + { + return storeGui != null && storeGui.m_rootPanel != null && storeGui.m_rootPanel.activeSelf; + } + } } diff --git a/EpicLoot/src/Loot/LootRoller.cs b/EpicLoot/src/Loot/LootRoller.cs index 8c0f5bea0..54c900929 100644 --- a/EpicLoot/src/Loot/LootRoller.cs +++ b/EpicLoot/src/Loot/LootRoller.cs @@ -1783,7 +1783,7 @@ public static List RollAugmentEffects(ItemDrop.ItemData item, M var currentEffect = magicItem.Effects[effectIndex]; - var valuelessEffect = MagicItemEffectDefinitions.IsValuelessEffect(currentEffect.EffectType, rarity); + var valuelessEffect = MagicItemEffectDefinitions.IsValuelessEffect(currentEffect.EffectType); var availableEffects = MagicItemEffectDefinitions.GetAvailableEffects(item, magicItem, valuelessEffect ? -1 : effectIndex); @@ -1822,7 +1822,7 @@ public static List RollAugmentEffects(ItemDrop.ItemData item, M results.Add(newEffect); currentEffectTypes.Add(newEffect.EffectType); - var newEffectIsValueless = MagicItemEffectDefinitions.IsValuelessEffect(newEffect.EffectType, rarity); + var newEffectIsValueless = MagicItemEffectDefinitions.IsValuelessEffect(newEffect.EffectType); if (newEffectIsValueless) { availableEffects.RemoveAll(x => x.Type == newEffect.EffectType); diff --git a/EpicLoot/src/Magic/MagicItemComponent.cs b/EpicLoot/src/Magic/MagicItemComponent.cs index f6103bebd..439cb6499 100755 --- a/EpicLoot/src/Magic/MagicItemComponent.cs +++ b/EpicLoot/src/Magic/MagicItemComponent.cs @@ -2,6 +2,7 @@ using EpicLoot.MagicItemEffects; using Newtonsoft.Json; using System; +using System.Collections.Generic; using UnityEngine; namespace EpicLoot; @@ -157,6 +158,15 @@ public override void Unload() Indestructible.Revert(Item); } + // A binary effect (Indestructible, Weightless, Throwable...) must sit at the default value of 1: + // older saves and hand-edited data can carry a stale number that would otherwise show up in the + // tooltip. "Binary" is deliberately judged across every rarity rather than at this item's own + // rarity -- Load() writes the result straight back to m_customData, so a per-rarity test would + // permanently flatten a legitimately rolled value whenever the loaded definitions simply lack a + // block for that rarity. That happens for real: a dedicated server replaces the client's + // definitions wholesale on connect (ELConfig.ApplyClientConfig), so a server config that predates + // a newly added rarity would otherwise reset every effect on every item of that rarity to 1 -- + // once, silently, and irreversibly. Report the gap instead, and leave the value alone. private void FixupValuelessEffects() { if (MagicItem == null) @@ -166,13 +176,44 @@ private void FixupValuelessEffects() foreach (MagicItemEffect effect in MagicItem.Effects) { - if (MagicItemEffectDefinitions.IsValuelessEffect(effect.EffectType, MagicItem.Rarity) && - !Mathf.Approximately(effect.EffectValue, 1)) + if (MagicItemEffectDefinitions.IsValuelessEffect(effect.EffectType)) { - EpicLoot.Log($"Fixing up effect on {MagicItem.DisplayName}: effect={effect.EffectType}"); - effect.EffectValue = 1; + if (!Mathf.Approximately(effect.EffectValue, 1)) + { + EpicLoot.Log($"Fixing up effect on {MagicItem.DisplayName}: effect={effect.EffectType}"); + effect.EffectValue = 1; + } + + continue; } + + WarnOnceAboutMissingRarityValues(effect.EffectType, MagicItem.Rarity); + } + } + + // Types already reported by WarnOnceAboutMissingRarityValues. Every item entering the world passes + // through Load(), so an unguarded warning would repeat per item per load; the interesting fact is + // which effect/rarity pairs have no values, and that is a property of the config, not of the item. + private static readonly HashSet ReportedRarityGaps = new HashSet(); + + private static void WarnOnceAboutMissingRarityValues(string effectType, ItemRarity rarity) + { + var effectDef = MagicItemEffectDefinitions.Get(effectType); + if (effectDef?.ValuesPerRarity?.GetValueDefForRarity(rarity) != null) + { + return; } + + if (!ReportedRarityGaps.Add($"{effectType}/{rarity}")) + { + return; + } + + // Forced: this is a silent data problem otherwise -- the effect renders with a blank value + // everywhere (tooltip, enchant preview) and cannot be rolled at this rarity. + EpicLoot.LogWarningForce($"Enchantment '{effectType}' has no values for rarity {rarity} in the " + + "loaded magiceffects.json. Items carrying it at that rarity will show a blank value. If you " + + "are on a dedicated server, the server's copy of magiceffects.json is the one in use."); } } diff --git a/EpicLoot/src/Magic/MagicItemEffectDefinition.cs b/EpicLoot/src/Magic/MagicItemEffectDefinition.cs index c510af173..83f293e18 100755 --- a/EpicLoot/src/Magic/MagicItemEffectDefinition.cs +++ b/EpicLoot/src/Magic/MagicItemEffectDefinition.cs @@ -662,16 +662,34 @@ public static List GetAvailableEffects( return results; } - public static bool IsValuelessEffect(string effectType, ItemRarity rarity) + /// + /// True only for an effect that carries no value at any rarity -- the binary grants + /// (Indestructible, Weightless, Warmth, Throwable...) whose magiceffects.json entry has no + /// ValuesPerRarity block at all. + /// + /// Deliberately not "has no value at rarity X": an effect with values at some rarities but not + /// that one is a config gap, not a binary effect, and treating the two the same is how a + /// legitimately rolled value gets destroyed (see MagicItemComponent.FixupValuelessEffects). + /// Callers that really do want the per-rarity question ask GetValuesForRarity directly. + /// + public static bool IsValuelessEffect(string effectType) { var effectDef = Get(effectType); - if (effectDef == null) + if (effectDef?.ValuesPerRarity == null) { - EpicLoot.LogWarning($"Checking if unknown effect is valuless ({effectType}/{rarity})"); + EpicLoot.LogWarning($"Checking if unknown effect is valueless ({effectType})"); return false; } - return effectDef.GetValuesForRarity(rarity) == null; + foreach (var rarity in Rarities.All) + { + if (effectDef.ValuesPerRarity.GetValueDefForRarity(rarity) != null) + { + return false; + } + } + + return true; } } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/BonemassCorpseRot.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/BonemassCorpseRot.cs index e8a741248..bee0f0e22 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/BonemassCorpseRot.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/BonemassCorpseRot.cs @@ -24,6 +24,16 @@ public static class BonemassCorpseRot { { PoisonPerTierKey, DefaultPoisonPerTier }, }; + // Tooltip: "Corpse Rot: {0} Poison" -- the same value * PoisonPerTier the burst deals, so the + // shown number rises with the shard's rarity instead of the effect reading as a flat name. + public static void RegisterDisplayValues() { + MagicItem.RegisterDisplayValues(MagicEffectType.CorpseRot, + value => new object[] { + value * EffectConfig.Get(MagicEffectType.CorpseRot, + 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() { diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/EikthyrShockingCharge.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/EikthyrShockingCharge.cs index 15eb6c430..15af3a03f 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/EikthyrShockingCharge.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/EikthyrShockingCharge.cs @@ -7,18 +7,26 @@ namespace EpicLoot.MagicItemEffects.Shards { public static class EikthyrShockingCharge { // 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; + // Hits required to trigger a discharge: MaxCharges is the threshold at a shard value of zero, + // ChargesPerValue is how many hits each point of value shaves off it, and MinCharges is the floor + // it may never drop below. This is what the shard's rarity buys -- at the shipped ramp a Rare + // Eikthyr shard discharges every 15 hits and an Ancient one every 9. + // + // DamageFraction is 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 = 18; + public const float DefaultChargesPerValue = 0.3f; + public const int DefaultMinCharges = 6; 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 ChargesPerValueKey = "ChargesPerValue"; + private const string MinChargesKey = "MinCharges"; private const string DamageFractionKey = "DamageFraction"; private const string ConeLengthKey = "ConeLength"; private const string ConeMaxWidthKey = "ConeMaxWidth"; @@ -26,16 +34,37 @@ public static class EikthyrShockingCharge { public static readonly Dictionary DefaultConfig = new Dictionary { { MaxChargesKey, DefaultMaxCharges }, + { ChargesPerValueKey, DefaultChargesPerValue }, + { MinChargesKey, DefaultMinCharges }, { 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, + // Hits needed to discharge at the given shard value. Rounded rather than truncated so a fractional + // rate lands on the nearer threshold, then clamped at both ends: never above the configured ceiling + // (so a negative rate cannot make a shard worse than no shard) and never below MinCharges or 1, so + // a misconfiguration can only weaken the effect rather than turn every hit into a discharge. + private static int GetChargesNeeded(float value) { + var ceiling = EffectConfig.GetIntAtLeast(MagicEffectType.ShockingCharge, MaxChargesKey, DefaultMaxCharges, 1); + var perValue = EffectConfig.Get(MagicEffectType.ShockingCharge, + ChargesPerValueKey, DefaultChargesPerValue); + var floor = Mathf.Clamp(EffectConfig.GetIntAtLeast(MagicEffectType.ShockingCharge, + MinChargesKey, DefaultMinCharges, 1), 1, ceiling); + return Mathf.Clamp(Mathf.RoundToInt(ceiling - value * perValue), floor, ceiling); + } + + // Tooltip: "Shocking Charge: Every {0} Hits", with {1} the discharge's share of the banked damage. + // Both are derived from the same helpers the effect runs on, so a retune moves the shown text too. + public static void RegisterDisplayValues() { + MagicItem.RegisterDisplayValues(MagicEffectType.ShockingCharge, + value => new object[] { + (float)GetChargesNeeded(value), + EffectConfig.Get(MagicEffectType.ShockingCharge, + DamageFractionKey, DefaultDamageFraction) * 100f, + }); } // Floored above zero: the cone test divides by the reach. @@ -58,7 +87,11 @@ private static float GetConeLength() { 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 => GetMaxCharges(); + // The HUD indicator's denominator: the threshold for the value the player is actually carrying, + // not the configured ceiling, so the counter matches what it takes to fire. + public static int MaxChargeCount => GetChargesNeeded(Player.m_localPlayer != null + ? Player.m_localPlayer.GetTotalActiveMagicEffectValue(MagicEffectType.ShockingCharge, 1f) + : 0f); // Postfix handler invoked by CharacterDamageDispatch (on-hit reaction). public static void OnDamageDealt(Character __instance, HitData hit, Character attacker) { @@ -91,7 +124,7 @@ public static void OnDamageDealt(Character __instance, HitData hit, Character at _bankedDamage += contributed; - if (++_charges < GetMaxCharges()) { + if (++_charges < GetChargesNeeded(value)) { ShowIndicator(player); return; } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/ElderForestsAid.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/ElderForestsAid.cs index 439f1cd24..7bb1d7b17 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/ElderForestsAid.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/ElderForestsAid.cs @@ -7,26 +7,53 @@ 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 { // 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; + // RadiusPerTier per point, cooldown is Cooldown MINUS CooldownPerValue per point, floored at + // MinCooldown -- so a stronger shard reaches further AND fires more often. At the shipped ramp + // that is 7.5m / 45s at Rare through 10.5m / 15s at Ancient. All tunable in this effect's Config + // block in config/shardstones.json, under these key names. + public const float DefaultCooldown = 60f; public const float DefaultCooldownPerValue = 1.5f; + public const float DefaultMinCooldown = 10f; public const float DefaultBaseRadius = 6f; public const float DefaultRadiusPerTier = 0.15f; private const string CooldownKey = "Cooldown"; private const string CooldownPerValueKey = "CooldownPerValue"; + private const string MinCooldownKey = "MinCooldown"; private const string BaseRadiusKey = "BaseRadius"; private const string RadiusPerTierKey = "RadiusPerTier"; public static readonly Dictionary DefaultConfig = new Dictionary { { CooldownKey, DefaultCooldown }, { CooldownPerValueKey, DefaultCooldownPerValue }, + { MinCooldownKey, DefaultMinCooldown }, { BaseRadiusKey, DefaultBaseRadius }, { RadiusPerTierKey, DefaultRadiusPerTier }, }; + private static float GetRadius(float value) { + return EffectConfig.Get(MagicEffectType.ForestsAid, BaseRadiusKey, DefaultBaseRadius) + + value * EffectConfig.Get(MagicEffectType.ForestsAid, RadiusPerTierKey, DefaultRadiusPerTier); + } + + // Shrinks as the shard value grows, never past MinCooldown. Floored just above zero on top of + // that, because a ttl of 0 is "no timeout" to vanilla, which would gate the shard permanently + // rather than removing the cooldown. + private static float GetCooldown(float value) { + var cooldown = EffectConfig.Get(MagicEffectType.ForestsAid, CooldownKey, DefaultCooldown) + - value * EffectConfig.Get(MagicEffectType.ForestsAid, + CooldownPerValueKey, DefaultCooldownPerValue); + var floor = EffectConfig.Get(MagicEffectType.ForestsAid, MinCooldownKey, DefaultMinCooldown); + return Mathf.Max(0.1f, Mathf.Max(floor, cooldown)); + } + + // Tooltip: "Forest's Aid: {0}m, {1}s Cooldown" -- both read through the same helpers the effect + // runs on, so the shown numbers follow a retune and visibly improve with the shard's rarity. + public static void RegisterDisplayValues() { + MagicItem.RegisterDisplayValues(MagicEffectType.ForestsAid, + value => new object[] { GetRadius(value), GetCooldown(value) }); + } + private const string ImmobilizeSE = "ImmobilizedAshlands"; private const string HitFxPrefab = "fx_natureweapon_hit"; private static readonly int ImmobilizeHash = ImmobilizeSE.GetStableHashCode(); @@ -55,17 +82,8 @@ public static void OnDamageTaken(Character __instance, HitData hit) { return; } - 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); + Immobilize(player, GetRadius(value)); + ShowCooldown(player, GetCooldown(value)); } private static void Immobilize(Player player, float radius) { diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/FaderNecroticFire.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/FaderNecroticFire.cs index 2e97e1eb4..200cbb783 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/FaderNecroticFire.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/FaderNecroticFire.cs @@ -3,6 +3,13 @@ namespace EpicLoot.MagicItemEffects.Shards { // Provides a conversion of physical damage to fire and poison damage based on the player's Necrotic Fire effect value. public static class NecroticFire { + // Tooltip: "Necrotic Fire: +{0}% Fire and Poison". The shard value is a fraction of the stripped + // physical pool paid out as EACH element, so it reads as a percentage rather than a bare 0.2. + public static void RegisterDisplayValues() { + MagicItem.RegisterDisplayValues(MagicEffectType.NecroticFire, + value => new object[] { value * 100f }); + } + // GetDamage postfix handler invoked by ModifyDamage (per-weapon modifier). public static void ModifyWeaponDamage(ItemDrop.ItemData __instance, ref HitData.DamageTypes __result) { // Only when the local player has this weapon equipped (also gates the weapon tooltip). diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/ModerIcyRetribution.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/ModerIcyRetribution.cs index 591ff1ece..2b8b7f07f 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/ModerIcyRetribution.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/ModerIcyRetribution.cs @@ -7,22 +7,36 @@ namespace EpicLoot.MagicItemEffects.Shards { public static class ModerIcyRetribution { // 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 + public const float DefaultFrostPerTier = 8f; // frost damage per point of value (15..30 -> 120..240) + public const float DefaultBaseCooldown = 200f; // cooldown at Epic (the shard's rarity floor) + public const float DefaultCooldownPerRarity = 20f; // removed per rarity above Epic + public const float DefaultMinCooldown = 30f; // floor, so a retune cannot make it near-instant private const string RadiusKey = "Radius"; private const string FrostPerTierKey = "FrostPerTier"; private const string BaseCooldownKey = "BaseCooldown"; private const string CooldownPerRarityKey = "CooldownPerRarity"; + private const string MinCooldownKey = "MinCooldown"; public static readonly Dictionary DefaultConfig = new Dictionary { { RadiusKey, DefaultRadius }, { FrostPerTierKey, DefaultFrostPerTier }, { BaseCooldownKey, DefaultBaseCooldown }, { CooldownPerRarityKey, DefaultCooldownPerRarity }, + { MinCooldownKey, DefaultMinCooldown }, }; + // Tooltip: "Icy Retribution: {0} Frost". The cooldown is keyed off the shard's rarity rather than + // its value (see GetCooldown), and a provider only receives the value -- summing two socketed + // shards would misreport it -- so the cooldown stays in the Shift-detail config lines instead. + public static void RegisterDisplayValues() { + MagicItem.RegisterDisplayValues(MagicEffectType.IcyRetribution, + value => new object[] { + value * EffectConfig.Get(MagicEffectType.IcyRetribution, + FrostPerTierKey, DefaultFrostPerTier), + }); + } + // 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. private const string NovaTemplateName = "EL_ModerIcyRetributionNova"; @@ -64,16 +78,18 @@ public static void OnDamageTaken(Character __instance, HitData hit) { ShowCooldown(player, GetCooldown(player)); } - // 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. + // Cooldown length shortens with the highest rarity among the equipped IcyRetribution shards: by + // default 200s at Epic, -20s for each rarity above it (Legendary 180s, Mythic 160s, Ancient 140s), + // never past MinCooldown. Floored just above zero on top of that: 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); var baseCooldown = EffectConfig.Get(MagicEffectType.IcyRetribution, BaseCooldownKey, DefaultBaseCooldown); var perRarity = EffectConfig.Get(MagicEffectType.IcyRetribution, CooldownPerRarityKey, DefaultCooldownPerRarity); - return Mathf.Max(0.1f, baseCooldown + stepsAboveEpic * perRarity); + var floor = EffectConfig.Get(MagicEffectType.IcyRetribution, MinCooldownKey, DefaultMinCooldown); + return Mathf.Max(0.1f, Mathf.Max(floor, baseCooldown - stepsAboveEpic * perRarity)); } // Highest source rarity among the socketed IcyRetribution effects on the player's equipped magic diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/QueenEverflow.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/QueenEverflow.cs index ba450bb51..83ca387fe 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/QueenEverflow.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/QueenEverflow.cs @@ -23,6 +23,13 @@ public static class QueenEverflow { { BuffDurationKey, DefaultBuffDuration }, }; + // Tooltip: "Queen's Everflow: +{0}% Regen per Stack (max {1})". The shard value IS the per-stack + // percentage, so {0} is the raw value; {1} comes from config and stays put as the rarity climbs. + public static void RegisterDisplayValues() { + MagicItem.RegisterDisplayValues(MagicEffectType.Everflow, + value => new object[] { value, (float)GetMaxStacks() }); + } + private const string BuffName = "EL_QueenEverflow"; private static readonly int BuffHash = BuffName.GetStableHashCode(); private static SE_QueenEverflow _buffPrototype; diff --git a/EpicLoot/src/ShardStones/ShardSocketManager.cs b/EpicLoot/src/ShardStones/ShardSocketManager.cs index ddb0195df..35ff80521 100644 --- a/EpicLoot/src/ShardStones/ShardSocketManager.cs +++ b/EpicLoot/src/ShardStones/ShardSocketManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using EpicLoot.Config; using EpicLoot.Crafting; @@ -117,7 +117,7 @@ public static bool CanSocket(ItemDrop.ItemData equipment, ItemDrop.ItemData inpu occupants.Add(new SocketOccupant(socket.ShardType, socket.Effect)); } } - if (!CheckDuplicateEffect(color, effect, rarity, occupants, out reason)) { + if (!CheckDuplicateEffect(color, effect, occupants, out reason)) { return false; } @@ -184,7 +184,7 @@ public static bool CanCoexist(ItemDrop.ItemData equipment, ItemDrop.ItemData inp occupants.Add(new SocketOccupant(otherColor, otherEffect)); } } - if (!CheckDuplicateEffect(color, effect, rarity, occupants, out reason)) { + if (!CheckDuplicateEffect(color, effect, occupants, out reason)) { return false; } @@ -208,7 +208,7 @@ public SocketOccupant(ShardType color, MagicItemEffect effect) { // rune vs rune, rune vs shard, two colors landing on one effect -- stays under // AllowDuplicateSocketedEffects, so nothing about those changes when stacking is off. private static bool CheckDuplicateEffect(ShardType inputColor, MagicItemEffect effect, - ItemRarity inputRarity, List occupants, out string reason) { + List occupants, out string reason) { reason = null; foreach (var occupant in occupants) { @@ -233,7 +233,7 @@ private static bool CheckDuplicateEffect(ShardType inputColor, MagicItemEffect e // An effect with no rarity-scaled value (Warmth, say) is a yes/no grant: a second one // adds nothing whether it is decayed or not, so stacking it would only cost the player // a socket. Same notion of "valueless" the BreakValueless removal mode uses. - if (MagicItemEffectDefinitions.IsValuelessEffect(effect.EffectType, inputRarity)) { + if (MagicItemEffectDefinitions.IsValuelessEffect(effect.EffectType)) { reason = "$mod_epicloot_socket_nostackbinary"; return false; } @@ -356,9 +356,8 @@ private static List> GroupSocketsByColor(MagicItem magicItem, float[] // How the given socketed entry is allowed to leave its socket. Derived live from config and the // socket's own data -- nothing is persisted, so a config change applies to every existing item - // immediately. `sourceRarity` is the shard/runestone's own rarity, the same key the shard grid - // is indexed by in ResolveSocketedEffect. - public static SocketRemoval GetRemovalPolicy(ShardType color, MagicItemEffect effect, ItemRarity sourceRarity) { + // immediately. + public static SocketRemoval GetRemovalPolicy(ShardType color, MagicItemEffect effect) { if (color == ShardType.None) { switch (ELConfig.RuneSocketRemovalMode.Value) { case RuneSocketMode.Break: @@ -372,7 +371,7 @@ public static SocketRemoval GetRemovalPolicy(ShardType color, MagicItemEffect ef switch (ELConfig.ShardSocketRemovalMode.Value) { case ShardSocketMode.BreakValueless: - return IsValuelessGrant(effect, sourceRarity) ? SocketRemoval.BreakOnly : SocketRemoval.Free; + return IsValuelessGrant(effect) ? SocketRemoval.BreakOnly : SocketRemoval.Free; case ShardSocketMode.BreakAll: return SocketRemoval.BreakOnly; case ShardSocketMode.Permanent: @@ -386,14 +385,14 @@ public static SocketRemoval GetRemovalPolicy(ShardType color, MagicItemEffect ef public static SocketRemoval GetRemovalPolicy(SocketedEffect socket) { return socket == null ? SocketRemoval.Free - : GetRemovalPolicy(socket.ShardType, socket.Effect, socket.SourceRarity); + : GetRemovalPolicy(socket.ShardType, socket.Effect); } // Policy for a socketable item sitting in the socket grid (UI). Anything that isn't a valid // socketable has no policy to enforce. public static SocketRemoval GetRemovalPolicy(ItemDrop.ItemData equipment, ItemDrop.ItemData socketed) { return ResolveSocketedEffect(equipment, socketed, out var effect, out var color, out var rarity) - ? GetRemovalPolicy(color, effect, rarity) + ? GetRemovalPolicy(color, effect) : SocketRemoval.Free; } @@ -402,9 +401,8 @@ public static SocketRemoval GetRemovalPolicy(ItemDrop.ItemData equipment, ItemDr // per-rarity number (that number is meaningless for a binary effect). A shard that grants // nothing at all in a slot is deliberately NOT valueless in this sense: the player gained // nothing from it, so it owes no commitment and stays freely removable. - private static bool IsValuelessGrant(MagicItemEffect effect, ItemRarity sourceRarity) { - return effect != null && - MagicItemEffectDefinitions.IsValuelessEffect(effect.EffectType, sourceRarity); + private static bool IsValuelessGrant(MagicItemEffect effect) { + return effect != null && MagicItemEffectDefinitions.IsValuelessEffect(effect.EffectType); } // The player-facing reason a socketed item may not simply be dragged out. diff --git a/EpicLoot/src/Tooltip/MagicTooltipShard.cs b/EpicLoot/src/Tooltip/MagicTooltipShard.cs index cd9d497ac..2512e64af 100644 --- a/EpicLoot/src/Tooltip/MagicTooltipShard.cs +++ b/EpicLoot/src/Tooltip/MagicTooltipShard.cs @@ -1,4 +1,4 @@ -using EpicLoot.ShardStones; +using EpicLoot.ShardStones; namespace EpicLoot; @@ -23,7 +23,7 @@ private void AddShardPreview() // socket for good, so the commitment has to be stated here too. if (color != ShardType.None) { - AppendBlanketRemovalWarning(ShardSocketManager.GetRemovalPolicy(color, null, rarity)); + AppendBlanketRemovalWarning(ShardSocketManager.GetRemovalPolicy(color, null)); } return; } @@ -34,7 +34,7 @@ private void AddShardPreview() // ends up granting -- i.e. the BreakAll/Permanent modes. When one of those is on, say so once // up front instead of repeating a marker on every line, and note that it covers the slots not // even listed below (those that grant nothing). - var blanket = ShardSocketManager.GetRemovalPolicy(color, null, rarity); + var blanket = ShardSocketManager.GetRemovalPolicy(color, null); // A uniform shard (e.g. a boss shard) grants one effect on every slot it is allowed into. if (def.UniformEffect != null) @@ -95,7 +95,7 @@ private static string PreviewRemovalTag(SocketRemoval blanket, ShardType color, return ""; } - var policy = ShardSocketManager.GetRemovalPolicy(color, new MagicItemEffect(effectType, value), rarity); + var policy = ShardSocketManager.GetRemovalPolicy(color, new MagicItemEffect(effectType, value)); switch (policy) { case SocketRemoval.BreakOnly: From f51a4e65714de6a17bf1e0306ed006e04a9a9cda Mon Sep 17 00:00:00 2001 From: MidnightsFX Date: Fri, 18 Sep 2026 16:16:20 -0700 Subject: [PATCH 13/20] bump version --- EpicLoot/Package/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpicLoot/Package/manifest.json b/EpicLoot/Package/manifest.json index 31827b398..88a56001a 100644 --- a/EpicLoot/Package/manifest.json +++ b/EpicLoot/Package/manifest.json @@ -1,6 +1,6 @@ { "name": "EpicLoot", - "version_number": "0.14.9", + "version_number": "0.14.10", "website_url": "https://discord.gg/ZNhYeavv3C", "description": "Adds loot drops, magic items, and enchanting to Valheim.", "dependencies": [ From d3f82da76fd1fae41c4e104fa10fe26a2675e47a Mon Sep 17 00:00:00 2001 From: ksavila <84932581+ksavila@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:41:39 -0400 Subject: [PATCH 14/20] Gamepad fixes for the enchanting table and merchant panel (#30) * Gamepad X presses the enchanting action button * Fix gamepad navigation in enchanting item lists * Switch enchanting tabs with the triggers, skip disabled tabs * Only show enchanting gamepad hints when a gamepad is active * Fix cancelling a sacrifice not stopping the countdown * Gamepad Y switches mode on the sacrifice and rune tabs * Cycle enchanting tabs in the order they appear on screen * Block trigger tab switching while the tabs are locked * Left stick navigates the table upgrade options The upgrade pane is not a MultiSelectItemList, so nothing ever gave its feature buttons gamepad focus and the pane's own Select hint did nothing. * Gamepad support for the merchant panel The bumpers walk one chain from the vanilla store list through each of the panel's lists, the stick and d-pad move the selection inside the focused list, X presses its action button and Y abandons a bounty. * build the compendium magic pages on gamepad navigation * right stick scrolls the compendium magic pages * adventure pin filters on the vanilla map icon row, frees the triggers * Stop gamepad A from submitting the rune mode toggle * Keep the rune action button labelled after an action finishes * Gamepad support for the rune tab's enchantment column * adventure pin filters moved into the vanilla boss and death panel, sized to fit the map * Gamepad d-pad cycling for the sacrifice tab's identify category --- EpicLoot/localizations/English.json | 1 + EpicLoot/src/Adventure/AbandonBountyDialog.cs | 4 +- .../Adventure/BaseMerchantPanelListElement.cs | 14 + EpicLoot/src/Adventure/MerchantPanel.cs | 12 + .../src/Adventure/MerchantPanelGamepad.cs | 481 ++++++++++++++++++ .../Adventure/Minimap/AdventurePinFilter.cs | 138 +++++ .../src/Adventure/Minimap/AdventureToggle.cs | 52 -- .../Adventure/Minimap/MinimapController.cs | 354 ++++++++++--- .../src/Adventure/Minimap/MinimapPatch.cs | 21 +- .../Adventure/feature/BountiesListPanel.cs | 7 + .../src/Adventure/feature/GambleListPanel.cs | 1 + .../Adventure/feature/MerchantListPanel.cs | 104 +++- .../Adventure/feature/SecretStashListPanel.cs | 1 + .../Adventure/feature/TreasureMapListPanel.cs | 1 + EpicLoot/src/Compendium/MagicPages.cs | 19 + EpicLoot/src/Compendium/MagicTextList.cs | 15 + EpicLoot/src/Config/ELConfig.cs | 2 +- EpicLoot/src/GamePatches/TextsDialog_Patch.cs | 28 +- .../src/UI/Enchanting/EnchantingTableUI.cs | 139 ++++- .../Enchanting/EnchantingTableUIPanelBase.cs | 79 ++- .../src/UI/Enchanting/MultiSelectItemList.cs | 175 ++++--- .../Enchanting/MultiSelectItemListElement.cs | 14 +- .../MultiSelectListFocusController.cs | 134 +++-- EpicLoot/src/UI/Enchanting/RuneUI.cs | 304 ++++++++++- EpicLoot/src/UI/Enchanting/SacrificeUI.cs | 135 ++++- EpicLoot/src/UI/Enchanting/UpgradeTableUI.cs | 68 +++ 26 files changed, 1992 insertions(+), 311 deletions(-) create mode 100644 EpicLoot/src/Adventure/MerchantPanelGamepad.cs create mode 100644 EpicLoot/src/Adventure/Minimap/AdventurePinFilter.cs delete mode 100644 EpicLoot/src/Adventure/Minimap/AdventureToggle.cs diff --git a/EpicLoot/localizations/English.json b/EpicLoot/localizations/English.json index c16e182a6..e1a6a20e6 100644 --- a/EpicLoot/localizations/English.json +++ b/EpicLoot/localizations/English.json @@ -851,6 +851,7 @@ "mod_epicloot_enchanting_select": "Select", "mod_epicloot_enchanting_sorting": "Change Sorting", "mod_epicloot_enchanting_changequantity": "Change Quantity", + "mod_epicloot_enchanting_identifycategory": "Change Category", "mod_epicloot_enchanting_scroll": "Scroll Info", "mod_epicloot_active_magic_effects": "Active Magic Effects", diff --git a/EpicLoot/src/Adventure/AbandonBountyDialog.cs b/EpicLoot/src/Adventure/AbandonBountyDialog.cs index ae7894966..715308f5a 100644 --- a/EpicLoot/src/Adventure/AbandonBountyDialog.cs +++ b/EpicLoot/src/Adventure/AbandonBountyDialog.cs @@ -23,14 +23,14 @@ public void Awake() NoButton.onClick.AddListener(OnNoButtonClicked); } - private void OnYesButtonClicked() + public void OnYesButtonClicked() { AdventureDataManager.Bounties.AbandonBounty(_bountyInfo); MerchantPanel.OnAbandonBounty(); Close(); } - private void OnNoButtonClicked() + public void OnNoButtonClicked() { Close(); } diff --git a/EpicLoot/src/Adventure/BaseMerchantPanelListElement.cs b/EpicLoot/src/Adventure/BaseMerchantPanelListElement.cs index ab065af30..f2f7a7060 100644 --- a/EpicLoot/src/Adventure/BaseMerchantPanelListElement.cs +++ b/EpicLoot/src/Adventure/BaseMerchantPanelListElement.cs @@ -1,19 +1,33 @@ using UnityEngine; +using UnityEngine.UI; namespace EpicLoot.Adventure { public interface IMerchantPanelListElement { void SetSelected(bool selected); + Button GetButton(); } public class BaseMerchantPanelListElement : MonoBehaviour, IMerchantPanelListElement where T : class { public GameObject SelectedBackground; + private Button _button; + public void SetSelected(bool selected) { SelectedBackground.SetActive(selected); } + + public Button GetButton() + { + if (_button == null) + { + _button = GetComponent public static bool IsValuelessEffect(string effectType) { - var effectDef = Get(effectType); - if (effectDef?.ValuesPerRarity == null) + // An unknown effect is never treated as binary, so its stored value is left alone. Not warned + // here: every item load passes through this, and Get() already reports the missing + // definition once per type wherever the effect is shown. + if (!TryGet(effectType, out var effectDef)) + { + return false; + } + + if (effectDef.ValuesPerRarity == null) { - EpicLoot.LogWarning($"Checking if unknown effect is valueless ({effectType})"); + EpicLoot.LogWarning($"Checking if effect without a ValuesPerRarity block is valueless ({effectType})"); return false; } diff --git a/EpicLoot/src/Magic/MagicItemEffects/AddLifeSteal.cs b/EpicLoot/src/Magic/MagicItemEffects/AddLifeSteal.cs index af905968e..2852ed575 100755 --- a/EpicLoot/src/Magic/MagicItemEffects/AddLifeSteal.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/AddLifeSteal.cs @@ -13,16 +13,7 @@ public static void CheckAndDoLifeSteal(HitData hit, Character attacker) return; } - ItemDrop.ItemData weapon; - if (Attack_Patch.ActiveAttack != null && Attack_Patch.ActiveAttack.m_weapon != null) - { - weapon = Attack_Patch.ActiveAttack.m_weapon; - } - else - { - weapon = player.GetCurrentWeapon(); - } - + ItemDrop.ItemData weapon = MagicEffectsHelper.GetActiveWeapon(player); if (weapon == null || !weapon.IsMagic()) { return; diff --git a/EpicLoot/src/Magic/MagicItemEffects/AddSkillLevel.cs b/EpicLoot/src/Magic/MagicItemEffects/AddSkillLevel.cs index 1b8d27342..c172294d8 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/AddSkillLevel.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/AddSkillLevel.cs @@ -2,6 +2,7 @@ using JetBrains.Annotations; using System.Collections.Generic; using System.Linq; +using System.Reflection; using TMPro; using UnityEngine; using UnityEngine.UI; @@ -84,13 +85,73 @@ void SkillsAsSkills(string effect, SkillType[] type, SkillType[] asType) } } + // The skill *level* vanilla reads for gameplay -- a hit's m_skillLevel, which sets the Staff of Protection + // bubble's absorb; a summon's damage factor and how many may be out at once (SpawnAbility) -- as opposed to + // the skill factor, which the patch above already raises. Adding the bonus to Skills.GetSkillLevel outright + // would count it twice in GetSkillFactor (which reads GetSkillLevel, then gets the bonus above) and in the + // skills panel, so it is only added for the duration of a gameplay read: Character.GetSkillLevel, which + // every attack, projectile, area effect and item tooltip goes through, and SpawnAbility's spawn coroutine, + // which reads Skills.GetSkillLevel directly. A GetSkillFactor inside such a read (SkillIncrease's + // skill-as-skill effects call it) suspends the read, for the same double-count reason. + [HarmonyPatch] + public static class AddSkillLevel_GameplaySkillLevel_Patch + { + private static int _gameplayReads; + + [HarmonyPatch(typeof(Skills), nameof(Skills.GetSkillLevel))] + [HarmonyPostfix] + private static void Skills_GetSkillLevel_Postfix(Skills __instance, SkillType skillType, ref float __result) + { + if (_gameplayReads > 0 && __instance.m_player != null && skillType != SkillType.None) + { + __result += AddSkillLevel_Skills_GetSkillFactor_Patch.SkillIncrease(__instance.m_player, skillType); + } + } + + [HarmonyPatch(typeof(Character), nameof(Character.GetSkillLevel))] + [HarmonyPrefix] + private static void Character_GetSkillLevel_Prefix() => _gameplayReads++; + + [HarmonyPatch(typeof(Character), nameof(Character.GetSkillLevel))] + [HarmonyFinalizer] + private static void Character_GetSkillLevel_Finalizer() => _gameplayReads--; + + [HarmonyPatch(typeof(Skills), nameof(Skills.GetSkillFactor))] + [HarmonyPrefix] + private static void Skills_GetSkillFactor_Prefix(out int __state) + { + __state = _gameplayReads; + _gameplayReads = 0; + } + + [HarmonyPatch(typeof(Skills), nameof(Skills.GetSkillFactor))] + [HarmonyFinalizer] + private static void Skills_GetSkillFactor_Finalizer(int __state) => _gameplayReads = __state; + + [HarmonyPatch] + private static class SpawnAbility_Spawn_Patch + { + [UsedImplicitly] + private static MethodBase TargetMethod() => + AccessTools.EnumeratorMoveNext(AccessTools.Method(typeof(SpawnAbility), nameof(SpawnAbility.Spawn))); + + [UsedImplicitly] + private static void Prefix() => _gameplayReads++; + + [UsedImplicitly] + private static void Finalizer() => _gameplayReads--; + } + } + // These fix a bug in vanilla where skill factor cannot go over 100 [HarmonyPatch(typeof(Skills), nameof(Skills.GetRandomSkillRange))] public static class Skills_GetRandomSkillRange_Patch { public static bool Prefix(Skills __instance, out float min, out float max, SkillType skillType) { - var skillValue = Mathf.Lerp(0.4f, 1.0f, __instance.GetSkillFactor(skillType)); + // Unclamped: the factor is above 1 when an EpicLoot skill bonus takes a skill past 100, and a + // clamped Lerp threw that part away, so +skill added no damage at trained 100. + var skillValue = Mathf.LerpUnclamped(0.4f, 1.0f, __instance.GetSkillFactor(skillType)); min = Mathf.Max(0, skillValue - 0.15f); max = skillValue + 0.15f; return false; diff --git a/EpicLoot/src/Magic/MagicItemEffects/AutoMeads.cs b/EpicLoot/src/Magic/MagicItemEffects/AutoMeads.cs index 1ba930236..ec5c71584 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/AutoMeads.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/AutoMeads.cs @@ -5,8 +5,14 @@ namespace EpicLoot.Magic.MagicItemEffects; public class AutoMeads { - // Prefix handler invoked by CharacterDamageDispatch (victim-side): auto-drink a healing mead when the - // incoming hit would drop the local player to critical health. + // True while AutoMeads is drinking for a hit that is about to drop the player to critical health. The + // player's health is still above the threshold at that moment, so Instant Mead (ModifyMeads) reads this + // as well as the current health -- otherwise the pre-emptive drink always came out as a slow mead. + public static bool DrinkingForCriticalHit { get; private set; } + + // Invoked by SharedPlayerPostArmorDamagePatch (victim-side), once the hit has been through the block, + // resistances and armor: auto-drink one healing mead when the hit will drop the local player to critical + // health, or when they are already there. public static void OnIncomingHit(Character __instance, HitData hit) { if (__instance is not Player player || @@ -28,7 +34,23 @@ public static void OnIncomingHit(Character __instance, HitData hit) !player.m_seman.HaveStatusEffect(item.m_shared.m_consumeStatusEffect.NameHash()) && !player.m_seman.HaveStatusEffectCategory(item.m_shared.m_consumeStatusEffect.m_category)) { - player.ConsumeItem(inventory, item); + bool consumed; + DrinkingForCriticalHit = true; + try + { + consumed = player.ConsumeItem(inventory, item); + } + finally + { + DrinkingForCriticalHit = false; + } + + // One mead per hit: meads from other mods that don't share vanilla's category would + // otherwise all be drunk at once. + if (consumed) + { + return; + } } } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/ChainLightning.cs b/EpicLoot/src/Magic/MagicItemEffects/ChainLightning.cs index 21738e9f7..6174254cb 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/ChainLightning.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/ChainLightning.cs @@ -1,4 +1,5 @@ -using UnityEngine; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; +using UnityEngine; namespace EpicLoot.MagicItemEffects { @@ -10,7 +11,9 @@ public static void OnDamageDealt(Character __instance, HitData hit, Character at } var player = attacker as Player; - var weapon = player?.GetCurrentWeapon(); + // The weapon that dealt this hit -- for an arrow or a thrown weapon, the one that fired it -- not + // whatever is in the right hand now. + var weapon = MagicEffectsHelper.GetActiveWeapon(player); if (weapon == null || weapon.GetMagicItem()?.HasEffect(nameof(MagicEffectType.ChainLightning), includeSocketed: true) != true) { return; } @@ -47,6 +50,8 @@ private static void TriggerChainLightningEffect(Character target, Player player) } var instance = Object.Instantiate(prefab, target.transform.position, Quaternion.identity); + // Its hits (and every jump's, which clones this live object) are bonus hits, not weapon strikes. + HitSource.MarkBonusSource(instance); var aoe = instance.GetComponent(); if (aoe != null) { diff --git a/EpicLoot/src/Magic/MagicItemEffects/Executioner.cs b/EpicLoot/src/Magic/MagicItemEffects/Executioner.cs index 96139db16..efdacadc1 100755 --- a/EpicLoot/src/Magic/MagicItemEffects/Executioner.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Executioner.cs @@ -58,6 +58,13 @@ public static void ModifyOutgoingHit(Character __instance, HitData hit) // equipped weapon. Melee hits derive from the live weapon per hit. float multiplier = ExecutionerMultiplier ?? ReadExecutionerValue(player); + // No Executioner on the attack: nothing to spend, so the target stays executable by a later hit + // that does carry it. + if (multiplier <= 1f) + { + return; + } + if (__instance.GetHealth() / __instance.GetMaxHealth() < 0.2f) { hit.m_damage.Modify(multiplier); diff --git a/EpicLoot/src/Magic/MagicItemEffects/ExplosiveArrows.cs b/EpicLoot/src/Magic/MagicItemEffects/ExplosiveArrows.cs index 4a9e2f812..9e3d848d7 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/ExplosiveArrows.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/ExplosiveArrows.cs @@ -1,4 +1,5 @@ using EpicLoot.General; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; using HarmonyLib; using Jotunn.Managers; using System.Collections.Generic; @@ -72,6 +73,16 @@ private static void SpawnExplosiveArrowOnHit(Vector3 hitPoint, Projectile instan return; } + // The explosion needs the shooter as its owner: every one of Aoe.ShouldHit's filters (the + // prefab's m_hitOwner / m_hitFriendly off) only applies when there is an owner, and the hits + // carry it as their attacker, which is what vanilla's PvP check in RPC_Damage keys on. An + // ownerless explosion hit every character in range -- players without PvP, tames and the + // shooter included. With no live shooter to own it, there is no explosion. + if (instance.m_owner == null) + { + return; + } + GameObject prefab = PrefabManager.Instance.GetPrefab(EpicAssets.ExplosiveArrow); if (prefab == null) @@ -90,6 +101,20 @@ private static void SpawnExplosiveArrowOnHit(Vector3 hitPoint, Projectile instan return; } + // Set directly rather than through Aoe.Setup, which would also apply the weapon's upgrade and + // world-level bonuses on top of the damage worked out below. + aoe.m_owner = instance.m_owner; + + // PvP: the explosion hits what the arrow itself could (vanilla Projectile.IsValidTarget). With + // the archer's PvP off that is enemies only; with it on, friendlies as well, and vanilla's + // RPC_Damage then spares any player whose own PvP is off. Every player shares one m_name + // ("Human"), so m_hitSame has to follow too, or the same-kind filter drops them all. + bool pvp = instance.m_owner.IsPVPEnabled(); + aoe.m_hitFriendly = pvp; + aoe.m_hitSame = pvp; + // Damage the effect deals on its own, not a weapon strike (see HitSource). + HitSource.MarkBonusSource(spawnedObject); + float explodingArrowStrength = explodingArrowValue * instance.m_damage.EpicLootGetTotalDamage(); aoe.m_damage.m_fire = explodingArrowStrength; } diff --git a/EpicLoot/src/Magic/MagicItemEffects/FrostAOE.cs b/EpicLoot/src/Magic/MagicItemEffects/FrostAOE.cs index 064408147..a99270aa4 100755 --- a/EpicLoot/src/Magic/MagicItemEffects/FrostAOE.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/FrostAOE.cs @@ -1,4 +1,5 @@ using EpicLoot.General; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; using HarmonyLib; using JetBrains.Annotations; using System.Collections; @@ -146,7 +147,7 @@ private IEnumerator DamageCoroutine() hitData.m_damage.m_frost = _frostDamage; hitData.SetAttacker(_player); - characterCollider.Damage(hitData); + HitSource.DealBonusDamage(characterCollider, hitData); } ZNetScene.instance.Destroy(gameObject); diff --git a/EpicLoot/src/Magic/MagicItemEffects/Helpers/DamageInRadius.cs b/EpicLoot/src/Magic/MagicItemEffects/Helpers/DamageInRadius.cs index 1be641c7d..1b37cdd30 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Helpers/DamageInRadius.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Helpers/DamageInRadius.cs @@ -27,7 +27,7 @@ public static void DamageEnemiesInRadius(Player attacker, Vector3 center, float m_ranged = true, }; hit.SetAttacker(attacker); - character.Damage(hit); + HitSource.DealBonusDamage(character, hit); } } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Helpers/ElementalHitVariant.cs b/EpicLoot/src/Magic/MagicItemEffects/Helpers/ElementalHitVariant.cs new file mode 100644 index 000000000..3b64ab12a --- /dev/null +++ b/EpicLoot/src/Magic/MagicItemEffects/Helpers/ElementalHitVariant.cs @@ -0,0 +1,21 @@ +namespace EpicLoot.src.Magic.MagicItemEffects.Helpers { + // HitData.m_variant picks which colour of an elemental status effect the target shows: Burning's start + // effects are vfx_Burning (0), vfx_Burning_blue (1) and vfx_Burning_green (2); Lightning's are fx_Lightning + // (0) and fx_Lightning_red (1). EffectList.Create spawns every entry when the variant is -1, and -1 is what + // an ordinary weapon carries (SharedData.m_hitVariant defaults to it). So fire or lightning added to such a + // weapon -- AddFireDamage, NecroticFire, the lightning conversions -- lit the target in every colour at once. + // + // An unset variant on a player's elemental hit is defaulted to 0, the ordinary colour. A weapon that sets + // its own variant (the FrostFire / BloodLightning golds, Fader's green fire) keeps it. + public static class ElementalHitVariant { + public static void ModifyOutgoingHit(HitData hit, Character attacker) { + if (hit.m_variant >= 0 || attacker is not Player) { + return; + } + + if (hit.m_damage.m_fire > 0f || hit.m_damage.m_lightning > 0f) { + hit.m_variant = 0; + } + } + } +} diff --git a/EpicLoot/src/Magic/MagicItemEffects/Helpers/HitSource.cs b/EpicLoot/src/Magic/MagicItemEffects/Helpers/HitSource.cs new file mode 100644 index 000000000..5878e1435 --- /dev/null +++ b/EpicLoot/src/Magic/MagicItemEffects/Helpers/HitSource.cs @@ -0,0 +1,142 @@ +using HarmonyLib; +using UnityEngine; + +namespace EpicLoot.src.Magic.MagicItemEffects.Helpers { + // Where the hit Character.Damage is processing came from, for the attacker-side dispatcher + // (SharedCharacterDamagePatch) to tell weapon strikes apart from everything else. + // + // Bonus hits: damage EpicLoot deals on its own -- Reflect, the Eikthyr charge, FrostAOE, Trailblazer's fire, + // the Moder and Bonemass novas, the meteor and chain lightning -- carries the local player as attacker, so + // without a tag every weapon-strike effect (crits, double damage, Wager and Mercenary stakes, lifesteal, + // Executioner, Slow, Paralyze, Eikthyr charges) rolled on it as well, once per target it touched. + // * Direct damage goes through DealBonusDamage, which holds the tag for the call: Character.Damage runs the + // whole attacker-side dispatch synchronously. + // * Damage that lands later, from a spawned vanilla Aoe or Projectile, is tagged with MarkBonusSource. The + // patches below hold the tag while a marked object deals damage, and mark whatever such an object sets up + // while tagged (a projectile's spawn-on-hit). Chain lightning's jumps are Instantiate clones of the live + // strike, so they carry the marker from the start. + // + // Firing weapon: a projectile lands long after its attack finished, when the hands may hold something else -- + // or nothing, for a thrown weapon, which vanilla unequips as it leaves the hand. While a local player's + // Projectile or Aoe deals damage, FiringWeapon is the weapon that fired it (Projectile.m_weapon / + // Aoe.m_itemData), which MagicEffectsHelper.GetActiveWeapon prefers over the hands. + public static class HitSource { + private static int _bonusDepth; + + public static bool IsBonusHit => _bonusDepth > 0; + + public static ItemDrop.ItemData FiringWeapon { get; private set; } + + public static void DealBonusDamage(IDestructible target, HitData hit) { + _bonusDepth++; + try { + target.Damage(hit); + } finally { + _bonusDepth--; + } + } + + public static void MarkBonusSource(GameObject source) { + if (source != null && !source.TryGetComponent(out BonusSource _)) { + source.AddComponent(); + } + } + + // Saved on entry to a hook and handed back on exit, so nested hooks (Projectile.OnHit calling DoAOE) + // restore what they found. + internal struct Scope { + public bool Bonus; + public ItemDrop.ItemData PreviousWeapon; + } + + internal static Scope Enter(Component source, Character owner, ItemDrop.ItemData weapon) { + var scope = new Scope { PreviousWeapon = FiringWeapon }; + if (source != null && source.TryGetComponent(out BonusSource _)) { + scope.Bonus = true; + _bonusDepth++; + } + if (weapon != null && owner != null && owner == Player.m_localPlayer) { + FiringWeapon = weapon; + } + return scope; + } + + internal static void Exit(Scope scope) { + if (scope.Bonus) { + _bonusDepth--; + } + FiringWeapon = scope.PreviousWeapon; + } + + internal static void MarkIfBonus(Component instance) { + if (IsBonusHit && instance != null) { + MarkBonusSource(instance.gameObject); + } + } + + public sealed class BonusSource : MonoBehaviour { + } + } + + [HarmonyPatch(typeof(Projectile), nameof(Projectile.OnHit))] + internal static class HitSource_Projectile_OnHit_Patch { + [HarmonyPrefix] + private static void Prefix(Projectile __instance, out HitSource.Scope __state) { + __state = HitSource.Enter(__instance, __instance.m_owner, __instance.m_weapon); + } + + [HarmonyFinalizer] + private static void Finalizer(HitSource.Scope __state) => HitSource.Exit(__state); + } + + // The in-flight damage path (m_hitMidFlight) reaches DoAOE from FixedUpdate rather than OnHit. + [HarmonyPatch(typeof(Projectile), nameof(Projectile.DoAOE))] + internal static class HitSource_Projectile_DoAOE_Patch { + [HarmonyPrefix] + private static void Prefix(Projectile __instance, out HitSource.Scope __state) { + __state = HitSource.Enter(__instance, __instance.m_owner, __instance.m_weapon); + } + + [HarmonyFinalizer] + private static void Finalizer(HitSource.Scope __state) => HitSource.Exit(__state); + } + + [HarmonyPatch(typeof(Aoe), nameof(Aoe.OnHit))] + internal static class HitSource_Aoe_OnHit_Patch { + [HarmonyPrefix] + private static void Prefix(Aoe __instance, out HitSource.Scope __state) { + __state = HitSource.Enter(__instance, __instance.m_owner, __instance.m_itemData); + } + + [HarmonyFinalizer] + private static void Finalizer(HitSource.Scope __state) => HitSource.Exit(__state); + } + + // Chain jumps are spawned from CustomFixedUpdate, not from OnHit. They normally inherit the marker by being + // clones of the live strike; holding the tag here as well marks them through the Setup postfix should the + // prefab ever point m_chainObj somewhere else. Only chaining Aoes pay for the component lookup. + [HarmonyPatch(typeof(Aoe), nameof(Aoe.CustomFixedUpdate))] + internal static class HitSource_Aoe_CustomFixedUpdate_Patch { + [HarmonyPrefix] + private static void Prefix(Aoe __instance, out HitSource.Scope __state) { + __state = __instance.m_chainStartChance > 0f + ? HitSource.Enter(__instance, null, null) + : new HitSource.Scope { PreviousWeapon = HitSource.FiringWeapon }; + } + + [HarmonyFinalizer] + private static void Finalizer(HitSource.Scope __state) => HitSource.Exit(__state); + } + + [HarmonyPatch(typeof(Aoe), nameof(Aoe.Setup))] + internal static class HitSource_Aoe_Setup_Patch { + [HarmonyPostfix] + private static void Postfix(Aoe __instance) => HitSource.MarkIfBonus(__instance); + } + + [HarmonyPatch(typeof(Projectile), nameof(Projectile.Setup))] + internal static class HitSource_Projectile_Setup_Patch { + [HarmonyPostfix] + private static void Postfix(Projectile __instance) => HitSource.MarkIfBonus(__instance); + } +} diff --git a/EpicLoot/src/Magic/MagicItemEffects/Helpers/MagicEffectsHelper.cs b/EpicLoot/src/Magic/MagicItemEffects/Helpers/MagicEffectsHelper.cs index 11cf13924..c54a6bed2 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Helpers/MagicEffectsHelper.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Helpers/MagicEffectsHelper.cs @@ -55,6 +55,15 @@ public static float GetTotalActiveMagicEffectValueForWeapon(Player player, ItemD { if (player != null) { + if (itemData != null && !player.IsItemEquiped(itemData)) + { + // The player-wide total already stands the firing weapon in for the held ones. + return itemData == HitSource.FiringWeapon + ? player.GetTotalActiveMagicEffectValue(effectType, scale) + : player.GetTotalActiveMagicEffectValue(effectType, scale) + + GetStandInAdjustment(player, itemData, effectType, scale); + } + return player.GetTotalActiveMagicEffectValue(effectType, scale, GetIgnoreWeapon(player, itemData)); } else if (itemData.IsMagic(out var magicItem)) @@ -65,6 +74,47 @@ public static float GetTotalActiveMagicEffectValueForWeapon(Player player, ItemD return 0; } + /// + /// What to add to the player's equipped-gear total while a thrown weapon's projectile is landing. Vanilla + /// unequips a thrown weapon as it leaves the hand, so its effects had dropped out of every total by the + /// time it hit; for the duration of its hit it counts in place of the weapons held now, the way it + /// counted when it was thrown. Zero at all other times, including for an arrow (the bow stays equipped). + /// + public static float GetFiringWeaponAdjustment(Player player, string effectType, float scale) + { + ItemDrop.ItemData firing = HitSource.FiringWeapon; + if (firing == null || player != Player.m_localPlayer || player.IsItemEquiped(firing)) + { + return 0f; + } + + return GetStandInAdjustment(player, firing, effectType, scale); + } + + // An unequipped weapon counted in place of the weapons held now: its own value, less theirs. Also what + // an inventory tooltip for an unequipped weapon should show. + private static float GetStandInAdjustment(Player player, ItemDrop.ItemData weapon, string effectType, float scale) + { + float adjustment = GetOwnEffectValue(weapon, effectType, scale); + if (IsWeapon(player.m_rightItem)) + { + adjustment -= GetOwnEffectValue(player.m_rightItem, effectType, scale); + } + if (IsWeapon(player.m_leftItem)) + { + adjustment -= GetOwnEffectValue(player.m_leftItem, effectType, scale); + } + + return adjustment; + } + + private static float GetOwnEffectValue(ItemDrop.ItemData item, string effectType, float scale) + { + return item != null && item.IsMagic(out MagicItem magicItem) + ? magicItem.GetTotalEffectValue(effectType, scale, includeSocketed: true) + : 0f; + } + public static bool HasActiveMagicEffect(Player player, ItemDrop.ItemData itemData, string effectType, out float effectValue) { return HasActiveMagicEffect(player, itemData.GetMagicItem(), effectType, out effectValue); @@ -90,7 +140,8 @@ public static bool HasActiveMagicEffectOnWeapon(Player player, ItemDrop.ItemData effectValue = 0f; if (player != null) { - return player.HasActiveMagicEffect(effectType, out effectValue, scale, GetIgnoreWeapon(player, itemData)); + effectValue = GetTotalActiveMagicEffectValueForWeapon(player, itemData, effectType, scale); + return effectValue != 0f; } else if (itemData.IsMagic(out var magicItem)) { @@ -140,13 +191,55 @@ public static float GetLocalPercent(string effectType, float scale = 0.01f) } /// - /// The weapon that produced the current attack: the in-progress melee attack's weapon when one is - /// active (), otherwise the player's currently equipped - /// weapon. Used by on-hit effects socketed into the attacking weapon. + /// The weapon that produced the current attack: the weapon that fired the projectile or area effect + /// now dealing damage (), else the in-progress melee attack's weapon + /// (), otherwise the player's currently equipped weapon. Used by + /// on-hit effects socketed into the attacking weapon. /// public static ItemDrop.ItemData GetActiveWeapon(Player player) { - return Attack_Patch.ActiveAttack?.m_weapon ?? player?.GetCurrentWeapon(); + return HitSource.FiringWeapon ?? Attack_Patch.ActiveAttack?.m_weapon ?? player?.GetCurrentWeapon(); + } + + /// + /// Whether killed , for a Character.Damage postfix on the + /// attacker's client. When this client owns the target, Character.Damage has already run RPC_Damage + /// synchronously (ZRoutedRpc handles a call to its own peer immediately), so the health is post-hit and + /// is read as-is -- subtracting the hit again refunded Wager on hits that left the target alive. A + /// remote target has not taken the hit yet, so its owner's resistance, armor and difficulty scaling are + /// estimated here; fire, poison and spirit arrive as damage over time and never kill on the hit itself. + /// + public static bool IsLethalHit(Character target, HitData hit) + { + if (target == null || hit == null || target.m_nview == null || !target.m_nview.IsValid()) + { + return false; + } + + if (target.m_nview.IsOwner()) + { + return target.GetHealth() <= 0f || target.IsDead(); + } + + HitData estimate = hit.Clone(); + estimate.ApplyResistance(target.GetDamageModifiers(), out _); + if (!target.IsPlayer()) + { + if (target.IsStaggering()) + { + estimate.ApplyModifier(2f); + } + if (Game.m_worldLevel > 0) + { + estimate.ApplyArmor(Game.m_worldLevel * Game.instance.m_worldLevelEnemyBaseAC); + } + estimate.ApplyModifier(Game.instance.GetDifficultyDamageScaleEnemy(target.transform.position)); + estimate.ApplyModifier(Game.m_playerDamageRate); + } + + HitData.DamageTypes damage = estimate.m_damage; + float instantDamage = damage.GetTotalDamage() - damage.m_fire - damage.m_poison - damage.m_spirit; + return target.GetHealth() - instantDamage <= 0f; } } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Helpers/SharedCharacterDamagePatch.cs b/EpicLoot/src/Magic/MagicItemEffects/Helpers/SharedCharacterDamagePatch.cs index 50e9e54c1..d30f18d4b 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Helpers/SharedCharacterDamagePatch.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Helpers/SharedCharacterDamagePatch.cs @@ -15,26 +15,48 @@ namespace EpicLoot.src.Magic.MagicItemEffects.Helpers { // apply an effect to the TARGET (Slow, Paralyze); those check the local player's effect here (attacker // side) but apply it via an RPC to the target's owner, where movement/AI is authoritative. // + // Two kinds of hit skip handlers: + // * A self-inflicted hit (Blood Block's cost) triggers none: it is a price, not an attack, so it must not + // crit itself, pay Mercenary, lifesteal its own blood back, or Slow/Paralyze the player. + // * A bonus hit (HitSource.IsBonusHit -- damage EpicLoot deals on its own: Reflect, novas, the meteor, + // chain lightning, ...) skips the weapon-strike handlers. It keeps the ones keyed on the damage type + // (poison, fire, lightning) and the kill rewards. + // // Each effect keeps its own guard (is-local-attacker / has-effect) inside its handler, so the order among // Normal-priority handlers is not load-bearing. Executioner keeps its original ordering relative to other // mods via the Priority.Last prefix below. [HarmonyPatch(typeof(Character), nameof(Character.Damage))] internal static class SharedCharacterDamagePatch { + private static bool IsSelfInflicted(Character target, Character attacker) { + return attacker != null && attacker == target; + } + [HarmonyPrefix] private static void EarlyPreDamagePatch(Character __instance, HitData hit) { if (hit == null) { return; } Character attacker = hit.GetAttacker(); + if (IsSelfInflicted(__instance, attacker)) { + return; + } + bool strike = !HitSource.IsBonusHit; // Attacker side -- modify the outgoing hit before it lands. - EitrImbueAttack.ModifyOutgoingHit(hit, attacker); + if (strike) { + EitrImbueAttack.ModifyOutgoingHit(hit, attacker); + } IncreaseAllPoisonDamageDone.ModifyOutgoingHit(hit, attacker); PoisonToTrueDamage.ModifyOutgoingHit(__instance, hit, attacker); - Mercenary.ModifyOutgoingHit(hit, attacker); - Wager.ModifyOutgoingHit(__instance, hit, attacker); - ChanceDoubleDamage.ModifyOutgoingHit(hit, attacker); - ChanceToCritOnHit.ModifyOutgoingHit(hit, attacker); + if (strike) { + Mercenary.ModifyOutgoingHit(hit, attacker); + Wager.ModifyOutgoingHit(__instance, hit, attacker); + ChanceDoubleDamage.ModifyOutgoingHit(hit, attacker); + ChanceToCritOnHit.ModifyOutgoingHit(hit, attacker); + } ModifySummonDamage.ModifyOutgoingHit(hit, attacker); + if (!strike) { + return; + } // Attacker-side reads that used to live (incorrectly) on the RPC_Damage dispatcher: // both read the local attacker's magic effects; the stagger tagger routes its write to // the target's owner. @@ -53,7 +75,11 @@ private static void LastPreDamagePatch(Character __instance, HitData hit) { if (hit == null) { return; } - ExecutionerCheckDamage_Character_Damage_Patch.ModifyOutgoingHit(__instance, hit); + if (!HitSource.IsBonusHit && !IsSelfInflicted(__instance, hit.GetAttacker())) { + ExecutionerCheckDamage_Character_Damage_Patch.ModifyOutgoingHit(__instance, hit); + } + // Last, so it sees fire/lightning that any earlier prefix converted into the hit. + ElementalHitVariant.ModifyOutgoingHit(hit, hit.GetAttacker()); } [HarmonyPostfix] @@ -62,28 +88,37 @@ private static void PostDamagePatch(Character __instance, HitData hit) { return; } Character attacker = hit.GetAttacker(); + if (IsSelfInflicted(__instance, attacker)) { + return; + } + bool strike = !HitSource.IsBonusHit; // Wager settles first: it consumes the stake its own prefix parked for THIS hit, and several // handlers below (ChainLightning, MeteorSummoner) deal further damage, which re-enters this // patch and would otherwise clear the stake before it could be refunded. - Wager.OnDamageDealt(__instance, hit); + if (strike) { + Wager.OnDamageDealt(__instance, hit); - // On-hit reactions (attacker side unless the handler guards otherwise). - AddLifeSteal.CheckAndDoLifeSteal(hit, attacker); - BloodDrinker.OnDamageDealt(hit, attacker); - AddEitrLeech.OnDamageDealt(hit, attacker); - ChainLightning.OnDamageDealt(__instance, hit, attacker); - StrikeCausesLightning.OnDamageDealt(__instance, hit, attacker); - ApplySlow_Character_Damage_Patch.OnDamageDealt(__instance, hit, attacker); - Paralyze.OnDamaged(__instance, hit, attacker); - StaggerOnDamageTaken_Character_Damage_Patch.OnDamageDealt(__instance, hit, attacker); - HealthGainPerXDamageDone.OnDamageDealt(hit, attacker); - LifeGainOnHit.OnDamageDealt(hit, attacker); + // On-hit reactions (attacker side unless the handler guards otherwise). + AddLifeSteal.CheckAndDoLifeSteal(hit, attacker); + BloodDrinker.OnDamageDealt(hit, attacker); + AddEitrLeech.OnDamageDealt(hit, attacker); + ChainLightning.OnDamageDealt(__instance, hit, attacker); + StrikeCausesLightning.OnDamageDealt(__instance, hit, attacker); + ApplySlow_Character_Damage_Patch.OnDamageDealt(__instance, hit, attacker); + Paralyze.OnDamaged(__instance, hit, attacker); + StaggerOnDamageTaken_Character_Damage_Patch.OnDamageDealt(__instance, hit, attacker); + HealthGainPerXDamageDone.OnDamageDealt(hit, attacker); + LifeGainOnHit.OnDamageDealt(hit, attacker); + } + // Keyed on the damage type or the kill, so a bonus hit earns these too. GainAdrenalineWhenApplyingPoison.OnDamageDealt(__instance, hit, attacker); BurningAdrenaline.OnDamageDealt(hit, attacker); StaminaOnKill.OnDamageDealt(__instance, hit, attacker); Conduit.OnDamageDealt(__instance, hit, attacker); - EikthyrShockingCharge.OnDamageDealt(__instance, hit, attacker); - MeteorSummoner.OnDamageDealt(__instance, hit, attacker); + if (strike) { + EikthyrShockingCharge.OnDamageDealt(__instance, hit, attacker); + MeteorSummoner.OnDamageDealt(__instance, hit, attacker); + } QueenEverflow.OnDamageDealt(__instance, hit, attacker); } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Helpers/SharedCharacterRpcDamagePatch.cs b/EpicLoot/src/Magic/MagicItemEffects/Helpers/SharedCharacterRpcDamagePatch.cs index b65cf82de..885e4b1e5 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Helpers/SharedCharacterRpcDamagePatch.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Helpers/SharedCharacterRpcDamagePatch.cs @@ -10,25 +10,34 @@ namespace EpicLoot.src.Magic.MagicItemEffects.Helpers { // * The prefix runs the incoming-hit modifiers in a fixed order and can cancel the hit entirely // (AvoidDamageTaken). The one load-bearing ordering constraint from the old code is preserved by // call order: IncomingPhysicalConversion moves physical damage onto an element BEFORE - // ModifyResistance reduces it (previously done with Priority.High). Avoidance is checked before the - // resource-spending mitigations (EitrShield, Coinplated) so a fully-avoided hit never spends eitr or - // coins. EitrShield runs first of those two, so eitr is drained before the purse. - // * The postfix runs the on-damage-taken reactions (slow, adrenaline, boss retributions). These run - // even when the prefix cancels the hit -- matching the old behavior, where these were independent - // postfixes that Harmony always executes. + // ModifyResistance reduces it (previously done with Priority.High). + // * The postfix runs the on-damage-taken reactions (slow, adrenaline, boss retributions), for hits that + // landed: not for one AvoidDamageTaken cancelled, nor one vanilla discards itself. + // * The resource-spending mitigations (EitrShield, Coinplated) and AutoMeads are not here: they run from + // SharedPlayerPostArmorDamagePatch below, once the hit has been through the bubble, block and armor. + // + // The prefix runs before vanilla's own checks, so it first asks whether vanilla will discard the hit + // (WillLand) -- otherwise a dodged hit, one from a despawned attacker or a PvP-off hit still spent + // resources and triggered reactions. A self-inflicted hit (Blood Block's cost) is a price, not an attack, + // so it triggers nothing here either. // // Each effect keeps its own guard (is-local-victim / has-effect / attacker checks) inside its handler. [HarmonyPatch(typeof(Character), nameof(Character.RPC_Damage))] internal static class SharedCharacterRpcDamagePatch { - // Returns false to cancel RPC_Damage (AvoidDamageTaken rolled an avoid). + // Returns false to cancel RPC_Damage (AvoidDamageTaken rolled an avoid). __state tells the postfix + // whether the hit landed; it is per call, so a re-entrant RPC_Damage (ReflectDamage) can't clobber it. [HarmonyPrefix] - private static bool Prefix(Character __instance, HitData hit) { + private static bool Prefix(Character __instance, HitData hit, out bool __state) { + __state = false; if (hit == null) { return true; } // Resolved once per hit; a local (not a static) so a re-entrant RPC_Damage from a handler // below (e.g. ReflectDamage) can never clobber an outer invocation's attacker. Character attacker = hit.GetAttacker(); + if (!WillLand(__instance, hit, attacker) || IsSelfInflicted(__instance, hit, attacker)) { + return true; + } // NOTE: Opportunist and the melee stagger-duration tagger moved to the attacker-side // dispatcher (SharedCharacterDamagePatch): they read the ATTACKER's magic effects, which @@ -38,36 +47,85 @@ private static bool Prefix(Character __instance, HitData hit) { // Convert physical -> element first, so the resistance step below reduces the converted damage. IncomingPhysicalConversion.ModifyIncoming(__instance, hit); - // Avoidance is decided before eitr is spent; an avoided hit cancels the whole method. + // Avoidance is decided before anything is spent; an avoided hit cancels the whole method. if (!AvoidDamageTaken_Character_RPC_Damage_Patch.ShouldTakeDamage(__instance, hit, attacker)) { return false; } ModifyResistance.ModifyIncoming(__instance, hit); - EitrShield.ModifyIncoming(__instance, hit); - Coinplated.ModifyIncoming(__instance, hit); DamageReductionAtNight.ModifyIncoming(__instance, hit); // Victim-side reactions moved off Character.Damage so they fire on the victim's own client // regardless of who owns the attacker. Run after the avoid check so an avoided hit doesn't - // waste a mead or reflect. ReflectDamage runs last (it reflects the fully-mitigated hit). + // waste a reflect. ReflectDamage runs last (it reflects the hit after these reductions). OffSetAttack.ReduceIncomingHit(__instance, hit); - AutoMeads.OnIncomingHit(__instance, hit); ReflectiveDamage_Character_Damage_Patch.OnIncomingHit(__instance, hit); + __state = true; return true; } [HarmonyPostfix] - private static void Postfix(Character __instance, HitData hit) { - if (hit == null) { + private static void Postfix(Character __instance, HitData hit, bool __state) { + if (hit == null || !__state) { return; } // On-damage-taken reactions. DamageTakenGivesAdrenaline.OnDamageTaken(__instance, hit); Bloodrage.OnDamageTaken(__instance, hit); - ElderForestsAid.OnDamageTaken(__instance, hit); - ModerIcyRetribution.OnDamageTaken(__instance, hit); + // Retaliation needs someone to retaliate against: not a fall, lava, drowning or the like. + if (hit.GetAttacker() != null) { + ElderForestsAid.OnDamageTaken(__instance, hit); + ModerIcyRetribution.OnDamageTaken(__instance, hit); + } + } + + // Mirrors the early returns at the top of vanilla Character.RPC_Damage: debug flying, not the owner, + // dead/teleporting/in a cutscene, dodge invincibility, an attacker that no longer exists, and a player + // hit by a player with PvP off. Keep in step with vanilla. + private static bool WillLand(Character target, HitData hit, Character attacker) { + if (target.IsDebugFlying() || target.m_nview == null || !target.m_nview.IsOwner()) { + return false; + } + if (target.GetHealth() <= 0f || target.IsDead() || target.IsTeleporting() || target.InCutscene() || + (hit.m_dodgeable && target.IsDodgeInvincible())) { + return false; + } + if (hit.HaveAttacker() && attacker == null) { + return false; + } + return !(target.IsPlayer() && !target.IsPVPEnabled() && attacker != null && attacker.IsPlayer() && + !hit.m_ignorePVP); + } + + internal static bool IsSelfInflicted(Character target, HitData hit, Character attacker) { + return hit.m_hitType == HitData.HitType.Self || (attacker != null && attacker == target); + } + } + + // The resource-spending mitigations, run where the hit is final: Player.DamageArmorDurability is called + // from exactly one place, in Character.RPC_Damage right after ApplyArmor, and only for players. By then + // vanilla has applied its discard checks, difficulty scaling, the Staff of Protection bubble, the block, + // resistances and armor, so eitr and coins are only spent on damage that would otherwise get through -- + // they used to be charged on the raw hit, before all of that. Fire, poison and spirit are still in the hit + // here (vanilla splits them off into damage over time next), so the shields cover those as before. + // + // A postfix rather than a prefix, so another mod's skipping prefix on this method can't skip these. + // AvoidDamageTaken cancels RPC_Damage outright, so an avoided hit never gets here. + [HarmonyPatch(typeof(Player), nameof(Player.DamageArmorDurability))] + internal static class SharedPlayerPostArmorDamagePatch { + [HarmonyPostfix] + private static void Postfix(Player __instance, HitData hit) { + if (hit == null || __instance != Player.m_localPlayer || + SharedCharacterRpcDamagePatch.IsSelfInflicted(__instance, hit, hit.GetAttacker())) { + return; + } + + // EitrShield first, so eitr is drained before the purse. + EitrShield.ModifyIncoming(__instance, hit); + Coinplated.ModifyIncoming(__instance, hit); + // Last: it judges the damage that will actually land. + AutoMeads.OnIncomingHit(__instance, hit); } } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Immovable.cs b/EpicLoot/src/Magic/MagicItemEffects/Immovable.cs index 007b7aa85..edc2d250e 100755 --- a/EpicLoot/src/Magic/MagicItemEffects/Immovable.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Immovable.cs @@ -16,6 +16,27 @@ private static bool Prefix(Character __instance) } } + // Skipping Stagger alone left the guard broken: vanilla BlockAttack asks AddStaggerDamage whether the block + // staggered, and a full stagger bar answers yes -- so the block let the whole hit through, with no stagger + // to show for it, and the bar stayed full (re-pinned by every hit) until it drained. While blocking with + // Immovable, stagger damage doesn't build at all. Stamina still runs out as usual. + [HarmonyPatch(typeof(Character), nameof(Character.AddStaggerDamage))] + public class Immovable_Character_AddStaggerDamage_Patch + { + [UsedImplicitly] + private static bool Prefix(Character __instance, ref bool __result) + { + if (__instance is Player player && player.IsBlocking() && + player.HasActiveMagicEffect(MagicEffectType.Immovable, out float _)) + { + __result = false; + return false; + } + + return true; + } + } + [HarmonyPatch(typeof(Character), nameof(Character.ApplyPushback), typeof(Vector3), typeof(float))] public class Immovable_Character_ApplyPushback_Patch { diff --git a/EpicLoot/src/Magic/MagicItemEffects/ModifyMeads.cs b/EpicLoot/src/Magic/MagicItemEffects/ModifyMeads.cs index e05668a4b..9e9d72d3c 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/ModifyMeads.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/ModifyMeads.cs @@ -47,7 +47,7 @@ dbEffect is not SE_Stats seStats || SE_Stats returnEffect = seStats; if (Player.m_localPlayer.HasActiveMagicEffect(MagicEffectType.InstantMead) && - ModifyWithLowHealth.PlayerHasLowHealth(Player.m_localPlayer)) + (ModifyWithLowHealth.PlayerHasLowHealth(Player.m_localPlayer) || AutoMeads.DrinkingForCriticalHit)) { returnEffect = TryCreateInstantMead(returnEffect); } diff --git a/EpicLoot/src/Magic/MagicItemEffects/ModifyWithLowHealth.cs b/EpicLoot/src/Magic/MagicItemEffects/ModifyWithLowHealth.cs index 7c9b4c816..9c8485ac1 100755 --- a/EpicLoot/src/Magic/MagicItemEffects/ModifyWithLowHealth.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/ModifyWithLowHealth.cs @@ -58,6 +58,10 @@ public static float GetLowHealthPercentage(Player player) return lowHealthThreshold; } + // Expects the hit as it stands after the block, resistances and armor (see + // SharedPlayerPostArmorDamagePatch), so it only applies what vanilla still does to it before the damage + // lands: fire, poison and spirit are split off into damage over time, the rest is scaled by the world's + // damage-taken rate, and a creature's world-level bonus is added flat (HitData.GetTotalDamage). public static bool PlayerWillBecomeHealthCritical(Player player, HitData hit) { if (PlayerHasLowHealth(player)) @@ -67,17 +71,13 @@ public static bool PlayerWillBecomeHealthCritical(Player player, HitData hit) float lowHealthPercentage = Mathf.Min(ModifyWithLowHealth.GetLowHealthPercentage(player), 1.0f) * player.GetMaxHealth(); float currentHealth = player.GetHealth(); - float hitTotalDamage = hit.m_damage.EpicLootGetTotalDamageAgainstPlayer(); - float armorValue = player.GetBodyArmor(); - hitTotalDamage = HitData.DamageTypes.ApplyArmor(hitTotalDamage, armorValue); + HitData.DamageTypes damage = hit.m_damage; + float typedDamage = damage.GetTotalDamage(); + float instantDamage = (typedDamage - damage.m_fire - damage.m_poison - damage.m_spirit) * Game.m_localDamgeTakenRate + + (hit.GetTotalDamage() - typedDamage); - if ((currentHealth - hitTotalDamage) < lowHealthPercentage) - { - return true; - } - - return false; + return currentHealth - instantDamage < lowHealthPercentage; } } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/MultiShot.cs b/EpicLoot/src/Magic/MagicItemEffects/MultiShot.cs index 6004a32ee..aa32e2c3a 100755 --- a/EpicLoot/src/Magic/MagicItemEffects/MultiShot.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/MultiShot.cs @@ -163,10 +163,13 @@ private static bool ModifyShot(ref Player player, ref Attack attack, Dictionary< } /// - /// Restore the attack damages to previous state if changed by the prefix. + /// Restore the attack damages to previous state if changed by the prefix. A finalizer, not a + /// postfix: m_shared is shared by every weapon of that kind, so an exception mid-burst would + /// otherwise leave all of them at the reduced damage until the game restarts. /// [HarmonyPatch(typeof(Attack), nameof(Attack.FireProjectileBurst))] - public static void Postfix(Attack __instance, ref HitData.DamageTypes? __state) + [HarmonyFinalizer] + public static void Attack_FireProjectileBurst_Finalizer(Attack __instance, HitData.DamageTypes? __state) { if (__state != null) { diff --git a/EpicLoot/src/Magic/MagicItemEffects/ReflectDamage.cs b/EpicLoot/src/Magic/MagicItemEffects/ReflectDamage.cs index 588d76928..f6233ba3a 100755 --- a/EpicLoot/src/Magic/MagicItemEffects/ReflectDamage.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/ReflectDamage.cs @@ -1,11 +1,14 @@ -namespace EpicLoot.MagicItemEffects +using EpicLoot.src.Magic.MagicItemEffects.Helpers; + +namespace EpicLoot.MagicItemEffects { public class ReflectiveDamage_Character_Damage_Patch { private static bool _isApplyingReflectiveDmg; - // Prefix handler invoked by CharacterDamageDispatch (victim-side, runs at Priority.Last so it - // reflects the fully-modified incoming damage). + // Prefix handler invoked by SharedCharacterRpcDamagePatch (victim-side, runs last in that prefix so + // it reflects the hit after the conversion, resistance and night reductions). The reflected hit is + // a bonus hit: it gets none of the local player's weapon-strike effects. public static void OnIncomingHit(Character __instance, HitData hit) { var attacker = hit.GetAttacker(); @@ -26,7 +29,7 @@ public static void OnIncomingHit(Character __instance, HitData hit) try { _isApplyingReflectiveDmg = true; - attacker.Damage(hitData); + HitSource.DealBonusDamage(attacker, hitData); } finally { diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/Coinplated.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/Coinplated.cs index 9a8b3c62c..1f6a7da9e 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/Coinplated.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/Coinplated.cs @@ -32,8 +32,8 @@ public static void RegisterDisplayValues() { }); } - // Prefix handler invoked by CharacterRpcDamageDispatch (victim-side incoming modifier; runs after - // avoidance so a fully-avoided hit never spends coins). + // Invoked by SharedPlayerPostArmorDamagePatch (victim-side): after the bubble, block, resistances and + // armor, so coins are only spent on damage that would actually land. public static void ModifyIncoming(Character __instance, HitData hit) { if (hit == null || __instance != Player.m_localPlayer) { return; diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/DamageConversionEffects.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/DamageConversionEffects.cs index 222b608f1..6295f7538 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/DamageConversionEffects.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/DamageConversionEffects.cs @@ -1,9 +1,17 @@ -namespace EpicLoot.MagicItemEffects.Shards { +using UnityEngine; + +namespace EpicLoot.MagicItemEffects.Shards { // Provides a conversion of incoming physical damage into elemental damage based on the player's active magic effects. public static class IncomingPhysicalConversion { // Prefix handler invoked by CharacterRpcDamageDispatch (victim-side incoming modifier). The // dispatcher calls this before ModifyResistance so the converted element is then reduced by the - // player's matching percentage resistance + // player's matching percentage resistance. + // + // With IsBlocked (from the BlockAttack postfix) it converts the on-block shares only. The general + // shares were already converted by the RPC_Damage prefix, which runs before vanilla's BlockAttack on + // this same HitData; adding them again converted a blocked hit twice (30% became 51%). The on-block + // shares are meant as a share of the original physical damage, of which only (1 - general) is left + // here, so they are scaled up to match. public static void ModifyIncoming(Character __instance, HitData hit, bool IsBlocked = false, bool IsParry = false) { if (__instance != Player.m_localPlayer) { return; @@ -16,10 +24,15 @@ public static void ModifyIncoming(Character __instance, HitData hit, bool IsBloc float toLightning = player.GetTotalActiveMagicEffectValue(MagicEffectType.PhysToLightning, 0.01f); if (IsBlocked) { - toFire += player.GetTotalActiveMagicEffectValue(MagicEffectType.PhysToFireOnBlock, 0.01f); - toFrost += player.GetTotalActiveMagicEffectValue(MagicEffectType.PhysToFrostOnBlock, 0.01f); - toPoison += player.GetTotalActiveMagicEffectValue(MagicEffectType.PhysToPoisonOnBlock, 0.01f); - toLightning += player.GetTotalActiveMagicEffectValue(MagicEffectType.PhysToLightningOnBlock, 0.01f); + float remaining = 1f - Mathf.Min(1f, toFire + toFrost + toPoison + toLightning); + if (remaining <= 0f) { + return; + } + + toFire = player.GetTotalActiveMagicEffectValue(MagicEffectType.PhysToFireOnBlock, 0.01f) / remaining; + toFrost = player.GetTotalActiveMagicEffectValue(MagicEffectType.PhysToFrostOnBlock, 0.01f) / remaining; + toPoison = player.GetTotalActiveMagicEffectValue(MagicEffectType.PhysToPoisonOnBlock, 0.01f) / remaining; + toLightning = player.GetTotalActiveMagicEffectValue(MagicEffectType.PhysToLightningOnBlock, 0.01f) / remaining; } float total = toFire + toFrost + toPoison + toLightning; diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/EikthyrShockingCharge.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/EikthyrShockingCharge.cs index 15af3a03f..a2540d5ea 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/EikthyrShockingCharge.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/EikthyrShockingCharge.cs @@ -209,7 +209,7 @@ private static void DamageEnemiesInCone(Player player, Vector3 origin, Vector3 d }; hit.m_damage.m_lightning = lightningDamage; hit.SetAttacker(player); - character.Damage(hit); + HitSource.DealBonusDamage(character, hit); } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/EitrShield.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/EitrShield.cs index 172469795..cbd181bcb 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/EitrShield.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/EitrShield.cs @@ -6,8 +6,8 @@ namespace EpicLoot.MagicItemEffects.Shards { // Absorbs a portion of incoming damage by spending Eitr public static class EitrShield { static GameObject effect = null; - // Prefix handler invoked by CharacterRpcDamageDispatch (victim-side incoming modifier; runs after - // avoidance so a fully-avoided hit never spends eitr). + // Invoked by SharedPlayerPostArmorDamagePatch (victim-side): after the bubble, block, resistances and + // armor, so eitr is only spent on damage that would actually land. public static void ModifyIncoming(Character __instance, HitData hit) { if (hit == null || __instance != Player.m_localPlayer) { return; diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/MeteorSummoner.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/MeteorSummoner.cs index 9f07dcb96..fde91964c 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/MeteorSummoner.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/MeteorSummoner.cs @@ -131,6 +131,9 @@ private static void SummonMeteor(Player player, Character target, float fireDama var velocity = (targetPos - spawnPos).normalized * speed; var meteor = Object.Instantiate(prefab, spawnPos, Quaternion.LookRotation(velocity)); + // A bonus hit, not a weapon strike: no crits, stakes or on-hit procs on the meteor's damage (or on + // whatever it spawns on impact). + HitSource.MarkBonusSource(meteor); var projectile = meteor.GetComponent(); if (projectile == null) { return; diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/QueenEverflow.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/QueenEverflow.cs index 83ca387fe..20eccca7e 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/QueenEverflow.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/QueenEverflow.cs @@ -43,10 +43,9 @@ public static void OnDamageDealt(Character __instance, HitData hit, Character at return; } - // Only fire on a kill. Predicted lethality: against a remote-owned victim the RPC - // carrying this hit has not executed yet, so GetHealth() still reads pre-hit health and - // a plain <= 0 check never fired in multiplayer. - if (__instance.GetHealth() - hit.GetTotalDamage() > 0f) { + // Only fire on a kill. Against a remote-owned victim the RPC carrying this hit has not executed + // yet, so IsLethalHit estimates the post-hit health; against one this client owns, it has. + if (!MagicEffectsHelper.IsLethalHit(__instance, hit)) { return; } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/StaminaOnKill.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/StaminaOnKill.cs index a86ac0700..ab1371dbf 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/StaminaOnKill.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/StaminaOnKill.cs @@ -1,15 +1,16 @@ -namespace EpicLoot.MagicItemEffects.Shards { +using EpicLoot.src.Magic.MagicItemEffects.Helpers; + +namespace EpicLoot.MagicItemEffects.Shards { // Restores a percentage of the player's maximum stamina when they kill an enemy public static class StaminaOnKill { // Postfix handler invoked by CharacterDamageDispatch (on-hit reaction). public static void OnDamageDealt(Character __instance, HitData hit, Character attacker) { var player = Player.m_localPlayer; - // Kill check by predicted lethality: against a remote-owned victim the RPC carrying this - // hit has not executed yet, so GetHealth() still reads pre-hit health and a plain <= 0 - // check never fired in multiplayer. + // Kill check via IsLethalHit: against a remote-owned victim the RPC carrying this hit has not + // executed yet, so the post-hit health is estimated; against one this client owns, it has. if (hit == null || player == null || __instance == player || attacker != player || __instance.IsPlayer() || __instance.IsTamed() - || __instance.GetHealth() - hit.GetTotalDamage() > 0f) { + || !MagicEffectsHelper.IsLethalHit(__instance, hit)) { return; } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/Trailblazer.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/Trailblazer.cs index 90ab4a3ec..ca0590a7f 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/Trailblazer.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/Trailblazer.cs @@ -243,7 +243,7 @@ private void Update() { hit.m_damage.m_fire = _tickDamage; hit.m_hitType = HitData.HitType.Burning; hit.SetAttacker(_owner); - character.Damage(hit); + HitSource.DealBonusDamage(character, hit); } } } diff --git a/EpicLoot/src/Magic/MagicItemEffects/Shards/Wager.cs b/EpicLoot/src/Magic/MagicItemEffects/Shards/Wager.cs index 39f655288..f7cb2b3c1 100644 --- a/EpicLoot/src/Magic/MagicItemEffects/Shards/Wager.cs +++ b/EpicLoot/src/Magic/MagicItemEffects/Shards/Wager.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using EpicLoot.General; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; using UnityEngine; namespace EpicLoot.MagicItemEffects.Shards { @@ -83,10 +84,9 @@ public static void OnDamageDealt(Character __instance, HitData hit) { return; } - // Predicted lethality (see StaminaOnKill): against a remote-owned victim the post-hit - // health is not visible yet, and the old plain check silently ATE the stake on every - // real kill in multiplayer. - if (__instance.GetHealth() - hit.GetTotalDamage() > 0f) { + // Against a remote-owned victim the post-hit health is not visible yet, so IsLethalHit estimates it; + // against one this client owns, the hit has already landed. + if (!MagicEffectsHelper.IsLethalHit(__instance, hit)) { return; // the bet was lost -- the stake is gone } diff --git a/EpicLoot/src/Magic/MagicItemNames.cs b/EpicLoot/src/Magic/MagicItemNames.cs index 99821f265..36aa37453 100755 --- a/EpicLoot/src/Magic/MagicItemNames.cs +++ b/EpicLoot/src/Magic/MagicItemNames.cs @@ -107,8 +107,7 @@ public static object GetPrefix(MagicItem magicItem) } var firstEffect = magicItem.Effects[0]; - var effectDef = MagicItemEffectDefinitions.Get(firstEffect.EffectType); - if (effectDef == null) + if (!MagicItemEffectDefinitions.TryGet(firstEffect.EffectType, out var effectDef)) { return null; } @@ -129,8 +128,7 @@ public static object GetSuffix(MagicItem magicItem) } var secondEffect = magicItem.Effects[1]; - var effectDef = MagicItemEffectDefinitions.Get(secondEffect.EffectType); - if (effectDef == null) + if (!MagicItemEffectDefinitions.TryGet(secondEffect.EffectType, out var effectDef)) { return null; } diff --git a/EpicLoot/src/Magic/PlayerExtensions.cs b/EpicLoot/src/Magic/PlayerExtensions.cs index 89e949c04..293e2a6b0 100644 --- a/EpicLoot/src/Magic/PlayerExtensions.cs +++ b/EpicLoot/src/Magic/PlayerExtensions.cs @@ -1,4 +1,5 @@ using EpicLoot.LegendarySystem; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; using System.Collections.Generic; using System.Linq; @@ -95,6 +96,11 @@ public static float GetTotalActiveMagicEffectValue(this Player player, string ef { totalValue -= magicItem.GetTotalEffectValue(effectType, scale, includeSocketed: true); } + else if (ignoreThisItem == null) + { + // Only non-zero while a thrown weapon's projectile is landing (see HitSource.FiringWeapon). + totalValue += MagicEffectsHelper.GetFiringWeaponAdjustment(player, effectType, scale); + } return totalValue; } diff --git a/EpicLoot/src/ShardStones/ShardSocketManager.cs b/EpicLoot/src/ShardStones/ShardSocketManager.cs index 35ff80521..e93f5578b 100644 --- a/EpicLoot/src/ShardStones/ShardSocketManager.cs +++ b/EpicLoot/src/ShardStones/ShardSocketManager.cs @@ -87,8 +87,7 @@ public static bool CanSocket(ItemDrop.ItemData equipment, ItemDrop.ItemData inpu return true; } - var def = MagicItemEffectDefinitions.Get(effect.EffectType); - if (def == null) { + if (!MagicItemEffectDefinitions.TryGet(effect.EffectType, out var def)) { reason = "$mod_epicloot_socket_invalidinput"; return false; } @@ -159,8 +158,7 @@ public static bool CanCoexist(ItemDrop.ItemData equipment, ItemDrop.ItemData inp return true; } - var def = MagicItemEffectDefinitions.Get(effect.EffectType); - if (def == null) { + if (!MagicItemEffectDefinitions.TryGet(effect.EffectType, out var def)) { reason = "$mod_epicloot_socket_invalidinput"; return false; } diff --git a/EpicLoot/src/Tempering/EnchantmentElement.cs b/EpicLoot/src/Tempering/EnchantmentElement.cs index 1ecfe67fc..5d913b431 100644 --- a/EpicLoot/src/Tempering/EnchantmentElement.cs +++ b/EpicLoot/src/Tempering/EnchantmentElement.cs @@ -39,15 +39,18 @@ public void Awake() elements.Add(this); } - public void SetEffect(MagicItemEffect effect, ItemRarity rarity) + // Takes the whole item rather than its rarity: a unique's range comes from its legendary entry when + // that declares one, and the row must show the same range the temper will actually step against. + public void SetEffect(MagicItemEffect effect, MagicItem magicItem) { + ItemRarity rarity = magicItem.Rarity; _effect = effect; _definition = MagicItemEffectDefinitions.Get(effect.EffectType); - _values = _definition.GetValuesForRarity(rarity); + _values = TemperData.GetTemperRange(magicItem, effect); string text = MagicItem.GetEffectTextGeneric(_definition, $"{effect.EffectValue}"); - canBeTempered = _values != null; + canBeTempered = TemperData.IsTemperable(magicItem, effect); label.text = text; - if (_values == null) + if (!canBeTempered) { button.interactable = false; label.color = Color.gray; diff --git a/EpicLoot/src/Tempering/TemperData.cs b/EpicLoot/src/Tempering/TemperData.cs index 3c1fa7de8..dcdca0c55 100644 --- a/EpicLoot/src/Tempering/TemperData.cs +++ b/EpicLoot/src/Tempering/TemperData.cs @@ -2,7 +2,6 @@ using System.Linq; using System.Text; using EpicLoot.Config; -using EpicLoot.LegendarySystem; using UnityEngine; namespace EpicLoot; @@ -40,28 +39,30 @@ public class TemperData private readonly int indexOfEffect; // The value range a temper of this effect works against: the legendary/mythic guaranteed-effect - // range when the item is a unique, the rarity table otherwise -- the same resolution the detailed - // tooltip uses (MagicItem.GetEffectDetailBlock). Null means the effect has no rolled value at this - // rarity (a valueless effect like Warmth) and cannot be tempered. + // range when the unique declares one, the rarity table otherwise -- the same resolution the detailed + // tooltip uses (MagicItemEffectDefinitions.GetRollRange). Stock legendaries.json declares no such + // range, so falling back to the rarity table is what keeps a unique's effects temperable at all. + // Null means the effect has no rolled value at this rarity (a valueless effect like Warmth) and + // cannot be tempered. private static MagicItemEffectDefinition.ValueDef ResolveValues(MagicItem magicItem, MagicItemEffectDefinition def) { - return string.IsNullOrEmpty(magicItem.LegendaryID) - ? def.GetValuesForRarity(magicItem.Rarity) - : UniqueLegendaryHelper.GetLegendaryEffectValues(magicItem.LegendaryID, def.Type); + return MagicItemEffectDefinitions.GetRollRange(def, magicItem.Rarity, magicItem.LegendaryID); + } + + // The range the temper panel shows and steps against for this effect on this item. Null for an + // effect with no registered definition as well: a stand-in's made-up range is nothing to temper by. + public static MagicItemEffectDefinition.ValueDef GetTemperRange(MagicItem magicItem, MagicItemEffect effect) + { + return MagicItemEffectDefinitions.GetRollRange(magicItem, effect.EffectType); } // Whether the effect can be tempered at all: it needs a value range and a positive increment to // step by. The enchantment list uses this to leave untemperable effects out entirely. public static bool IsTemperable(MagicItem magicItem, MagicItemEffect effect) { - MagicItemEffectDefinition def = MagicItemEffectDefinitions.Get(effect.EffectType); - if (def == null) - { - return false; - } - - MagicItemEffectDefinition.ValueDef values = ResolveValues(magicItem, def); + // An effect with no registered definition has no real range to temper against. + MagicItemEffectDefinition.ValueDef values = GetTemperRange(magicItem, effect); return values != null && values.Increment > 0f && values.MaxValue > 0f; } @@ -71,8 +72,9 @@ public TemperData(ItemDrop.ItemData itemData, string effectType) magicItem = itemData.GetMagicItem(); selectedEffect = magicItem.GetEffects(effectType)[0]; + // Get() for display (never null); the range itself comes only from a registered definition. selectedDefinition = MagicItemEffectDefinitions.Get(effectType); - selectedValues = ResolveValues(magicItem, selectedDefinition); + selectedValues = GetTemperRange(magicItem, selectedEffect); rarityColor = EpicLoot.GetRarityColor(magicItem.Rarity); // The panel filters untemperable effects out via IsTemperable, so this only trips if a config @@ -187,8 +189,10 @@ public void OnFail() if (effect.EffectType == effectToReduce) { - MagicItemEffectDefinition def = MagicItemEffectDefinitions.Get(effect.EffectType); - MagicItemEffectDefinition.ValueDef values = def != null ? ResolveValues(magicItem, def) : null; + MagicItemEffectDefinition.ValueDef values = + MagicItemEffectDefinitions.TryGet(effect.EffectType, out MagicItemEffectDefinition def) + ? ResolveValues(magicItem, def) + : null; // SelectWeightedEffect only offers effects with a value range, but guard anyway -- a // failed lookup must degrade to "nothing reduced", never throw mid-temper. if (values != null) @@ -197,9 +201,10 @@ public void OnFail() _tempValue = newValue.EffectValue; _tempIncrement = values.Increment; // A failure can never push an effect below the worst legitimate roll for the - // rarity. An effect already at (or somehow below) MinValue just stays put. - newValue.EffectValue = Mathf.Max(values.MinValue, - newValue.EffectValue - values.Increment); + // rarity. An effect already at (or somehow below) MinValue just stays put: the + // outer Min stops the floor from raising it. + newValue.EffectValue = Mathf.Min(newValue.EffectValue, + Mathf.Max(values.MinValue, newValue.EffectValue - values.Increment)); _tempUpdatedValue = newValue.EffectValue; _tempEffect = newValue; } @@ -227,8 +232,7 @@ private string SelectWeightedEffect(List effects) for (int i = 0; i < effects.Count; ++i) { MagicItemEffect effect = effects[i]; - MagicItemEffectDefinition def = MagicItemEffectDefinitions.Get(effect.EffectType); - MagicItemEffectDefinition.ValueDef values = def != null ? ResolveValues(magicItem, def) : null; + MagicItemEffectDefinition.ValueDef values = GetTemperRange(magicItem, effect); if (values == null || values.MaxValue <= 0f) continue; float num = effect.EffectValue / values.MaxValue; candidates.Add(effect); diff --git a/EpicLoot/src/Tempering/TemperPanel.cs b/EpicLoot/src/Tempering/TemperPanel.cs index 2b9499d93..588a468d2 100644 --- a/EpicLoot/src/Tempering/TemperPanel.cs +++ b/EpicLoot/src/Tempering/TemperPanel.cs @@ -426,10 +426,11 @@ private bool HaveRequirements(Player player) { } private bool ConsumeRequirements(Player player) { - if (!selectedItemElement) { - return false; - } - if (!TemperMan.IsTemperableRarity(selectedItemElement._magicItem.Rarity)) { + // Re-checked here, at completion, not just when the button was enabled: vanilla + // ConsumeResources quietly takes only what is present, so materials spent (or sold to the + // trader) during the countdown used to buy a temper at a discount. HaveRequirements also + // carries the rarity check and the no-cost cheat. + if (!HaveRequirements(player)) { return false; } if (player.NoCostCheat()) { @@ -472,6 +473,10 @@ public void FillItemList(Player player) { element.SetItem(item); element.index = i; element.button.onClick.AddListener(() => { + // A temper in progress acts on the selection it started with. + if (IsTempering()) { + return; + } SetSelectedItem(element); }); } @@ -555,12 +560,16 @@ public void FillEnchantmentList(string selectedEffect = null) { instance.SetActive(true); if (instance.TryGetComponent(out EnchantmentElement element)) { element.selected.color = EpicLoot.GetRarityColorARGB(selectedItemElement._magicItem.Rarity); - element.SetEffect(effect, selectedItemElement._magicItem.Rarity); + element.SetEffect(effect, selectedItemElement._magicItem); // List position, NOT the effect index: gamepad navigation walks // EnchantmentElement.elements by this value, and skipping untemperable effects // above desyncs the two (the up-branch then indexed past the end of the list). element.index = listIndex++; element.button.onClick.AddListener(() => { + // A temper in progress acts on the selection it started with. + if (IsTempering()) { + return; + } SetSelectedEnchantment(element); }); if (!string.IsNullOrEmpty(selectedEffect) && effect.EffectType == selectedEffect) { @@ -614,8 +623,17 @@ public void FillRequirementList() { public void UpdateTemperButton() { temperButton.interactable = CanTemper(); } + // True while the countdown runs. Selection input is ignored for that long -- the temper completes + // against whatever is selected when it ends, so re-selecting mid-way used to temper a different + // item or effect than the one started (and paid for). The guard sits on the input paths, not in + // SetSelectedItem/SetSelectedEnchantment: OnTemper calls those itself while the panel is up. + public bool IsTempering() => progressPanel != null && progressPanel.activeSelf; + public void OnStartTemper() { - if (!Player.m_localPlayer || temperData == null) { + // The button's interactable state is only refreshed on selection changes, so it can be + // stale; check again rather than trusting it. + if (IsTempering() || !CanTemper()) { + UpdateTemperButton(); return; } progressBar.SetValue(0f); @@ -645,6 +663,10 @@ public void OnCancel() { progressPanel.SetActive(false); progressTimer = -1f; temperButton.gameObject.SetActive(true); + StopStartEffects(); + } + + private void StopStartEffects() { if (startEffects != null) { for (int i = 0; i < startEffects.Length; ++i) { GameObject effect = startEffects[i]; @@ -660,8 +682,18 @@ public void OnTemper() { if (!Player.m_localPlayer || temperData == null) { return; } - if (!ConsumeRequirements(Player.m_localPlayer)) { - EpicLoot.LogWarning("Tried to temper, but failed to consume requirements"); + // The item may have left the inventory during the countdown (sold, dropped); a temper would + // only charge for an item the player no longer has. + if (!selectedItemElement || !Player.m_localPlayer.GetInventory().ContainsItem(selectedItemElement._item) || + !ConsumeRequirements(Player.m_localPlayer)) { + EpicLoot.LogWarning("Tried to temper, but the item or the requirements were no longer there"); + // Treated like a cancel: nothing was taken, the item is untouched and the start effects + // stop. UpdateProgress puts the temper button back once this returns. + StopStartEffects(); + Player.m_localPlayer.Message(MessageHud.MessageType.Center, "$msg_missingrequirement"); + FillRequirementList(); + UpdateCurrencies(); + UpdateTemperButton(); return; } // The outcome is decided here, at execution -- not at selection -- so re-selecting an @@ -680,6 +712,10 @@ public void OnTemper() { Player.m_localPlayer.GetInventory().RemoveOneItem(itemToRemove); ItemElement.elements.Remove(selectedItemElement); Destroy(selectedItemElement.gameObject); + // Gamepad up/down steps by index; without closing the gap it skipped an entry. + for (int i = 0; i < ItemElement.elements.Count; ++i) { + ItemElement.elements[i].index = i; + } SetSelectedItem(null); UpdateLog(Localization.instance.Localize($"{itemToRemove.GetDisplayName()} destroyed")); temperDoneEffects.Create(Player.m_localPlayer.transform.position, Quaternion.identity); @@ -728,6 +764,17 @@ public void UpdateGamepadInput() { bool didTakeGamepadInput = takeGamepadInput; gamepadPanelHints.SetActive(true); + + // Everything below except the tooltip scrolling moves or clears the selection, and a temper + // in progress completes against the selection it started with. The progress panel's + // cancel button still answers. + if (IsTempering()) { + if (takeGamepadInput) { + UpdateGamepadTooltipScroll(); + } + return; + } + if (!takeGamepadInput && ZInput.GetButtonDown("JoyTabRight")) { selectedFrame.enabled = true; takeGamepadInput = true; @@ -843,21 +890,7 @@ public void UpdateGamepadInput() { } } - if (ZInput.GetButton("JoyRStickUp")) { - if (ZInput.GetButton("JoyRTrigger")) { - infoTooltip._scrollbar.value = Mathf.Clamp01(infoTooltip._scrollbar.value + 0.1f); - } else { - logTooltip._scrollbar.value = Mathf.Clamp01(logTooltip._scrollbar.value + 0.1f); - } - } - - if (ZInput.GetButton("JoyRStickDown")) { - if (ZInput.GetButton("JoyRTrigger")) { - infoTooltip._scrollbar.value = Mathf.Clamp01(infoTooltip._scrollbar.value - 0.1f); - } else { - logTooltip._scrollbar.value = Mathf.Clamp01(logTooltip._scrollbar.value - 0.1f); - } - } + UpdateGamepadTooltipScroll(); } else { if (!isFirstChild || selectedItemElement) { transform.SetAsFirstSibling(); @@ -868,6 +901,24 @@ public void UpdateGamepadInput() { } } + private void UpdateGamepadTooltipScroll() { + if (ZInput.GetButton("JoyRStickUp")) { + if (ZInput.GetButton("JoyRTrigger")) { + infoTooltip._scrollbar.value = Mathf.Clamp01(infoTooltip._scrollbar.value + 0.1f); + } else { + logTooltip._scrollbar.value = Mathf.Clamp01(logTooltip._scrollbar.value + 0.1f); + } + } + + if (ZInput.GetButton("JoyRStickDown")) { + if (ZInput.GetButton("JoyRTrigger")) { + infoTooltip._scrollbar.value = Mathf.Clamp01(infoTooltip._scrollbar.value - 0.1f); + } else { + logTooltip._scrollbar.value = Mathf.Clamp01(logTooltip._scrollbar.value - 0.1f); + } + } + } + [HarmonyPatch(typeof(StoreGui), nameof(StoreGui.UpdateRecipeGamepadInput))] private static class StoreGui_UpdateRecipeGamepadInput_Patch { private static bool Prefix() { diff --git a/EpicLoot/src/Terminal/Commands/MagicItem.cs b/EpicLoot/src/Terminal/Commands/MagicItem.cs index de18e7692..5afba77d2 100644 --- a/EpicLoot/src/Terminal/Commands/MagicItem.cs +++ b/EpicLoot/src/Terminal/Commands/MagicItem.cs @@ -76,8 +76,7 @@ private static List GetSpawnMagicItemOptions(string[] args) private static List GetValidMagicItemNamesWithRequirements(string effectType) { List result = []; - var definition = MagicItemEffectDefinitions.Get(effectType); - if (definition == null) + if (!MagicItemEffectDefinitions.TryGet(effectType, out var definition)) { return result; } @@ -116,8 +115,7 @@ private static void SpawnMagicItemWithEffect(Terminal.ConsoleEventArgs args) string itemPrefabNameArg = args.GetString(2); args.Context.PrintInfo($"magicitem - {itemPrefabNameArg} with effect: {effectArg}"); - MagicItemEffectDefinition magicItemEffectDef = MagicItemEffectDefinitions.Get(effectArg); - if (magicItemEffectDef == null) + if (!MagicItemEffectDefinitions.TryGet(effectArg, out MagicItemEffectDefinition magicItemEffectDef)) { args.Context.PrintWarning($"> Could not find effect: {effectArg}"); return; diff --git a/EpicLoot/src/Tooltip/MagicTooltipShard.cs b/EpicLoot/src/Tooltip/MagicTooltipShard.cs index 2512e64af..e5f8b1759 100644 --- a/EpicLoot/src/Tooltip/MagicTooltipShard.cs +++ b/EpicLoot/src/Tooltip/MagicTooltipShard.cs @@ -41,8 +41,7 @@ private void AddShardPreview() { if (def.UniformEffect.ValuesPerRarity.TryGetValue(rarity, out var uniformValue)) { - var uniformDef = MagicItemEffectDefinitions.Get(def.UniformEffect.EffectType); - if (uniformDef != null) + if (MagicItemEffectDefinitions.TryGet(def.UniformEffect.EffectType, out var uniformDef)) { var allSlots = Localization.instance.Localize("$mod_epicloot_shard_allslots"); var uniformText = MagicItem.GetEffectText(uniformDef, uniformValue); @@ -69,8 +68,7 @@ private void AddShardPreview() continue; } - var effectMagicDef = MagicItemEffectDefinitions.Get(effectDef.EffectType); - if (effectMagicDef == null) + if (!MagicItemEffectDefinitions.TryGet(effectDef.EffectType, out var effectMagicDef)) { continue; } diff --git a/EpicLoot/src/Tooltip/MagicTooltip_Helpers.cs b/EpicLoot/src/Tooltip/MagicTooltip_Helpers.cs index 9f6ce7201..26eb58d40 100644 --- a/EpicLoot/src/Tooltip/MagicTooltip_Helpers.cs +++ b/EpicLoot/src/Tooltip/MagicTooltip_Helpers.cs @@ -101,12 +101,24 @@ public static float GetBlockPowerValue(ItemDrop.ItemData item, MagicItem magicIt } /// - /// Adds the magic effect bonus and the m_timedBlockBonus (a multiplier to base block power) + /// How much stronger a parry is than a normal block with this item. Vanilla multiplies the parry's block + /// power by m_timedBlockBonus, and ModifyParry raises the base block power a parry starts from + /// (ModifyBlock.GetBlockPowerValue), so the two multiply -- adding them understated the bonus. /// public static float GetParryBonusValue(ItemDrop.ItemData item, MagicItem magicItem, int quality, out bool hasModifiers) { hasModifiers = magicItem.HasEffect(MagicEffectType.ModifyParry) || magicItem.HasEffect(MagicEffectType.ModifyParryLowHealth); - return item.m_shared.m_timedBlockBonus + ModifyBlock.GetMultiplier(null, MagicEffectType.ModifyParry, item); + + // The same base block power vanilla's GetBaseBlockPower returns, before EpicLoot's postfixes. + float baseBlock = item.m_shared.m_blockPower + Mathf.Max(0, quality - 1) * item.m_shared.m_blockPowerPerLevel; + Player player = ModifyBlock.RunModifyBlockPatchs(item) ? Player.m_localPlayer : null; + float normalBlock = ModifyBlock.GetBlockPowerValue(player, item, baseBlock, false); + if (normalBlock <= 0f) + { + return item.m_shared.m_timedBlockBonus; + } + + return item.m_shared.m_timedBlockBonus * ModifyBlock.GetBlockPowerValue(player, item, baseBlock, true) / normalBlock; } } \ No newline at end of file diff --git a/EpicLoot/src/UI/Enchanting/EnchantingTableUIPanelBase.cs b/EpicLoot/src/UI/Enchanting/EnchantingTableUIPanelBase.cs index 2f4b3025a..33d19b0f0 100644 --- a/EpicLoot/src/UI/Enchanting/EnchantingTableUIPanelBase.cs +++ b/EpicLoot/src/UI/Enchanting/EnchantingTableUIPanelBase.cs @@ -284,7 +284,9 @@ public virtual void Unlock() EnchantingTableUI.instance.UnlockTabs(); } - protected static bool LocalPlayerCanAffordCost(List cost) + // Internal as well as protected: EnchantingUIController re-checks the identify cost with it after + // rolling, right before charging. + protected internal static bool LocalPlayerCanAffordCost(List cost) { if (Player.m_localPlayer.NoCostCheat()) { diff --git a/EpicLoot/src/UI/Enchanting/InventoryManagement.cs b/EpicLoot/src/UI/Enchanting/InventoryManagement.cs index b9aa29059..0cb2c3dcc 100644 --- a/EpicLoot/src/UI/Enchanting/InventoryManagement.cs +++ b/EpicLoot/src/UI/Enchanting/InventoryManagement.cs @@ -172,8 +172,16 @@ private void DropItem(ItemDrop.ItemData item) // Both removal paths spend the player's own inventory first and only charge the shortfall to // external providers, matching how the read paths above report availability. - public void RemoveExactItem(ItemDrop.ItemData item, int amount) + // Returns how many were actually removed, which is less than amount when the item (or part of its + // stack) was no longer anywhere the table can reach. Callers that hand something out in exchange + // must pay out for that number, not for what they asked for. + public int RemoveExactItem(ItemDrop.ItemData item, int amount) { + if (item == null || amount <= 0) + { + return 0; + } + Inventory inventory = GetInventory(); int taken = 0; @@ -187,8 +195,10 @@ public void RemoveExactItem(ItemDrop.ItemData item, int amount) int shortfall = amount - taken; if (shortfall > 0) { - API.RemoveExactProviderItem(item, shortfall); + taken += API.RemoveExactProviderItem(item, shortfall); } + + return taken; } public void RemoveItem(ItemDrop.ItemData item) diff --git a/EpicLoot/src/UI/Enchanting/RuneUI.cs b/EpicLoot/src/UI/Enchanting/RuneUI.cs index 58a2cda77..3b78d10d4 100644 --- a/EpicLoot/src/UI/Enchanting/RuneUI.cs +++ b/EpicLoot/src/UI/Enchanting/RuneUI.cs @@ -51,6 +51,9 @@ private class EnchantmentRow public Toggle Toggle; public GameObject FocusGlow; public RowHover Hover; + // False for an effect the rune tab may not touch (CanBeRunified off, or no definition), so + // Unlock knows to leave that row disabled. + public bool Selectable; } private class RowHover : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler @@ -429,13 +432,19 @@ private void RefreshSelectableEnchantments() enchantmentElement.text = effect.Item1; } + // Dimming the row was all that marked an effect with CanBeRunified off, and it could + // still be selected, extracted and overwritten. Same treatment as the augment tab. + bool selectable = effect.Item2; + enchantmentbutton.interactable = selectable && !_locked; + enchantmentListElement.SetActive(true); _enchantmentRows.Add(new EnchantmentRow { Toggle = enchantmentbutton, FocusGlow = CreateRowFocusGlow(enchantmentListElement.transform), - Hover = enchantmentListElement.AddComponent() + Hover = enchantmentListElement.AddComponent(), + Selectable = selectable }); } @@ -575,80 +584,182 @@ protected override void DoMainAction() float powerModifier = GetPowerModifier(featureValues.Item2); ItemDrop.ItemData item = selectedItem.Item1.GetItem(); - if (_runeAction == RuneAction.Extract) + // Everything below acts on the selection as it stands when the countdown ends, so check it + // still describes an item the player holds and an effect the rune tab may touch (and the + // one the cost was shown for). + if (item != _selectedItem || !InventoryManagement.Instance.GetAllItems().Contains(item) || + !EnchantingUIController.CanRunifyEffect(item.GetMagicItem(), _selectedEnchantmentIndex)) + { + AbortMainAction("the selected item or enchantment is no longer valid"); + return; + } + + bool completed = _runeAction == RuneAction.Extract + ? ExtractSelectedEnchantment(item, costReduction, powerModifier) + : _runeAction == RuneAction.Etch && EtchSelectedRune(item, costReduction); + if (!completed) + { + return; + } + + DeselectAll(); + + RefreshAvailableItems(); + _selectedEnchantmentIndex = -1; + CostList.SetItems(new List()); + AvailableRunes.SetItems(new List()); + } + + private bool ExtractSelectedEnchantment(ItemDrop.ItemData item, float costReduction, float powerModifier) + { + List cost = EnchantingUIController.GetRuneExtractCost(item, _selectedRarity, costReduction); + ItemDrop.ItemData RuneWithEnchant = EnchantingUIController.BuildEnchantedRune(item, _selectedEnchantmentIndex, powerModifier); + + if (RuneWithEnchant == null) + { + AbortMainAction("the rune could not be built"); + return false; + } + + Player player = Player.m_localPlayer; + bool noCost = player.NoCostCheat(); + if (!noCost && !LocalPlayerCanAffordCost(cost)) { - List cost = EnchantingUIController.GetRuneExtractCost(item, _selectedRarity, costReduction); - ItemDrop.ItemData RuneWithEnchant = EnchantingUIController.BuildEnchantedRune(item, _selectedEnchantmentIndex, powerModifier); + AbortMainAction("the cost can no longer be paid", missingRequirements: true); + return false; + } - if (RuneWithEnchant == null) + RuneExtractMode mode = EnchantingUIController.GetRuneExtractMode(); + List reclaimedSockets = null; + if (mode == RuneExtractMode.DestroyItem) + { + // Socketed stones are the player's property: the non-Locked ones are handed back once + // the item is gone, the same policy disenchanting uses. Read before it goes. + if (item.IsMagic(out MagicItem extractedMagicItem) && extractedMagicItem.Sockets.Count > 0) { - return; + reclaimedSockets = EnchantingUIController.ReclaimSockets(extractedMagicItem); } - Player player = Player.m_localPlayer; - if (!player.NoCostCheat()) + // Vanilla never auto-unequips a removed item: destroying an equipped piece (listed when + // ShowEquippedAndHotbarItemsInSacrificeTab is on) would leave its stats and visuals. + if (player.IsItemEquiped(item)) { - if (!LocalPlayerCanAffordCost(cost)) - { - return; - } - - foreach (InventoryItemListElement costElement in cost) - { - InventoryManagement.Instance.RemoveItem(costElement.GetItem()); - } + player.UnequipItem(item, false); } - // Apply the configured effect to the source item. The rune was already built above. - switch (EnchantingUIController.GetRuneExtractMode()) + // Taken before anything is charged or handed out, so an item that could not be + // removed costs nothing and yields nothing. + if (InventoryManagement.Instance.RemoveExactItem(item, 1) < 1) { - case RuneExtractMode.KeepItem: - // Item returned untouched. - break; - case RuneExtractMode.ReduceEnchants: - EnchantingUIController.ReduceItemAfterRuneExtract(item, _selectedEnchantmentIndex, reduceRarity: false); - break; - case RuneExtractMode.ReduceEnchantsAndRarity: - EnchantingUIController.ReduceItemAfterRuneExtract(item, _selectedEnchantmentIndex, reduceRarity: true); - break; - case RuneExtractMode.DestroyItem: - // Socketed stones are the player's property: hand the non-Locked ones back - // before the item is destroyed, the same policy disenchanting uses. - if (item.IsMagic(out MagicItem extractedMagicItem) && extractedMagicItem.Sockets.Count > 0) - { - GiveItemsToPlayer(EnchantingUIController.ReclaimSockets(extractedMagicItem)); - } - InventoryManagement.Instance.RemoveExactItem(item, 1); - break; + AbortMainAction("the item could not be removed"); + return false; } + } - InventoryManagement.Instance.GiveItem(RuneWithEnchant); - CostList.SetItems(new List()); + if (!noCost) + { + foreach (InventoryItemListElement costElement in cost) + { + InventoryManagement.Instance.RemoveItem(costElement.GetItem()); + } } - else if (_runeAction == RuneAction.Etch) + + // Apply the configured effect to the source item. The rune was already built above. + switch (mode) + { + case RuneExtractMode.KeepItem: + case RuneExtractMode.DestroyItem: + // Item returned untouched, or already removed above. + break; + case RuneExtractMode.ReduceEnchants: + EnchantingUIController.ReduceItemAfterRuneExtract(item, _selectedEnchantmentIndex, reduceRarity: false); + break; + case RuneExtractMode.ReduceEnchantsAndRarity: + EnchantingUIController.ReduceItemAfterRuneExtract(item, _selectedEnchantmentIndex, reduceRarity: true); + break; + } + + if (reclaimedSockets != null) { - // Modify an existing item and destroy the selected Rune - ItemDrop.ItemData rune = AvailableRunes.GetSingleSelectedItem().Item1.GetItem(); - ItemDrop.ItemData itemToEtch = selectedItem?.Item1.GetItem(); + GiveItemsToPlayer(reclaimedSockets); + } - if (_successDialog != null) + InventoryManagement.Instance.GiveItem(RuneWithEnchant); + return true; + } + + // Modifies the existing item and consumes the selected rune. The etch is worked out on a copy + // first; the rune and then the cost are taken, and only then is the result written to the item, + // so a failure at any step leaves the item, the rune and the materials where they were. + private bool EtchSelectedRune(ItemDrop.ItemData item, float costReduction) + { + ItemDrop.ItemData rune = AvailableRunes.GetSingleSelectedItem()?.Item1.GetItem(); + string targetEffect = EnchantingUIController.GetSelectedEnchantmentNameByIndex(item, _selectedEnchantmentIndex); + if (rune == null || rune == item || !InventoryManagement.Instance.GetAllItems().Contains(rune) || + !EnchantingUIController.GetApplyableRunesforItem(item, targetEffect).Any(x => x.GetItem() == rune)) + { + AbortMainAction("the selected rune is no longer available for this enchantment"); + return false; + } + + // The same cost CheckIfActionDoable showed and gated the button on. + List cost = EnchantingUIController.GetRuneEtchCost(item, _selectedRarity, costReduction); + bool noCost = Player.m_localPlayer.NoCostCheat(); + if (!noCost && !LocalPlayerCanAffordCost(cost)) + { + AbortMainAction("the cost can no longer be paid", missingRequirements: true); + return false; + } + + MagicItem etched = EnchantingUIController.BuildRuneEtchResult(item, rune, _selectedEnchantmentIndex); + if (etched == null) + { + AbortMainAction("the etch could not be applied"); + return false; + } + + // The rune first: the cost is only taken once the rune is actually gone. + if (InventoryManagement.Instance.RemoveExactItem(rune, 1) < 1) + { + AbortMainAction("the rune could not be removed"); + return false; + } + + if (!noCost) + { + foreach (InventoryItemListElement costElement in cost) { - Destroy(_successDialog); + InventoryManagement.Instance.RemoveItem(costElement.GetItem()); } + } - _successDialog = EnchantingUIController.RuneEnhanceItemAndReturnSuccess(itemToEtch, rune, _selectedEnchantmentIndex); - _successDialog.SetActive(true); - // Remove the rune from the inventory - InventoryManagement.Instance.RemoveExactItem(rune, 1); - CostList.SetItems(new List()); + EnchantingUIController.ApplyRuneEtch(item, etched); + + if (_successDialog != null) + { + Destroy(_successDialog); } - DeselectAll(); + _successDialog = EnchantingUIController.ShowRuneEtchSuccessDialog(item); + _successDialog.SetActive(true); + return true; + } + // A main action that could not go ahead: nothing was taken or changed. The panel is already + // unlocked (DoMainAction cancels first); rebuild the lists so they show what is really there + // now, and let the button state follow the fresh selection. + private void AbortMainAction(string reason, bool missingRequirements = false) + { + Debug.LogWarning($"[Rune] {(_runeAction == RuneAction.Etch ? "Etch" : "Extract")} cancelled: {reason}."); + if (missingRequirements) + { + Player.m_localPlayer?.Message(MessageHud.MessageType.Center, "$msg_missingrequirement"); + } RefreshAvailableItems(); _selectedEnchantmentIndex = -1; CostList.SetItems(new List()); AvailableRunes.SetItems(new List()); + CheckIfActionDoable(); } // base.Cancel restores _defaultButtonLabelText, which Awake captured from the prefab's shipped @@ -737,7 +848,8 @@ private void CheckIfActionDoable() { bool state = true; - if (_selectedItem == null || _selectedEnchantmentIndex == -1) + if (_selectedItem == null || _selectedEnchantmentIndex == -1 || + !EnchantingUIController.CanRunifyEffect(_selectedItem.GetMagicItem(), _selectedEnchantmentIndex)) { state = false; MainButton.interactable = false; @@ -804,6 +916,16 @@ public override void Lock() RuneExtractButton.interactable = false; RuneEtchButton.interactable = false; MainButton.interactable = false; + + // The countdown acts on _selectedEnchantmentIndex when it ends; changing the row mid-way + // used to etch or extract a different effect from the one the cost was shown for. + foreach (EnchantmentRow row in _enchantmentRows) + { + if (row.Toggle != null) + { + row.Toggle.interactable = false; + } + } } public override void Unlock() @@ -812,6 +934,14 @@ public override void Unlock() RuneExtractButton.interactable = true; RuneEtchButton.interactable = true; + + foreach (EnchantmentRow row in _enchantmentRows) + { + if (row.Toggle != null) + { + row.Toggle.interactable = row.Selectable; + } + } } public override void DeselectAll() diff --git a/EpicLoot/src/UI/Enchanting/SacrificeUI.cs b/EpicLoot/src/UI/Enchanting/SacrificeUI.cs index 9de23ecf4..ac586f839 100644 --- a/EpicLoot/src/UI/Enchanting/SacrificeUI.cs +++ b/EpicLoot/src/UI/Enchanting/SacrificeUI.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using EpicLoot; +using EpicLoot.Crafting; using EpicLoot.CraftingV2; using JetBrains.Annotations; using UnityEngine; @@ -194,20 +195,18 @@ private void IdentifyItems() float powerModifier = GetPowerModifier(featureValues.Item2); List cost = EnchantingUIController.GetIdentifyCostForCategory(filterType, unidentifiedItems, costReduction); + // Cancel on every way out: returning without it left the button reading "Cancel". if (!LocalPlayerCanAffordCost(cost)) { + Player.m_localPlayer.Message(MessageHud.MessageType.Center, "$msg_missingrequirement"); + Cancel(); + RefreshAvailableItems(); return; } - if (!Player.m_localPlayer.NoCostCheat()) - { - foreach (InventoryItemListElement costElement in cost) - { - InventoryManagement.Instance.RemoveItem(costElement.GetItem()); - } - } - - List identifiedItems = EnchantingUIController.LootRollSelectedItems(filterType, unidentifiedItems, powerModifier); + // Rolls, re-checks the cost, charges it, removes the inputs and hands the results over -- + // or, if any stack cannot be identified in full, does none of that. + EnchantingUIController.LootRollSelectedItems(filterType, unidentifiedItems, costReduction, powerModifier); Cancel(); RefreshAvailableItems(); @@ -227,16 +226,61 @@ private float GetPowerModifier(float value) private void SacrificeItems() { List> selectedItems = AvailableItems.GetSelectedItems(); - List sacrificeProducts = EnchantingUIController.GetSacrificeProducts(selectedItems - .Select(x => new Tuple(x.Item1.GetItem(), x.Item2)).ToList()); Cancel(); + // Remove first, pay out after: the products and the returned socket stones follow from what + // was actually taken, so an item that is no longer there yields nothing. + Player player = Player.m_localPlayer; + List> sacrificedItems = new List>(); + List reclaimedSockets = new List(); + foreach (Tuple selectedItem in selectedItems) + { + ItemDrop.ItemData sacrificed = selectedItem.Item1.GetItem(); + int amount = selectedItem.Item2; + // Re-asked now rather than trusted from the list: an external filter (API + // RegisterSacrificeFilter) may have started vetoing the item during the countdown. + if (sacrificed == null || amount <= 0 || EnchantCostsHelper.GetSacrificeProducts(sacrificed) == null) + { + continue; + } + + // Socketed stones are the player's property, not part of the sacrifice yield: the + // non-Locked ones go back once the item is gone, the same policy disenchanting uses. + // Kept apart from the products so the double-yield bonus never doubles them. + List sockets = + sacrificed.IsMagic(out MagicItem sacrificedMagicItem) && sacrificedMagicItem.Sockets.Count > 0 + ? EnchantingUIController.ReclaimSockets(sacrificedMagicItem) + : null; + + // Listed while equipped when ShowEquippedAndHotbarItemsInSacrificeTab is on, and vanilla + // never auto-unequips a removed item (ghost stats and visuals). + if (player != null && player.IsItemEquiped(sacrificed)) + { + player.UnequipItem(sacrificed, false); + } + + int removed = InventoryManagement.Instance.RemoveExactItem(sacrificed, amount); + if (removed <= 0) + { + Debug.LogWarning($"[Sacrifice] {sacrificed.m_shared.m_name} could not be removed; skipped."); + continue; + } + + sacrificedItems.Add(new Tuple(sacrificed, removed)); + if (sockets != null) + { + reclaimedSockets.AddRange(sockets); + } + } + + List sacrificeProducts = EnchantingUIController.GetSacrificeProducts(sacrificedItems); + Tuple chanceToDoubleEntry = EnchantingTableUI.instance.SourceTable.GetFeatureCurrentValue(EnchantingFeature.Sacrifice); float chanceToDouble = float.IsNaN(chanceToDoubleEntry.Item1) ? 0.0f : chanceToDoubleEntry.Item1 / 100.0f; - if (Random.Range(0.0f, 1.0f) < chanceToDouble) + if (sacrificeProducts.Count > 0 && Random.Range(0.0f, 1.0f) < chanceToDouble) { EnchantingTableUI.instance.PlayEnchantBonusSFX(); BonusPanel.Show(); @@ -247,20 +291,7 @@ private void SacrificeItems() } } - foreach (Tuple selectedItem in selectedItems) - { - // Socketed stones are the player's property, not part of the sacrifice yield: hand the - // non-Locked ones back before the item is destroyed, the same policy disenchanting uses. - // Given separately from sacrificeProducts so the double-yield bonus never doubles them. - ItemDrop.ItemData sacrificed = selectedItem.Item1.GetItem(); - if (sacrificed.IsMagic(out MagicItem sacrificedMagicItem) && sacrificedMagicItem.Sockets.Count > 0) - { - GiveItemsToPlayer(EnchantingUIController.ReclaimSockets(sacrificedMagicItem)); - } - - InventoryManagement.Instance.RemoveExactItem(sacrificed, selectedItem.Item2); - } - + GiveItemsToPlayer(reclaimedSockets); GiveItemsToPlayer(sacrificeProducts); RefreshAvailableItems(); @@ -346,7 +377,8 @@ protected override void OnSelectedItemsChanged() { string identifyFilter = IdentifyStyle.options[IdentifyStyle.value].text; List potentialIdentifyItems = - EnchantingUIController.GetPotentialItemRollsByCategory(identifyFilter, selectedItems.Select(x => x.Item1.GetItem()).ToList()); + EnchantingUIController.GetPotentialItemRollsByCategory(identifyFilter, + selectedItems.Select(x => x.Item1.GetItem()).ToList(), out bool hasIdentifyLoot); SacrificeProducts.SetItems(potentialIdentifyItems.Cast().ToList()); // Say so when progression gating will identify a selected item below its own biome. @@ -365,7 +397,10 @@ protected override void OnSelectedItemsChanged() CostList.SetItems(cost.Cast().ToList()); canAfford = LocalPlayerCanAffordIdentifyCost(cost); - if (potentialIdentifyItems.Count() == 0) + // Whether the loot lists name anything at all, not whether the preview shows anything: + // the preview hides entries gating would replace, and the roll still hands out those + // replacements. + if (!hasIdentifyLoot) { canAfford = false; } From ae07da692127e34a13bf39b62d5a0653ae17c59f Mon Sep 17 00:00:00 2001 From: MidnightsFX Date: Thu, 24 Sep 2026 09:29:26 -0700 Subject: [PATCH 20/20] 0.14.14 temp --- EpicLoot/Package/CHANGELOG.md | 7 + EpicLoot/external/SLSAPI.cs | 445 ++++++++++++++++++++--- EpicLoot/src/Adventure/BountyTarget.cs | 99 +++-- EpicLoot/src/Magic/ItemDataExtensions.cs | 6 +- 4 files changed, 473 insertions(+), 84 deletions(-) diff --git a/EpicLoot/Package/CHANGELOG.md b/EpicLoot/Package/CHANGELOG.md index b0869db24..6b0df27d3 100644 --- a/EpicLoot/Package/CHANGELOG.md +++ b/EpicLoot/Package/CHANGELOG.md @@ -1,3 +1,10 @@ +**0.14.14** +* Fix for bounty creatures losing their extra health once they were loaded again or another player's game took them over + * With StarLevelSystem the bonus is now kept on the creature, and needs StarLevelSystem 1.18.2 to apply on every player's game + * Without StarLevelSystem, a bounty creature loaded at full health no longer drops back to its normal health +* Bounty targets now scale as StarLevelSystem bosses from the moment they spawn, not only after a reload +* StarLevelSystem no longer deletes, copies or re-levels bounty targets and their minions (needs StarLevelSystem 1.18.2) + **0.14.13** * Adds a repair system for bounties/treasure which had not previous spawned, due to 1.0 save migration * Fix for rune etching being free diff --git a/EpicLoot/external/SLSAPI.cs b/EpicLoot/external/SLSAPI.cs index d73d984b6..fe4892dea 100644 --- a/EpicLoot/external/SLSAPI.cs +++ b/EpicLoot/external/SLSAPI.cs @@ -6,7 +6,7 @@ namespace StarLevelSystem { - + [PublicAPI] public static class API { @@ -35,6 +35,7 @@ public static class API private static readonly MethodInfo SetAllDamageBonus; private static readonly MethodInfo ApplyUpdatesToCreature; + private static readonly MethodInfo SetCreatureSpawnManagedMethod; private static readonly MethodInfo GetPossibleModifiersForType; private static readonly MethodInfo GetAllModifiersForCreature; @@ -42,22 +43,53 @@ public static class API private static readonly MethodInfo AddNewModifierToSLS; + // Location Reset + private static readonly MethodInfo RegisterLocationResetTargetMethod; + private static readonly MethodInfo UnregisterLocationResetTargetMethod; + private static readonly MethodInfo GetRegisteredLocationResetTargetsMethod; + private static readonly MethodInfo GetLocationResetTargetInfoMethod; + private static readonly MethodInfo IsLocationResetReadyMethod; + private static readonly MethodInfo GetLocationResetStatusMethod; + private static readonly MethodInfo IsKnownResetTargetNameMethod; + private static readonly MethodInfo GetLocationLastResetMethod; + private static readonly MethodInfo GetSecondsUntilLocationResetMethod; + private static readonly MethodInfo GetLocationResetInfoMethod; + private static readonly MethodInfo GetChunkResetInfoMethod; + private static readonly MethodInfo ResetNamedLocationMethod; + private static readonly MethodInfo ResetLocationsInRadiusMethod; + + /// + /// True when Star Level System is installed at all. This only proves the receiver TYPE + /// resolved - it says nothing about which methods that build actually has. + /// public static bool IsAvailable => APIReciever != null; - static API() - { + /// + /// True when the installed Star Level System is new enough to expose the Location Reset API. + /// Check this rather than IsAvailable before calling anything in that section: a consumer + /// shipping a newer copy of this file than the installed mod would otherwise call through a + /// null MethodInfo. + /// + public static bool SupportsLocationReset => ResetNamedLocationMethod != null; + + /// + /// True when the installed Star Level System has SetCreatureSpawnManaged. Older builds make that call return false. + /// + public static bool SupportsSpawnManaged => SetCreatureSpawnManagedMethod != null; + + static API() { APIReciever = Type.GetType("StarLevelSystem.modules.APIReciever, StarLevelSystem"); if (APIReciever == null) return; UpdateCreatureLevel = APIReciever.GetMethod("UpdateCreatureLevel", BindingFlags.Public | BindingFlags.Static); - UpdateCreatureColorization = APIReciever.GetMethod("UpdateCreatureColorization", BindingFlags.Public | BindingFlags.Static); - GetBaseAttributeValue = APIReciever.GetMethod("GetBaseAttributeValue", BindingFlags.Public | BindingFlags.Static); - UpdateCreatureBaseAttributes = APIReciever.GetMethod("UpdateCreatureBaseAttributes", BindingFlags.Public | BindingFlags.Static); - GetAllBaseAttributeValues = APIReciever.GetMethod("GetAllBaseAttributeValues", BindingFlags.Public | BindingFlags.Static); - SetAllBaseAttributeValues = APIReciever.GetMethod("SetAllBaseAttributeValues", BindingFlags.Public | BindingFlags.Static); + UpdateCreatureColorization = APIReciever.GetMethod("UpdateCreatureColorization", BindingFlags.Public | BindingFlags.Static); + GetBaseAttributeValue = APIReciever.GetMethod("GetBaseAttributeValue", BindingFlags.Public | BindingFlags.Static); + UpdateCreatureBaseAttributes = APIReciever.GetMethod("UpdateCreatureBaseAttributes", BindingFlags.Public | BindingFlags.Static); + GetAllBaseAttributeValues = APIReciever.GetMethod("GetAllBaseAttributes", BindingFlags.Public | BindingFlags.Static); + SetAllBaseAttributeValues = APIReciever.GetMethod("SetAllBaseAttributes", BindingFlags.Public | BindingFlags.Static); GetPerLevelAttributeValue = APIReciever.GetMethod("GetPerLevelAttributeValue", BindingFlags.Public | BindingFlags.Static); UpdateCreaturePerLevelAttributes = APIReciever.GetMethod("UpdateCreaturePerLevelAttributes", BindingFlags.Public | BindingFlags.Static); - GetAllPerLevelAttributeValues = APIReciever.GetMethod("GetAllPerLevelAttributeValues", BindingFlags.Public | BindingFlags.Static); - SetAllPerLevelAttributeValues = APIReciever.GetMethod("SetAllPerLevelAttributeValues", BindingFlags.Public | BindingFlags.Static); + GetAllPerLevelAttributeValues = APIReciever.GetMethod("GetAllPerLevelAttributes", BindingFlags.Public | BindingFlags.Static); + SetAllPerLevelAttributeValues = APIReciever.GetMethod("SetAllPerLevelAttributes", BindingFlags.Public | BindingFlags.Static); GetCreatureDamageRecievedModifier = APIReciever.GetMethod("GetCreatureDamageRecievedModifier", BindingFlags.Public | BindingFlags.Static); UpdateCreatureDamageRecievedModifier = APIReciever.GetMethod("UpdateCreatureDamageRecievedModifier", BindingFlags.Public | BindingFlags.Static); GetAllDamageRecievedModifiers = APIReciever.GetMethod("GetAllDamageRecievedModifiers", BindingFlags.Public | BindingFlags.Static); @@ -67,10 +99,36 @@ static API() GetAllDamageBonus = APIReciever.GetMethod("GetAllDamageBonus", BindingFlags.Public | BindingFlags.Static); SetAllDamageBonus = APIReciever.GetMethod("SetAllDamageBonus", BindingFlags.Public | BindingFlags.Static); ApplyUpdatesToCreature = APIReciever.GetMethod("ApplyUpdatesToCreature", BindingFlags.Public | BindingFlags.Static); + SetCreatureSpawnManagedMethod = APIReciever.GetMethod("SetCreatureSpawnManaged", BindingFlags.Public | BindingFlags.Static); GetPossibleModifiersForType = APIReciever.GetMethod("GetPossibleModifiersForType", BindingFlags.Public | BindingFlags.Static); GetAllModifiersForCreature = APIReciever.GetMethod("GetAllModifiersForCreature", BindingFlags.Public | BindingFlags.Static); AddModifierToCreature = APIReciever.GetMethod("AddModifierToCreature", BindingFlags.Public | BindingFlags.Static); AddNewModifierToSLS = APIReciever.GetMethod("AddNewModifierToSLS", BindingFlags.Public | BindingFlags.Static); + + RegisterLocationResetTargetMethod = APIReciever.GetMethod("RegisterLocationResetTarget", BindingFlags.Public | BindingFlags.Static); + UnregisterLocationResetTargetMethod = APIReciever.GetMethod("UnregisterLocationResetTarget", BindingFlags.Public | BindingFlags.Static); + GetRegisteredLocationResetTargetsMethod = APIReciever.GetMethod("GetRegisteredLocationResetTargets", BindingFlags.Public | BindingFlags.Static); + GetLocationResetTargetInfoMethod = APIReciever.GetMethod("GetLocationResetTargetInfo", BindingFlags.Public | BindingFlags.Static); + IsLocationResetReadyMethod = APIReciever.GetMethod("IsLocationResetReady", BindingFlags.Public | BindingFlags.Static); + GetLocationResetStatusMethod = APIReciever.GetMethod("GetLocationResetStatus", BindingFlags.Public | BindingFlags.Static); + IsKnownResetTargetNameMethod = APIReciever.GetMethod("IsKnownResetTargetName", BindingFlags.Public | BindingFlags.Static); + GetLocationLastResetMethod = APIReciever.GetMethod("GetLocationLastReset", BindingFlags.Public | BindingFlags.Static); + GetSecondsUntilLocationResetMethod = APIReciever.GetMethod("GetSecondsUntilLocationReset", BindingFlags.Public | BindingFlags.Static); + GetLocationResetInfoMethod = APIReciever.GetMethod("GetLocationResetInfo", BindingFlags.Public | BindingFlags.Static); + GetChunkResetInfoMethod = APIReciever.GetMethod("GetChunkResetInfo", BindingFlags.Public | BindingFlags.Static); + ResetNamedLocationMethod = APIReciever.GetMethod("ResetNamedLocation", BindingFlags.Public | BindingFlags.Static); + ResetLocationsInRadiusMethod = APIReciever.GetMethod("ResetLocationsInRadius", BindingFlags.Public | BindingFlags.Static); + } + + // Invoke that degrades instead of throwing. + // + // The creature methods above call Invoke straight off the field, so a consumer shipping a + // NEWER copy of this file than the installed Star Level System gets a NullReferenceException + // at their own call site rather than a usable answer. Everything added from the Location + // Reset section onward routes through here and returns `fallback` instead. + private static object Call(MethodInfo method, object fallback, params object[] args) { + if (method == null) { return fallback; } + return method.Invoke(null, args); } ///////////////////// @@ -84,8 +142,7 @@ static API() /// The creature's Character class /// The new level to set the creature to /// returns>bool success - public static bool SetCreatureLevel(Character creatureId, int newLevel) - { + public static bool SetCreatureLevel(Character creatureId, int newLevel) { return (bool)UpdateCreatureLevel.Invoke(null, new object[] { creatureId, newLevel }); } @@ -103,8 +160,7 @@ public static bool SetCreatureLevel(Character creatureId, int newLevel) /// unity material saturation /// enables emission on the material, if enabled, the emissive color will be value, hue, saturation /// returns>bool success - public static bool SetCreatureColorization(Character creatureId, float value, float hue, float sat, bool emission = false) - { + public static bool SetCreatureColorization(Character creatureId, float value, float hue, float sat, bool emission = false) { return (bool)UpdateCreatureColorization.Invoke(null, new object[] { creatureId, value, hue, sat, emission }); } @@ -118,8 +174,7 @@ public static bool SetCreatureColorization(Character creatureId, float value, fl /// The creature's Character class /// The enum value of which attribute to get: BaseHealth = 0, BaseDamage = 1, AttackSpeed = 2, Speed = 3, Size = 4 /// returns>float value of the base attribute - public static float GetCreatureBaseAttribute(Character creatureId, int attribute) - { + public static float GetCreatureBaseAttribute(Character creatureId, int attribute) { return (float)GetBaseAttributeValue.Invoke(null, new object[] { creatureId, attribute }); } @@ -127,12 +182,16 @@ public static float GetCreatureBaseAttribute(Character creatureId, int attribute /// This allows setting modifiers to any of a creatures base attributes (this value is applied once, flat addition) /// this does not apply immediately and must be applied with ApplyCreatureUpdates /// + /// + /// Persisted on the creature when called by its ZDO owner, so the value survives a reload, an ownership + /// handoff and a Star Level System config reload, and every other peer sees it. A call from a peer that does + /// not own the creature changes only that peer's view. + /// /// The creature's Character class /// The enum value of which attribute to get: BaseHealth = 0, BaseDamage = 1, AttackSpeed = 2, Speed = 3, Size = 4 /// The value this attribute will be set to (overrides existing) /// returns>bool success - public static bool SetCreatureBaseAttribute(Character creatureId, int attribute, float value) - { + public static bool SetCreatureBaseAttribute(Character creatureId, int attribute, float value) { return (bool)UpdateCreatureBaseAttributes.Invoke(null, new object[] { creatureId, attribute, value }); } @@ -142,8 +201,7 @@ public static bool SetCreatureBaseAttribute(Character creatureId, int attribute, /// /// The creature's Character class /// returns>Dictionary - public static Dictionary GetAllCreatureBaseAttributes(Character creatureId) - { + public static Dictionary GetAllCreatureBaseAttributes(Character creatureId) { return (Dictionary)GetAllBaseAttributeValues.Invoke(null, new object[] { creatureId }); } @@ -152,11 +210,15 @@ public static Dictionary GetAllCreatureBaseAttributes(Character crea /// and sets their values for the creature /// this applies immediately /// + /// + /// Persisted on the creature when called by its ZDO owner, so the value survives a reload, an ownership + /// handoff and a Star Level System config reload, and every other peer sees it. A call from a peer that does + /// not own the creature changes only that peer's view. + /// /// The creature's Character class /// Dictionary of all creatures attributes /// returns>bool success - public static bool SetAllCreatureBaseAttributes(Character creatureId, Dictionary attributes) - { + public static bool SetAllCreatureBaseAttributes(Character creatureId, Dictionary attributes) { return (bool)SetAllBaseAttributeValues.Invoke(null, new object[] { creatureId, attributes }); } @@ -170,8 +232,7 @@ public static bool SetAllCreatureBaseAttributes(Character creatureId, Dictionary /// The creature's Character class /// The enum value of which attribute to get: HealthPerLevel = 0, DamagePerLevel = 1, SpeedPerLevel = 2, AttackSpeedPerLevel = 3, SizePerLevel = 4 /// returns>float value of the per level attribute - public static float GetCreaturePerLevelAttribute(Character creatureId, int attribute) - { + public static float GetCreaturePerLevelAttribute(Character creatureId, int attribute) { return (float)GetPerLevelAttributeValue.Invoke(null, new object[] { creatureId, attribute }); } @@ -179,12 +240,16 @@ public static float GetCreaturePerLevelAttribute(Character creatureId, int attri /// This allows setting modifiers to any of a creatures per level attributes (this value is applied once for every level) /// this does not apply immediately and must be applied with ApplyCreatureUpdates /// + /// + /// Persisted on the creature when called by its ZDO owner, so the value survives a reload, an ownership + /// handoff and a Star Level System config reload, and every other peer sees it. A call from a peer that does + /// not own the creature changes only that peer's view. + /// /// The creature's Character class /// The enum value of which attribute to get: HealthPerLevel = 0, DamagePerLevel = 1, SpeedPerLevel = 2, AttackSpeedPerLevel = 3, SizePerLevel = 4 /// The value this attribute will be set to (overrides existing) /// returns>bool success - public static bool SetCreaturePerLevelAttribute(Character creatureId, int attribute, float value) - { + public static bool SetCreaturePerLevelAttribute(Character creatureId, int attribute, float value) { return (bool)UpdateCreaturePerLevelAttributes.Invoke(null, new object[] { creatureId, attribute, value }); } @@ -194,8 +259,7 @@ public static bool SetCreaturePerLevelAttribute(Character creatureId, int attrib /// /// The creature's Character class /// returns>Dictionary - public static Dictionary GetAllCreaturePerLevelAttributes(Character creatureId) - { + public static Dictionary GetAllCreaturePerLevelAttributes(Character creatureId) { return (Dictionary)GetAllPerLevelAttributeValues.Invoke(null, new object[] { creatureId }); } @@ -204,11 +268,15 @@ public static Dictionary GetAllCreaturePerLevelAttributes(Character /// and sets their values for the creature /// this applies immediately /// + /// + /// Persisted on the creature when called by its ZDO owner, so the value survives a reload, an ownership + /// handoff and a Star Level System config reload, and every other peer sees it. A call from a peer that does + /// not own the creature changes only that peer's view. + /// /// The creature's Character class /// Dictionary of all creatures attributes /// returns>bool success - public static bool SetAllCreaturePerLevelAttributes(Character creatureId, Dictionary attributes) - { + public static bool SetAllCreaturePerLevelAttributes(Character creatureId, Dictionary attributes) { return (bool)SetAllPerLevelAttributeValues.Invoke(null, new object[] { creatureId, attributes }); } @@ -223,8 +291,7 @@ public static bool SetAllCreaturePerLevelAttributes(Character creatureId, Dictio /// The creature's Character class /// The enum value of which attribute to get: Blunt = 0, Slash = 1, Pierce = 2, Fire = 3, Frost = 4, Lightning = 5, Poison = 6, Spirit = 7, Chop = 8, Pickaxe = 9 /// returns>float value damage recieved modifier - public static float GetCreatureDamageReceivedModifier(Character creatureId, int damageType) - { + public static float GetCreatureDamageReceivedModifier(Character creatureId, int damageType) { return (float)GetCreatureDamageRecievedModifier.Invoke(null, new object[] { creatureId, damageType }); } @@ -233,12 +300,17 @@ public static float GetCreatureDamageReceivedModifier(Character creatureId, int /// 1.0 = 100% damage taken, 0.5 = 50% damage taken, 2.0 = 200% damage taken /// this does not apply immediately and must be applied with ApplyCreatureUpdates /// + /// + /// Persisted on the creature when called by its ZDO owner, so the value survives a reload, an ownership + /// handoff and a Star Level System config reload, and every other peer sees it. A call from a peer that does + /// not own the creature changes only that peer's view. + /// After a rebuild the persisted value is the base the creature's own modifiers (Resist*, Flame, ...) stack on. + /// /// The creature's Character class /// The enum value of which attribute to get: Blunt = 0, Slash = 1, Pierce = 2, Fire = 3, Frost = 4, Lightning = 5, Poison = 6, Spirit = 7, Chop = 8, Pickaxe = 9 /// The value this attribute will be set to (overrides existing) /// returns>bool success - public static bool SetCreatureDamageReceivedModifier(Character creatureId, int damageType, float value) - { + public static bool SetCreatureDamageReceivedModifier(Character creatureId, int damageType, float value) { return (bool)UpdateCreatureDamageRecievedModifier.Invoke(null, new object[] { creatureId, damageType, value }); } @@ -248,8 +320,7 @@ public static bool SetCreatureDamageReceivedModifier(Character creatureId, int d /// /// The creature's Character class /// returns>Dictionary - public static Dictionary GetAllCreatureDamageReceivedModifiers(Character creatureId) - { + public static Dictionary GetAllCreatureDamageReceivedModifiers(Character creatureId) { return (Dictionary)GetAllDamageRecievedModifiers.Invoke(null, new object[] { creatureId }); } @@ -257,10 +328,15 @@ public static Dictionary GetAllCreatureDamageReceivedModifiers(Chara /// Sets all of the creature damage recieved modifiers as a dictionary with the key as the enum (Blunt = 0, Slash = 1, Pierce = 2, Fire = 3, Frost = 4, Lightning = 5, Poison = 6, Spirit = 7, Chop = 8, Pickaxe = 9) /// 1.0 = 100% damage taken, 0.5 = 50% damage taken, 2.0 = 200% damage taken /// + /// + /// Persisted on the creature when called by its ZDO owner, so the value survives a reload, an ownership + /// handoff and a Star Level System config reload, and every other peer sees it. A call from a peer that does + /// not own the creature changes only that peer's view. + /// After a rebuild the persisted value is the base the creature's own modifiers (Resist*, Flame, ...) stack on. + /// /// The creature's Character class /// Dictionary of creatures damage recived modifiers - public static bool SetAllCreatureDamageReceivedModifiers(Character creatureId, Dictionary attributes) - { + public static bool SetAllCreatureDamageReceivedModifiers(Character creatureId, Dictionary attributes) { return (bool)SetAllDamageRecievedModifiers.Invoke(null, new object[] { creatureId, attributes }); } @@ -274,8 +350,7 @@ public static bool SetAllCreatureDamageReceivedModifiers(Character creatureId, D /// The creature's Character class /// The enum value of which attribute to get: Blunt = 0, Slash = 1, Pierce = 2, Fire = 3, Frost = 4, Lightning = 5, Poison = 6, Spirit = 7, Chop = 8, Pickaxe = 9 /// returns>float value of the base attribute - public static float GetCreatureFlatDamageBonus(Character creatureId, int damageType) - { + public static float GetCreatureFlatDamageBonus(Character creatureId, int damageType) { return (float)GetCreatureDamageBonus.Invoke(null, new object[] { creatureId, damageType }); } @@ -283,12 +358,17 @@ public static float GetCreatureFlatDamageBonus(Character creatureId, int damageT /// Allows setting flat damage bonus values for a creature (this value is applied once, flat addition) /// this does not apply immediately and must be applied with ApplyCreatureUpdates /// + /// + /// Persisted on the creature when called by its ZDO owner, so the value survives a reload, an ownership + /// handoff and a Star Level System config reload, and every other peer sees it. A call from a peer that does + /// not own the creature changes only that peer's view. + /// After a rebuild the persisted value is the base the creature's own modifiers (Resist*, Flame, ...) stack on. + /// /// The creature's Character class /// The enum value of which attribute to get: Blunt = 0, Slash = 1, Pierce = 2, Fire = 3, Frost = 4, Lightning = 5, Poison = 6, Spirit = 7, Chop = 8, Pickaxe = 9 /// The value this attribute will be set to (overrides existing) /// returns>bool success - public static bool SetCreatureFlatDamageBonus(Character creatureId, int damageType, float value) - { + public static bool SetCreatureFlatDamageBonus(Character creatureId, int damageType, float value) { return (bool)UpdateCreatureDamageBonus.Invoke(null, new object[] { creatureId, damageType, value }); } @@ -297,8 +377,7 @@ public static bool SetCreatureFlatDamageBonus(Character creatureId, int damageTy /// /// The creature's Character class /// returns>Dictionary - public static Dictionary GetAllCreatureFlatDamageBonuses(Character creatureId) - { + public static Dictionary GetAllCreatureFlatDamageBonuses(Character creatureId) { return (Dictionary)GetAllDamageBonus.Invoke(null, new object[] { creatureId }); } @@ -306,11 +385,16 @@ public static Dictionary GetAllCreatureFlatDamageBonuses(Character c /// Sets all of the creatures flat damage bonuses as a dictionary with the key as the enum (Blunt = 0, Slash = 1, Pierce = 2, Fire = 3, Frost = 4, Lightning = 5, Poison = 6, Spirit = 7, Chop = 8, Pickaxe = 9) /// this applies immediately /// + /// + /// Persisted on the creature when called by its ZDO owner, so the value survives a reload, an ownership + /// handoff and a Star Level System config reload, and every other peer sees it. A call from a peer that does + /// not own the creature changes only that peer's view. + /// After a rebuild the persisted value is the base the creature's own modifiers (Resist*, Flame, ...) stack on. + /// /// The creature's Character class /// Dictionary of all creatures flat damage bonuses /// returns>bool success - public static bool SetAllCreatureFlatDamageBonuses(Character creatureId, Dictionary attributes) - { + public static bool SetAllCreatureFlatDamageBonuses(Character creatureId, Dictionary attributes) { return (bool)SetAllDamageBonus.Invoke(null, new object[] { creatureId, attributes }); } @@ -324,11 +408,29 @@ public static bool SetAllCreatureFlatDamageBonuses(Character creatureId, Diction /// /// The creature's Character class /// returns>bool success - public static bool ApplyCreatureUpdates(Character creatureId) - { + public static bool ApplyCreatureUpdates(Character creatureId) { return (bool)ApplyUpdatesToCreature.Invoke(null, new object[] { creatureId }); } + //////////////////////////////////////// + /// SPAWN MANAGEMENT + //////////////////////////////////////// + + /// + /// Marks a creature your mod spawned and owns the existence and level of (a quest or bounty target, say). + /// Star Level System keeps giving it stats, modifiers and colour, but never deletes or multiplies it for + /// spawn-rate or disabled-spawn rules, and never rerolls or clamps its level. + /// Only the creature's ZDO owner can set this. Call it in the frame you spawn the creature (Star Level System's + /// own setup waits InitialDelayBeforeSetup), and again when it loads if it may have been spawned before your + /// mod made this call. + /// + /// The creature's Character class + /// True to mark the creature, false to hand it back to Star Level System's spawn rules + /// returns>bool success; false when this peer does not own the creature or Star Level System is too old (see SupportsSpawnManaged) + public static bool SetCreatureSpawnManaged(Character creatureId, bool managed = true) { + return (bool)Call(SetCreatureSpawnManagedMethod, false, creatureId, managed); + } + //////////////////////////////////////// /// Creature Modifier Management //////////////////////////////////////// @@ -339,8 +441,7 @@ public static bool ApplyCreatureUpdates(Character creatureId) /// /// The type of modifier to get (types = 0 = Major, 1 = Minro, 2 = Boss) /// returns>List of modifier names - public static List GetPossibleModifiers(int modType = 0) - { + public static List GetPossibleModifiers(int modType = 0) { return (List)GetPossibleModifiersForType.Invoke(null, new object[] { modType }); } @@ -350,8 +451,7 @@ public static List GetPossibleModifiers(int modType = 0) /// /// The creature's Character class /// returns>Dictionary of modifier names and type int - public static Dictionary GetCreaturesModifiers(Character creatureId) - { + public static Dictionary GetCreaturesModifiers(Character creatureId) { return (Dictionary)GetAllModifiersForCreature.Invoke(null, new object[] { creatureId }); } @@ -364,8 +464,7 @@ public static Dictionary GetCreaturesModifiers(Character creatureId /// The modifiers type Major (0), Minor (1), and Boss (2) /// If true applies updates to the creature to rebuild the creatures name and other stats /// returns>bool success - public static bool AddModifierToTargetCreature(Character creatureId, string modifierName, int modifierType, bool update = true) - { + public static bool AddModifierToTargetCreature(Character creatureId, string modifierName, int modifierType, bool update = true) { return (bool)AddModifierToCreature.Invoke(null, new object[] { creatureId, modifierName, modifierType, update }); } @@ -397,23 +496,251 @@ public static bool AddModifierToTargetCreature(Character creatureId, string modi public static bool AddNewModifier( int modifierID, string modifier_name, - string setupMethod = null, + Delegate setupMethod = null, float selectionWeight = 10f, float basepower = 0f, float perlevelpower = 0f, Dictionary> biomeConfig = null, int namingStyle = 2, - List name_suffixes = null, - List name_prefixes = null, + string name_suffixes = null, + string name_prefixes = null, int visualStyle = 0, Sprite starIcon = null, GameObject visualEffect = null, List allowed_creatures = null, List unallowed_creatures = null, - List allowed_biomes = null) - { + List allowed_biomes = null) { return (bool)AddNewModifierToSLS.Invoke(null, new object[] { modifierID, modifier_name, setupMethod, selectionWeight, basepower, perlevelpower, biomeConfig, namingStyle, name_suffixes, name_prefixes, visualStyle, starIcon, visualEffect, allowed_creatures, unallowed_creatures, allowed_biomes }); } + + //////////////////////////////////////// + /// LOCATION RESET + //////////////////////////////////////// + /// + /// Star Level System can restore looted locations, dungeons, ore, pickables and vegetation on + /// a schedule. This section lets your mod register its own targets for that sweep, ask for a + /// reset directly, and find out when something was last reset. + /// + /// ALL OF IT WORKS FROM A CLIENT. The work happens on the server - it owns the world objects - + /// but calling from a client relays the request and brings the answer back, so an item that + /// resets a dungeon can run its logic on whoever used it. + /// + /// That is why every method here takes a callback and returns bool rather than returning the + /// answer directly: on a client the answer arrives over the network a moment later. + /// + /// THE CALLBACK ALWAYS FIRES, EXACTLY ONCE - on success, on a deferral, on a refusal, on a + /// timeout, and when there is no server to ask. Refusals arrive as a result carrying + /// "outcome" = "refused", a human-readable "reason" and a machine-readable "refusalCode", so + /// your follow-up logic lives in the callback and nowhere else. The bool return only tells + /// you whether the work started; when it is false the reason has already been delivered. + /// + /// On the server, and for anything refused before it leaves this machine, the callback runs + /// before the call returns. + /// + /// Guard on SupportsLocationReset, not just IsAvailable. + + /// + /// Register a location or vegetation prefab as a recurring reset target. It joins Star Level + /// System's normal background sweep exactly as a configured target would, timed off the + /// location's own world data, and survives config reloads and world reloads. + /// + /// Safe to call at any point, including before a world is loaded, if you are the server. From + /// a client this relays, so it needs a live server connection - register on connect rather + /// than in your Awake if your mod is client-side. + /// + /// The server owner's LocationResetSettings.yaml always wins: if they add an entry for this + /// prefab name, their settings replace yours entirely. Protection rules (what player-built + /// content blocks a reset) are theirs alone and cannot be set from here. + /// + /// Location or vegetation prefab name, e.g. "Crypt2" + /// Your plugin GUID. Used in logs and to scope unregistration + /// Hours between resets. 0 uses the server's default interval. Minimum 0.25 + /// A 5-field cron expression instead of an interval, e.g. "0 3 * * *". Wins over resetHours + /// Whether the target is active + /// 0 = Full (clear and rebuild), 1 = TerrainOnly (undo terraforming, leave the location) + /// Also revert player terraforming around the location + /// Terrain reset radius. 0 uses the location's own exterior radius + /// Extra metres of terrain reset beyond that radius + /// Whether a dungeon interior may be regenerated. False leaves such locations alone entirely + /// Only apply beyond this distance from the world's reset centre. 0 = no limit + /// Only apply within this distance. 0 = no limit + /// Receives whether the server accepted the registration + /// bool - whether the request was dispatched + public static bool RegisterLocationReset(string prefabName, string sourceId, + float resetHours = 0f, string resetSchedule = null, bool enabled = true, int mode = 0, + bool resetTerrain = false, float terrainRadius = 0f, float extraTerrainRadius = 0f, + bool resetInterior = true, float minDistance = 0f, float maxDistance = 0f, + Action onResult = null) { + return (bool)Call(RegisterLocationResetTargetMethod, false, + prefabName, sourceId, resetHours, resetSchedule, mode, resetTerrain, terrainRadius, + extraTerrainRadius, resetInterior, minDistance, maxDistance, enabled, onResult); + } + + /// + /// Remove a target you registered. Refused if another mod owns that registration. + /// + /// bool - whether the request was dispatched + public static bool UnregisterLocationReset(string prefabName, string sourceId, Action onResult = null) { + return (bool)Call(UnregisterLocationResetTargetMethod, false, prefabName, sourceId, onResult); + } + + /// + /// Every prefab name registered through this API. Pass your plugin GUID to see only your own, + /// or null for all of them. + /// + /// bool - whether the request was dispatched + public static bool GetRegisteredLocationResets(string sourceId, Action> onResult) { + return (bool)Call(GetRegisteredLocationResetTargetsMethod, false, sourceId, onResult); + } + + /// + /// How a reset target actually resolved, whoever registered it. Keys include: name, known, + /// configured, enabled, source ("entry", "group", "api", "defaults", "none"), registeredBy, + /// hardBlocked, isLocation, isVegetation, resetSeconds, schedule, mode, resetTerrain, + /// terrainRadius, extraTerrainRadius, resetInterior. + /// + /// "source" is the one to check if your registration does not seem to be taking effect: + /// anything other than "api" means the server's config is overriding it. + /// + /// bool - whether the request was dispatched + public static bool GetLocationResetTargetInfo(string prefabName, Action> onResult) { + return (bool)Call(GetLocationResetTargetInfoMethod, false, prefabName, onResult); + } + + /// + /// Whether a reset request would currently be accepted by the server: a world is loaded and + /// no conflicting reset mod is installed. + /// + /// bool - whether the request was dispatched + public static bool IsLocationResetReady(Action onResult) { + return (bool)Call(IsLocationResetReadyMethod, false, onResult); + } + + /// + /// Location Reset state on the server. Keys include: available, isServer, ready, sweepAllowed, + /// masterSwitchEnabled, configEnabled, blockedByModConflict, resetRunning, trackedZones, + /// generatedZones, sweepFloorSeconds, apiRegistrations, and the limits applied to client + /// requests: clientMaxRadius, clientMaxDistance, clientCooldownSeconds. + /// + /// bool - whether the request was dispatched + public static bool GetLocationResetStatus(Action> onResult) { + return (bool)Call(GetLocationResetStatusMethod, false, onResult); + } + + /// + /// Whether this world has any location, vegetation entry or prefab by that name. Use it to + /// catch a prefab name that a game update renamed out from under you. + /// + /// bool - whether the request was dispatched + public static bool IsKnownLocationResetTarget(string prefabName, Action onResult) { + return (bool)Call(IsKnownResetTargetNameMethod, false, prefabName, onResult); + } + + /// + /// When the named location nearest `center` was last reset, as Unix seconds UTC. + /// The callback receives -1 if no location of that name is within `radius` (or it has nothing + /// to time from), and 0 if it exists but has never been reset. + /// + /// bool - whether the request was dispatched + public static bool GetLocationLastReset(string locationName, Vector3 center, float radius, Action onResult) { + return (bool)Call(GetLocationLastResetMethod, false, locationName, center, radius, onResult); + } + + /// + /// Seconds until the named location is next due for a reset. The callback receives 0 for due + /// now, and -1 for unknown - not found, never reset, or nothing has it configured. + /// + /// bool - whether the request was dispatched + public static bool GetSecondsUntilLocationReset(string locationName, Vector3 center, float radius, Action onResult) { + return (bool)Call(GetSecondsUntilLocationResetMethod, false, locationName, center, radius, onResult); + } + + /// + /// Full reset state for the named location nearest `center`. Keys include: found, name, + /// zoneX, zoneZ, positionX/Y/Z, distance, configured, enabled, hardBlocked, source, + /// groupName, schedule, mode, resetTerrain, resetInterior, hasProxy, lastResetUnix, + /// secondsSinceReset, secondsUntilDue, dueNow, rateMultiplier, rateDescription. + /// + /// Always check "found" first. + /// + /// bool - whether the request was dispatched + public static bool GetLocationResetInfo(string locationName, Vector3 center, float radius, + Action> onResult) { + return (bool)Call(GetLocationResetInfoMethod, false, locationName, center, radius, onResult); + } + + /// + /// Reset state for the map chunk containing `position`. Keys include: zoneX, zoneZ, centerX, + /// centerZ, biome, generated, loaded, tracked, lastExaminedUnix, secondsSinceExamined, + /// deferredUntilUnix, retryAtUnix, retryCount, rateMultiplier, rateDescription, + /// protectionBlocked, protectionReason, locationName, locationLastResetUnix, + /// locationSecondsUntilDue. + /// + /// Note that lastExaminedUnix is when the sweep last LOOKED at this chunk, which is not the + /// same as anything in it having been reset - for that, read locationLastResetUnix or use + /// GetLocationLastReset. When the chunk has been deferred (a player build is blocking it, for + /// instance) lastExaminedUnix is 0 and deferredUntilUnix carries the time it comes back up. + /// + /// Also return a "prefabs" list of per-prefab census rows + /// (name, lastResetUnix, baseline, live). This costs a full scan of the chunk + /// bool - whether the request was dispatched + public static bool GetChunkResetInfo(Vector3 position, bool includePrefabs, + Action> onResult) { + return (bool)Call(GetChunkResetInfoMethod, false, position, includePrefabs, onResult); + } + + /// + /// Reset the named location nearest `center`, within `radius`. + /// + /// Unlike the background sweep this works on a location the server has not configured for + /// resets at all - you named it, so it is reset. Locations that can never be reset (the + /// starting temple) are still refused. + /// + /// SAFETY. safety 0 (Safe, the default) waits for players to leave the affected chunks before + /// touching anything, and gives up without resetting if they do not. safety 1 (Force) resets + /// immediately, working on chunks that are loaded around a player - use this when you want a + /// location restored before somebody walks back into it. Player-built structures block a + /// reset in BOTH modes and there is no way to override that. + /// + /// Be careful with Force on a dungeon somebody is inside: interiors are rebuilt from scratch + /// and a player standing in one will fall. + /// + /// CALLING FROM A CLIENT. The server clamps the radius, refuses a position further away than + /// its configured limit, and rate-limits how often any one client may ask. Read + /// clientMaxRadius / clientMaxDistance / clientCooldownSeconds from GetLocationResetStatus to + /// size your requests. Every refusal reaches onComplete with a refusalCode - "too_far", + /// "cooldown", "no_such_location", "already_running" and so on - so a caller can retry, + /// explain, or refund without guessing why. + /// + /// Location prefab name, e.g. "Crypt2" + /// World position to search around + /// Search radius in metres + /// 0 = Safe (wait for players), 1 = Force (reset now) + /// Reset every match in range rather than just the nearest + /// How long Safe waits before giving up. 0 uses the default (300s) + /// Include a per-chunk "zones" list in the result + /// Called exactly once: with the result summary when the reset + /// finishes, or with a refusal carrying outcome/reason/refusalCode if it never ran + /// bool - whether the reset started. False means onComplete has already been given + /// the reason + public static bool ResetNamedLocation(string locationName, Vector3 center, float radius = 128f, + int safety = 0, bool resetAllMatches = false, float safeWaitSeconds = 0f, + bool includeDetail = false, Action> onComplete = null) { + return (bool)Call(ResetNamedLocationMethod, false, + locationName, center, radius, safety, resetAllMatches, safeWaitSeconds, includeDetail, onComplete); + } + + /// + /// Reset everything the server has configured for resets within `radius` of `center`. Same + /// safety rules and same client limits as ResetNamedLocation. + /// + /// bool - whether the request was dispatched + public static bool ResetLocationsInRadius(Vector3 center, float radius = 128f, int safety = 0, + float safeWaitSeconds = 0f, bool includeDetail = false, + Action> onComplete = null) { + return (bool)Call(ResetLocationsInRadiusMethod, false, + center, radius, safety, safeWaitSeconds, includeDetail, onComplete); + } } } diff --git a/EpicLoot/src/Adventure/BountyTarget.cs b/EpicLoot/src/Adventure/BountyTarget.cs index 0678ea24a..037df3580 100644 --- a/EpicLoot/src/Adventure/BountyTarget.cs +++ b/EpicLoot/src/Adventure/BountyTarget.cs @@ -10,6 +10,10 @@ namespace EpicLoot.Adventure [RequireComponent(typeof(Character))] public class BountyTarget : MonoBehaviour { + // Set once a StarLevelSystem API call has thrown (an installed SLS whose creature methods no longer match + // the bundled shim), so every later bounty creature goes straight to EpicLoot's own health multiplier. + private static bool _starLevelsUnusable; + private BountyInfo _bountyInfo; private string _monsterID; private bool _isAdd; @@ -99,24 +103,15 @@ public void Initialize(BountyInfo bounty, string monsterID, bool isAdd) _zdo.Set(BountyTargetComponent.BountyTargetNameKey, GetTargetName(_character.m_name, isAdd, bounty.TargetName)); _character.SetLevel(GetTargetLevel(bounty, monsterID, isAdd)); - if (StarLevelSystem.API.IsAvailable) - { - StarLevelSystem.API.SetCreatureBaseAttribute(_character, 0, GetMaxHealthModifier(bounty, isAdd)); - StarLevelSystem.API.ApplyCreatureUpdates(_character); // Flush changes to the creature - } - else - { - _character.SetMaxHealth(GetModifiedMaxHealth(_character, bounty, isAdd)); - } _character.m_baseAI.SetPatrolPoint(); - Reinitialize(); + Reinitialize(spawning: true); } /// - /// Initialize custom data from the character zdo. + /// Initialize custom data from the character zdo. Runs at spawn and on every load, on every peer. /// - public void Reinitialize() + public void Reinitialize(bool spawning = false) { // Read unconditionally -- see the note in Initialize. OnDeath runs on whichever machine owns // the creature, and under serverside simulation that is the dedicated server, so the server @@ -151,28 +146,84 @@ public void Reinitialize() _isAdd = _zdo.GetBool(BountyTargetComponent.IsAddKey); _character.m_name = _zdo.GetString(BountyTargetComponent.BountyTargetNameKey); - _character.m_boss = !_zdo.GetBool(BountyTargetComponent.IsAddKey); - } + // Before the health bonus: StarLevelSystem reads IsBoss() when it builds this creature's stats (boss + // health and damage per star, boss modifiers), and it has to get the same answer at spawn as on a load. + // It used to build them at spawn before this was set, and after it on every load. + _character.m_boss = !_isAdd; - private void DestroyInstance() - { - _zdo.SetOwner(ZDOMan.GetSessionID()); - _character.m_nview.Destroy(); + ApplyHealthBonus(spawning); } - private static float GetModifiedMaxHealth(Character character, BountyInfo bounty, bool isAdd) + /// + /// Gives the creature its bounty health multiplier. This is not a one-off at spawn: vanilla's Character.Awake + /// resets an undamaged creature's max health to base x level whenever its owner loads it, and StarLevelSystem + /// rebuilds its stats on every peer and every load, so the bonus is re-established each time. + /// + private void ApplyHealthBonus(bool spawning) { - if (isAdd) + float multiplier = GetMaxHealthModifier(_bountyInfo, _isAdd); + bool isOwner = _character.m_nview.IsOwner(); + + if (StarLevelSystem.API.IsAvailable && !_starLevelsUnusable) { - return character.GetMaxHealth() * AdventureDataManager.Config.Bounties.AddsHealthMultiplier; + try + { + if (isOwner) + { + // Keeps StarLevelSystem from deleting or copying a bounty creature for its spawn-rate and + // disabled-spawn rules, or rerolling its level against its level caps. Repeated on load so + // creatures spawned before this call existed are covered too; older SLS builds ignore it. + StarLevelSystem.API.SetCreatureSpawnManaged(_character, true); + } + + // SLS saves this on the creature when the owner sets it, so every peer's stats and every later + // load keep it. Older SLS builds only keep it in this peer's view, which is why every peer sets it. + StarLevelSystem.API.SetCreatureBaseAttribute(_character, 0, multiplier); + if (isOwner) + { + // A forced apply, which keeps the damage already taken: repairs a max health vanilla reset on + // load, and one an older build left with non-boss scaling. + StarLevelSystem.API.ApplyCreatureUpdates(_character); + } + return; + } + catch (Exception e) + { + _starLevelsUnusable = true; + EpicLoot.LogWarningForce("StarLevelSystem's creature API could not be used; bounty creature " + + $"health falls back to EpicLoot's own multiplier for this session. {e}"); + } } - if (bounty.RewardGold > 0) + if (!isOwner) + { + return; + } + + if (spawning) + { + _character.SetMaxHealth(_character.GetMaxHealth() * multiplier); + return; + } + + // On a load, only undo vanilla's reset. Any other max health was set on purpose, by the spawn above or + // by another mod, and is left alone. + float currentMax = _character.GetMaxHealth(); + if (!Mathf.Approximately(currentMax, _character.GetMaxHealthBase() * _character.GetLevel())) { - return character.GetMaxHealth() * AdventureDataManager.Config.Bounties.GoldHealthMultiplier; + return; } - return character.GetMaxHealth() * AdventureDataManager.Config.Bounties.IronHealthMultiplier; + float damageTaken = currentMax - _character.GetHealth(); + float target = currentMax * multiplier; + _character.SetMaxHealth(target); + _character.SetHealth(target - damageTaken); + } + + private void DestroyInstance() + { + _zdo.SetOwner(ZDOMan.GetSessionID()); + _character.m_nview.Destroy(); } private static float GetMaxHealthModifier(BountyInfo bounty, bool isAdd) diff --git a/EpicLoot/src/Magic/ItemDataExtensions.cs b/EpicLoot/src/Magic/ItemDataExtensions.cs index f4beec61f..be7006a27 100644 --- a/EpicLoot/src/Magic/ItemDataExtensions.cs +++ b/EpicLoot/src/Magic/ItemDataExtensions.cs @@ -320,6 +320,10 @@ public static List GetMundaneSetPieces(string setName) { // TODO: improve performace of this call List results = new List(); + // One entry per display token, not per prefab. Deep North registers SP_/FW_ NPC prop copies of + // set armour (SP_ArmorTrollLeatherChest, FW_CapeTrollHide, ...) that keep the real item's + // m_name and m_setName, so without this the troll set listed its tunic, pants and cape three times. + HashSet seen = new HashSet(); foreach (GameObject itemPrefab in ObjectDB.instance.m_items) { if (itemPrefab == null) @@ -335,7 +339,7 @@ public static List GetMundaneSetPieces(string setName) continue; } - if (itemDrop.m_itemData.m_shared.m_setName == setName) + if (itemDrop.m_itemData.m_shared.m_setName == setName && seen.Add(itemDrop.m_itemData.m_shared.m_name)) { results.Add(itemDrop.m_itemData.m_shared.m_name); }