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..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.2"; + public const string Version = "0.14.9"; 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!"); @@ -1162,32 +1165,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/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 091ca18b0..6f0b5ff3f 100644 --- a/EpicLoot/Package/CHANGELOG.md +++ b/EpicLoot/Package/CHANGELOG.md @@ -1,3 +1,41 @@ +**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 +* Removes bounty/treasure relocation due to the potential for schisms + +**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 +* 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 +* 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 + +**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/Package/manifest.json b/EpicLoot/Package/manifest.json index 361fd736a..31827b398 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.9", "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/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/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/StoreGui_Patch.cs b/EpicLoot/src/Adventure/StoreGui_Patch.cs index 084c80fc1..21658576e 100644 --- a/EpicLoot/src/Adventure/StoreGui_Patch.cs +++ b/EpicLoot/src/Adventure/StoreGui_Patch.cs @@ -9,18 +9,50 @@ 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) { - if (!EpicLoot.IsAdventureModeEnabled() || __instance == null) + // 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; } 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 +74,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/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 9d19029ce..5e1d83e4b 100644 --- a/EpicLoot/src/Adventure/bounties/AdventureSpawnController.cs +++ b/EpicLoot/src/Adventure/bounties/AdventureSpawnController.cs @@ -1,6 +1,10 @@ -using EpicLoot.Data; +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 @@ -22,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; } @@ -33,24 +69,129 @@ internal class AdventureSpawnController : MonoBehaviour private BoolZNetProperty isBounty { get; set; } - /// Frames to settle before the first search attempt. - private const int InitialSearchDelayFrames = 300; + /// + /// 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 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 circle has loaded. + private const float AreaPollSeconds = 0.25f; + + /// + /// 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 + /// loaded wards, so this keeps a search that rejects everything to a few milliseconds a frame. + /// + 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; /// - /// 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. + /// The furthest a minion is placed from its bounty target, before it is clamped to the margin + /// leaves inside the circle. /// - private const int RetrySearchDelayFrames = 1800; + 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 + } - private const int SpawnAttemptsPerBand = 100; + /// 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; - private int currentUpdates = 0; - private int updatesRequired = InitialSearchDelayFrames; + /// Time.time before which Update will not start (or restart) a spawn. + private float nextSearchTime = 0f; private bool startedPlacement = false; - private Vector3 defaultSpawn = new(1, 1, 1); + + // 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 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 BountyInfo defaultBounty = new(); private TreasureMapChestInfo defaultTreasure = new(); @@ -67,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); } } @@ -93,9 +234,10 @@ public void Update() return; } - if (currentUpdates < updatesRequired) + CheckOverdue(); + + if (Time.time < nextSearchTime) { - currentUpdates += 1; return; } @@ -104,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()); @@ -152,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(); @@ -182,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; @@ -201,22 +377,9 @@ 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); placed.ForceSet(true); } @@ -244,157 +407,263 @@ private void SpawnChest(TreasureMapChestInfo treasure) } treasureChest.Setup(treasure.PlayerID, treasure.Biome, treasure.Interval); + LogPlacement("treasure chest", treasure.Biome); placed.ForceSet(true); } - internal IEnumerator DeterminespawnPoint(Vector3 startingSpawnPoint, + /// + /// 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 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) + { + 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, {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 circleCentre, Heightmap.Biome biome, WaterPlacement waterPlacement = WaterPlacement.Reject) { + searchStartTime = Time.time; + 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. 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. + stage = PlacementStage.WaitingForIndex; 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; + } + } + + // 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. + stage = PlacementStage.WaitingForArea; + while (!IsAreaSettled(circleCentre)) + { + yield return new WaitForSeconds(AreaPollSeconds); + if (!StillOwner()) + { + AbandonSearch(); + yield break; + } + } - while (!ZNetScene.instance.IsAreaReady(startingSpawnPoint)) + // One frame so colliders created this frame are in the physics scene before the raycasts. + yield return null; + if (!StillOwner()) { - yield return new WaitForSeconds(1f); + AbandonSearch(); + yield break; } - // TODO: If bounties get their own minimap area radius config this must choose the correct one - float radius = AdventureDataManager.Config.TreasureMap.MinimapAreaRadius; + areaReadyTime = Time.time; + zoneReadyCache.Clear(); + stage = PlacementStage.Searching; + + 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; - - // 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++) - { - 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; + bool haveUsable = false; + Candidate best = default; + bool haveFallback = false; + Candidate fallback = default; + int candidatesThisFrame = 0; - // Attempt to find a spawn point, valid height must be selected - while (spawnLocationAttempts < SpawnAttemptsPerBand) + // 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++) + { + // 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); - - if (spawnLocationAttempts > 1 && spawnLocationAttempts % 10 == 0) - { - // Sleep to avoid locking the thread - yield return new WaitForSeconds(1f); - } - - ZoneSystem.instance.GetGroundData( - ref determinedSpawn, out var normal, out var foundBiome, out var biomeArea, out var hmap); - - if (hmap == null || foundBiome != biome) - { - 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 + candidatesThisFrame = 0; + yield return null; + if (!StillOwner()) { - 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; - break; } - - 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) { - EpicLoot.LogWarning( - "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."); - ParkAndRetry(); - yield break; + 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) + { + chosen = centre; + } + else + { + chosen = fallback; + } + + 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. @@ -408,35 +677,302 @@ 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 Candidate EvaluateCandidate(Vector3 sample, Heightmap.Biome biome, bool spawnInOpenWater, + float waterSurface, float wardBuffer) + { + 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; + } + + /// + /// 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 ParkAndRetry() + private void AbandonSearch() { + EpicLoot.Log("Adventure spawner changed owner mid-search; leaving the search to the new owner."); startedPlacement = false; - currentUpdates = 0; - updatesRequired = RetrySearchDelayFrames; + stage = PlacementStage.NotStarted; + } + + /// + /// 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 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); + } + + 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; + } + + /// + /// 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. + /// + 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) + { + 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); + + foreach (ZDO zdo in ZoneObjectsScratch) + { + if (ZNetScene.instance.IsPrefabZDOValid(zdo) && !ZNetScene.instance.HaveInstance(zdo)) + { + waitingOn = zdo; + break; + } + } + + ZoneObjectsScratch.Clear(); + return waitingOn != null; } /// @@ -445,7 +981,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. /// @@ -454,6 +990,7 @@ private void ParkAndRetry() /// private void ReportMissingPrefab(string role, string bountyId, string monsterId) { + missingPrefab = monsterId; if (!reportedMissingPrefab) { reportedMissingPrefab = true; @@ -464,77 +1001,45 @@ 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; } /// - /// 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= 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/BountyLocationEarlyCache.cs b/EpicLoot/src/Adventure/feature/BountyLocationEarlyCache.cs index 426235f09..09c8de447 100755 --- a/EpicLoot/src/Adventure/feature/BountyLocationEarlyCache.cs +++ b/EpicLoot/src/Adventure/feature/BountyLocationEarlyCache.cs @@ -17,9 +17,9 @@ namespace EpicLoot.Adventure.Feature /// with it. /// /// Everything here is seed-only and therefore blind to terrain colliders, wards and player bases. - /// 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/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/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); 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/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/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/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/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/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/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/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 3fd065ba3..35f2f5107 100644 --- a/EpicLoot/src/Magic/AutoAddEnchantableItems.cs +++ b/EpicLoot/src/Magic/AutoAddEnchantableItems.cs @@ -112,7 +112,9 @@ 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) && + !LootDenyList.IsDenied(i.name) && + !AttackKillsWielder(i.m_itemData)).ToList(); EpicLoot.Log($"Checking all equipment in game."); foundByCategory = EnsureItemsInConfigMutating(foundByCategory, itemsByCategory, allEquipment); @@ -133,6 +135,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); @@ -142,7 +150,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 +166,78 @@ 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); + } + + /// + /// 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. + /// + /// 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 != null && + (item.m_shared.m_attack?.m_attackKillsSelf == true || + item.m_shared.m_secondaryAttack?.m_attackKillsSelf == true); + } + + /// + /// 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 selfKilling = new HashSet(allItems + .Where(i => AttackKillsWielder(i.m_itemData)) + .Select(i => i.name)); + + SortedSet removedNames = new SortedSet(StringComparer.Ordinal); + bool IsProp(string name) + { + 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(IsProp); +#pragma warning restore 612 + foreach (KeyValuePair> byBoss in itemType.ItemsByBoss) + { + removed += byBoss.Value.RemoveAll(IsProp); + } + } + + if (removed > 0) + { + 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."); + } } private static void AddRemoveItemsFromLootLists(List magicMats, @@ -276,7 +356,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 +439,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 { @@ -644,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]; @@ -654,10 +743,17 @@ 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. + // ...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 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 dbe625acc..1b0236d9b 100644 --- a/EpicLoot/src/Magic/ItemDataExtensions.cs +++ b/EpicLoot/src/Magic/ItemDataExtensions.cs @@ -324,39 +324,147 @@ 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. /// 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. 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/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(); 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/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/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/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/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/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/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/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) 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/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); diff --git a/EquipmentAndQuickSlots/EquipmentAndQuickSlots.cs b/EquipmentAndQuickSlots/EquipmentAndQuickSlots.cs index f3539e536..b6a4e2a8f 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.3"; public static Sprite PaperdollMale; public static Sprite PaperdollFemale; diff --git a/EquipmentAndQuickSlots/Package/CHANGELOG.md b/EquipmentAndQuickSlots/Package/CHANGELOG.md index d7c08627e..f376becf7 100644 --- a/EquipmentAndQuickSlots/Package/CHANGELOG.md +++ b/EquipmentAndQuickSlots/Package/CHANGELOG.md @@ -1,3 +1,14 @@ +**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 +* 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..96cdcf7af 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.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/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); } } } 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