diff --git a/Projects/UOContent/Engines/ExileHunterBestiary/CHANGES_v2.md b/Projects/UOContent/Engines/ExileHunterBestiary/CHANGES_v2.md new file mode 100644 index 000000000..e6085a1a5 --- /dev/null +++ b/Projects/UOContent/Engines/ExileHunterBestiary/CHANGES_v2.md @@ -0,0 +1,69 @@ +# Exile Hunter's Bestiary - changelog & audit response + +## Final stable state +This build is the final working configuration for the current ModernUO project state: +- No BaseCreature patch required. +- Death handling is done through generated death events. +- Drop result is resolved from combat tracker + killer resolution. +- Treatise is dropped into the corpse when available; otherwise, it is retried for a short window. +- Legacy partial-file patching is not used. + +## Critical fixes applied +1. Treatise drop now lands in the corpse when the corpse exists. +2. Death processing no longer fails because the mob is already deleted by the time the event fires. +3. Auto-retry is used when corpse is not yet available immediately after death. +4. Duplicate processing of the same death is blocked by Serial-based guard. +5. Kill credit is resolved from LastKiller or combat tracker, not from dead/invalid objects. +6. Dead code and noisy debug logging were removed. +7. Registry validation is preserved. +8. Profile persistence remains compatible with XML save/load. + +## Architecture +- Death hook is registered via generated event: `BaseCreature.CreatureDeathEvent`. +- Combat tracker records the last player damage event and the creature location/map. +- Bestiary drop logic is separated into: + - Registry + - Profile + - Store + - Engine + - Combat tracker + - Death hook + - UI gump + - Treatise item + +## Balance +- Per-creature mastery bonus: +10% +- Category milestone bonus: up to +20% +- Total hard cap: +25% +- Pity timer: guaranteed treatise every 40 kills per creature +- Damage mode: Mark is default and patch-free + +## Important note +The system does NOT require modifying `BaseCreature.cs` in the final version. +The working approach is: +- use combat tracker from `AggressiveAction` +- use generated death event +- resolve actual killer +- drop treatise into corpse or retry for a short time + +## v3.2 - final stable build +- Removed old patch-based death handling and fallback partial overrides. +- Removed excessive debug logging from runtime combat flow. +- Death event is now the only actual drop trigger. +- Combat tracker stores last player hit and location before the mob is deleted. +- Treatise drop request waits a little if corpse is not immediately available. +- Final implementation is leaner and safer than patch-based variations. + +## v3.3 - docs cleanup +- Removed outdated instructions about BaseCreature edits. +- Removed legacy mentions of "Variant A/B/C/D" patch hooks. +- Clarified that `HunterCreatureDeathEvent.cs` is the correct active death hook. +- Corrected troubleshooting around corpse timing in ModernUO. + +## Current supported behavior +- Treatise drops only for registered creature types. +- Only players can receive credit. +- Summoned and bonded creatures are ignored. +- Global chance override: `exileBestiary.drop.chance` +- Default chance values are still taken from the registry if the global key is unset or set to -1. +- `exileBestiary.debug` is available for optional diagnostics, but production logs should stay clean. \ No newline at end of file diff --git a/Projects/UOContent/Engines/ExileHunterBestiary/Core/BestiaryCategory.cs b/Projects/UOContent/Engines/ExileHunterBestiary/Core/BestiaryCategory.cs new file mode 100644 index 000000000..bb71cd5f5 --- /dev/null +++ b/Projects/UOContent/Engines/ExileHunterBestiary/Core/BestiaryCategory.cs @@ -0,0 +1,13 @@ +namespace Server.Custom.ExileHunterBestiary; + +public enum BestiaryCategory +{ + Undead, + Daemons, + Reptiles, + Elementals, + Humanoids, + Arachnids, + Beasts, + Bosses +} diff --git a/Projects/UOContent/Engines/ExileHunterBestiary/Core/BestiaryData.cs b/Projects/UOContent/Engines/ExileHunterBestiary/Core/BestiaryData.cs new file mode 100644 index 000000000..b301b249a --- /dev/null +++ b/Projects/UOContent/Engines/ExileHunterBestiary/Core/BestiaryData.cs @@ -0,0 +1,214 @@ +namespace Server.Custom.ExileHunterBestiary; + +internal static class BestiaryData +{ + public static void RegisterAll() + { + + BestiaryRegistry.Register("Skeleton", "Skeleton", "Skeleton", BestiaryCategory.Undead, 2101, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("SkeletalMage", "Skeletal Mage", "SkeletalMage", BestiaryCategory.Undead, 2101, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("SkeletalKnight", "Skeletal Knight", "SkeletalKnight", BestiaryCategory.Undead, 2101, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("PatchworkSkeleton", "Patchwork Skeleton", "PatchworkSkeleton", BestiaryCategory.Undead, 2101, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Zombie", "Zombie", "Zombie", BestiaryCategory.Undead, 1175, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Ghoul", "Ghoul", "Ghoul", BestiaryCategory.Undead, 1175, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Shade", "Shade", "Shade", BestiaryCategory.Undead, 1150, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Spectre", "Spectre", "Spectre", BestiaryCategory.Undead, 1150, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Wraith", "Wraith", "Wraith", BestiaryCategory.Undead, 1150, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Mummy", "Mummy", "Mummy", BestiaryCategory.Undead, 2305, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("PestilentBandage", "Pestilent Bandage", "PestilentBandage", BestiaryCategory.Undead, 1150, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("BoneKnight", "Bone Knight", "BoneKnight", BestiaryCategory.Undead, 2101, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("BoneMagi", "Bone Magi", "BoneMagi", BestiaryCategory.Undead, 2101, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("RottingCorpse", "Rotting Corpse", "RottingCorpse", BestiaryCategory.Undead, 1175, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Lich", "Lich", "Lich", BestiaryCategory.Undead, 1150, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("LichLord", "Lich Lord", "LichLord", BestiaryCategory.Undead, 1153, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("AncientLich", "Ancient Lich", "AncientLich", BestiaryCategory.Undead, 1175, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("SkeletalDragon", "Skeletal Dragon", "SkeletalDragon", BestiaryCategory.Undead, 1175, 0.03); + BestiaryRegistry.Register("BoneDemon", "Bone Demon", "BoneDemon", BestiaryCategory.Undead, 2101, 0.04); + BestiaryRegistry.Register("UndeadGargoyle", "Undead Gargoyle", "UndeadGargoyle", BestiaryCategory.Undead, 1175, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("PutridUndeadGargoyle", "Putrid Undead Gargoyle", "PutridUndeadGargoyle", BestiaryCategory.Undead, 1175, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("WailingBanshee", "Wailing Banshee", "WailingBanshee", BestiaryCategory.Undead, 1153, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("GoreFiend", "Gore Fiend", "GoreFiend", BestiaryCategory.Undead, 1175, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("FleshGolem", "Flesh Golem", "FleshGolem", BestiaryCategory.Undead, 1175, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Gibberling", "Gibberling", "Gibberling", BestiaryCategory.Undead, 1175, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Rotworm", "Rotworm", "Rotworm", BestiaryCategory.Undead, 2215, HunterBestiaryEngine.DefaultDropChance); + + + BestiaryRegistry.Register("Daemon", "Daemon", "Daemon", BestiaryCategory.Daemons, 1258, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("FireDaemon", "Fire Daemon", "FireDaemon", BestiaryCategory.Daemons, 1260, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("IceFiend", "Ice Fiend", "IceFiend", BestiaryCategory.Daemons, 1152, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Succubus", "Succubus", "Succubus", BestiaryCategory.Daemons, 1166, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Balron", "Balron", "Balron", BestiaryCategory.Daemons, 1255, 0.03); + BestiaryRegistry.Register("ArcaneDaemon", "Arcane Daemon", "ArcaneDaemon", BestiaryCategory.Daemons, 1165, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("ChaosDaemon", "Chaos Daemon", "ChaosDaemon", BestiaryCategory.Daemons, 1167, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Moloch", "Moloch", "Moloch", BestiaryCategory.Daemons, 1257, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Ravager", "Ravager", "Ravager", BestiaryCategory.Daemons, 1258, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Devourer", "Devourer of Souls", "Devourer", BestiaryCategory.Daemons, 1153, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Imp", "Imp", "Imp", BestiaryCategory.Daemons, 1255, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Gargoyle", "Gargoyle", "Gargoyle", BestiaryCategory.Daemons, 2419, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("StoneGargoyle", "Stone Gargoyle", "StoneGargoyle", BestiaryCategory.Daemons, 2413, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("FireGargoyle", "Fire Gargoyle", "FireGargoyle", BestiaryCategory.Daemons, 1260, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("GargoyleEnforcer", "Gargoyle Enforcer", "GargoyleEnforcer", BestiaryCategory.Daemons, 2419, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("GargoyleDestroyer", "Gargoyle Destroyer", "GargoyleDestroyer", BestiaryCategory.Daemons, 2419, HunterBestiaryEngine.DefaultDropChance); + + + BestiaryRegistry.Register("Dragon", "Dragon", "Dragon", BestiaryCategory.Reptiles, 1365, 0.03); + BestiaryRegistry.Register("Drake", "Drake", "Drake", BestiaryCategory.Reptiles, 1362, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("WhiteWyrm", "White Wyrm", "WhiteWyrm", BestiaryCategory.Reptiles, 1150, 0.03); + BestiaryRegistry.Register("ShadowWyrm", "Shadow Wyrm", "ShadowWyrm", BestiaryCategory.Reptiles, 1109, 0.03); + BestiaryRegistry.Register("AncientWyrm", "Ancient Wyrm", "AncientWyrm", BestiaryCategory.Reptiles, 1370, 0.04); + BestiaryRegistry.Register("GreaterDragon", "Greater Dragon", "GreaterDragon", BestiaryCategory.Reptiles, 1360, 0.04); + BestiaryRegistry.Register("SwampDragon", "Swamp Dragon", "SwampDragon", BestiaryCategory.Reptiles, 2212, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Hydra", "Hydra", "Hydra", BestiaryCategory.Reptiles, 1368, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("SerpentineDragon", "Serpentine Dragon", "SerpentineDragon", BestiaryCategory.Reptiles, 1160, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Reptalon", "Reptalon", "Reptalon", BestiaryCategory.Reptiles, 1372, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Lizardman", "Lizardman", "Lizardman", BestiaryCategory.Reptiles, 2210, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("OphidianWarrior", "Ophidian Warrior", "OphidianWarrior", BestiaryCategory.Reptiles, 2001, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("OphidianMage", "Ophidian Mage", "OphidianMage", BestiaryCategory.Reptiles, 2002, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("OphidianMatriarch", "Ophidian Matriarch", "OphidianMatriarch", BestiaryCategory.Reptiles, 2003, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Slith", "Slith", "Slith", BestiaryCategory.Reptiles, 2212, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("ToxicSlith", "Toxic Slith", "ToxicSlith", BestiaryCategory.Reptiles, 1267, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("StoneSlith", "Stone Slith", "StoneSlith", BestiaryCategory.Reptiles, 2413, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("LavaLizard", "Lava Lizard", "LavaLizard", BestiaryCategory.Reptiles, 1260, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("LavaSerpent", "Lava Serpent", "LavaSerpent", BestiaryCategory.Reptiles, 1260, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("LavaSnake", "Lava Snake", "LavaSnake", BestiaryCategory.Reptiles, 1260, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("IceSerpent", "Ice Serpent", "IceSerpent", BestiaryCategory.Reptiles, 1152, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("IceSnake", "Ice Snake", "IceSnake", BestiaryCategory.Reptiles, 1152, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("SilverSerpent", "Silver Serpent", "SilverSerpent", BestiaryCategory.Reptiles, 2301, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("GiantSerpent", "Giant Serpent", "GiantSerpent", BestiaryCategory.Reptiles, 2210, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Alligator", "Alligator", "Alligator", BestiaryCategory.Reptiles, 2210, HunterBestiaryEngine.DefaultDropChance); + + + BestiaryRegistry.Register("EarthElemental", "Earth Elemental", "EarthElemental", BestiaryCategory.Elementals, 2419, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("AirElemental", "Air Elemental", "AirElemental", BestiaryCategory.Elementals, 1153, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("FireElemental", "Fire Elemental", "FireElemental", BestiaryCategory.Elementals, 1260, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("WaterElemental", "Water Elemental", "WaterElemental", BestiaryCategory.Elementals, 1165, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("PoisonElemental", "Poison Elemental", "PoisonElemental", BestiaryCategory.Elementals, 1267, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("BloodElemental", "Blood Elemental", "BloodElemental", BestiaryCategory.Elementals, 1258, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("SnowElemental", "Snow Elemental", "SnowElemental", BestiaryCategory.Elementals, 1150, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("IceElemental", "Ice Elemental", "IceElemental", BestiaryCategory.Elementals, 1152, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Efreet", "Efreet", "Efreet", BestiaryCategory.Elementals, 1260, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("LavaElemental", "Lava Elemental", "LavaElemental", BestiaryCategory.Elementals, 1260, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("SandVortex", "Sand Vortex", "SandVortex", BestiaryCategory.Elementals, 2419, HunterBestiaryEngine.DefaultDropChance); + + + BestiaryRegistry.Register("Ogre", "Ogre", "Ogre", BestiaryCategory.Humanoids, 2215, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("OgreLord", "Ogre Lord", "OgreLord", BestiaryCategory.Humanoids, 2216, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("ArcticOgreLord", "Arctic Ogre Lord", "ArcticOgreLord", BestiaryCategory.Humanoids, 1150, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Ettin", "Ettin", "Ettin", BestiaryCategory.Humanoids, 2214, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Troll", "Troll", "Troll", BestiaryCategory.Humanoids, 2213, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Titan", "Titan", "Titan", BestiaryCategory.Humanoids, 1153, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Cyclops", "Cyclops", "Cyclops", BestiaryCategory.Humanoids, 2217, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Orc", "Orc", "Orc", BestiaryCategory.Humanoids, 2208, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("OrcishLord", "Orcish Lord", "OrcishLord", BestiaryCategory.Humanoids, 2209, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("OrcCaptain", "Orc Captain", "OrcCaptain", BestiaryCategory.Humanoids, 2210, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("OrcishMage", "Orcish Mage", "OrcishMage", BestiaryCategory.Humanoids, 2208, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("OrcBrute", "Orc Brute", "OrcBrute", BestiaryCategory.Humanoids, 2211, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Ratman", "Ratman", "Ratman", BestiaryCategory.Humanoids, 2307, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("RatmanArcher", "Ratman Archer", "RatmanArcher", BestiaryCategory.Humanoids, 2307, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("RatmanMage", "Ratman Mage", "RatmanMage", BestiaryCategory.Humanoids, 2308, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Minotaur", "Minotaur", "Minotaur", BestiaryCategory.Humanoids, 2418, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Centaur", "Centaur", "Centaur", BestiaryCategory.Humanoids, 2215, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Satyr", "Satyr", "Satyr", BestiaryCategory.Humanoids, 2215, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("InsaneDryad", "Insane Dryad", "InsaneDryad", BestiaryCategory.Humanoids, 2210, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Pixie", "Pixie", "Pixie", BestiaryCategory.Humanoids, 1160, HunterBestiaryEngine.DefaultDropChance); + + + BestiaryRegistry.Register("GiantSpider", "Giant Spider", "GiantSpider", BestiaryCategory.Arachnids, 2110, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("DreadSpider", "Dread Spider", "DreadSpider", BestiaryCategory.Arachnids, 1175, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("FrostSpider", "Frost Spider", "FrostSpider", BestiaryCategory.Arachnids, 1152, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("GiantBlackWidow", "Giant Black Widow", "GiantBlackWidow", BestiaryCategory.Arachnids, 1109, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("TerathanDrone", "Terathan Drone", "TerathanDrone", BestiaryCategory.Arachnids, 2115, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("TerathanWarrior", "Terathan Warrior", "TerathanWarrior", BestiaryCategory.Arachnids, 2116, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("TerathanMatriarch", "Terathan Matriarch", "TerathanMatriarch", BestiaryCategory.Arachnids, 2117, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Scorpion", "Scorpion", "Scorpion", BestiaryCategory.Arachnids, 2206, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("RuneBeetle", "Rune Beetle", "RuneBeetle", BestiaryCategory.Arachnids, 1165, 0.03); + BestiaryRegistry.Register("FireBeetle", "Fire Beetle", "FireBeetle", BestiaryCategory.Arachnids, 1260, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Beetle", "Giant Beetle", "Beetle", BestiaryCategory.Arachnids, 2210, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("IronBeetle", "Iron Beetle", "IronBeetle", BestiaryCategory.Arachnids, 2413, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("DeathwatchBeetle", "Deathwatch Beetle", "DeathwatchBeetle", BestiaryCategory.Arachnids, 2215, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("AntLion", "Ant Lion", "AntLion", BestiaryCategory.Arachnids, 2419, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("FireAnt", "Fire Ant", "FireAnt", BestiaryCategory.Arachnids, 1260, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("SkitteringHopper", "Skittering Hopper", "SkitteringHopper", BestiaryCategory.Arachnids, 2210, HunterBestiaryEngine.DefaultDropChance); + + + BestiaryRegistry.Register("Gazer", "Gazer", "Gazer", BestiaryCategory.Beasts, 1166, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("ElderGazer", "Elder Gazer", "ElderGazer", BestiaryCategory.Beasts, 1167, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("GazerLarva", "Gazer Larva", "GazerLarva", BestiaryCategory.Beasts, 1166, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("HellHound", "Hell Hound", "HellHound", BestiaryCategory.Beasts, 1258, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("HellCat", "Hell Cat", "HellCat", BestiaryCategory.Beasts, 1258, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("PredatorHellCat", "Predator Hell Cat", "PredatorHellCat", BestiaryCategory.Beasts, 1258, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Nightmare", "Nightmare", "Nightmare", BestiaryCategory.Beasts, 1109, 0.03); + BestiaryRegistry.Register("CuSidhe", "Cu Sidhe", "CuSidhe", BestiaryCategory.Beasts, 2212, 0.03); + BestiaryRegistry.Register("Kirin", "Ki-Rin", "Kirin", BestiaryCategory.Beasts, 1160, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Unicorn", "Unicorn", "Unicorn", BestiaryCategory.Beasts, 1150, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Hiryu", "Hiryu", "Hiryu", BestiaryCategory.Beasts, 1365, 0.03); + BestiaryRegistry.Register("Phoenix", "Phoenix", "Phoenix", BestiaryCategory.Beasts, 1260, 0.04); + BestiaryRegistry.Register("TimberWolf", "Timber Wolf", "TimberWolf", BestiaryCategory.Beasts, 2215, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("GreyWolf", "Grey Wolf", "GreyWolf", BestiaryCategory.Beasts, 2215, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("WhiteWolf", "White Wolf", "WhiteWolf", BestiaryCategory.Beasts, 1150, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("DireWolf", "Dire Wolf", "DireWolf", BestiaryCategory.Beasts, 2305, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Cougar", "Cougar", "Cougar", BestiaryCategory.Beasts, 2215, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Panther", "Panther", "Panther", BestiaryCategory.Beasts, 1109, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("SnowLeopard", "Snow Leopard", "SnowLeopard", BestiaryCategory.Beasts, 1150, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("GrizzlyBear", "Grizzly Bear", "GrizzlyBear", BestiaryCategory.Beasts, 2215, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("PolarBear", "Polar Bear", "PolarBear", BestiaryCategory.Beasts, 1150, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("BlackBear", "Black Bear", "BlackBear", BestiaryCategory.Beasts, 1109, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Gorilla", "Gorilla", "Gorilla", BestiaryCategory.Beasts, 2210, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Walrus", "Walrus", "Walrus", BestiaryCategory.Beasts, 2419, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("GiantToad", "Giant Toad", "GiantToad", BestiaryCategory.Beasts, 2210, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Bullfrog", "Bullfrog", "BullFrog", BestiaryCategory.Beasts, 2210, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("VampireBat", "Vampire Bat", "VampireBat", BestiaryCategory.Beasts, 1109, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Mongbat", "Mongbat", "Mongbat", BestiaryCategory.Beasts, 2215, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("DarkWisp", "Dark Wisp", "DarkWisp", BestiaryCategory.Beasts, 1109, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("ShadowWisp", "Shadow Wisp", "ShadowWisp", BestiaryCategory.Beasts, 1109, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("GiantIceWorm", "Giant Ice Worm", "GiantIceWorm", BestiaryCategory.Beasts, 1152, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Raptor", "Raptor", "Raptor", BestiaryCategory.Beasts, 2210, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Kepetch", "Kepetch", "Kepetch", BestiaryCategory.Beasts, 2215, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("KepetchAmbusher", "Kepetch Ambusher", "KepetchAmbusher", BestiaryCategory.Beasts, 2215, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("BloodWorm", "Blood Worm", "BloodWorm", BestiaryCategory.Beasts, 1258, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("OsseinRam", "Ossein Ram", "OsseinRam", BestiaryCategory.Beasts, 2101, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Harpy", "Harpy", "Harpy", BestiaryCategory.Beasts, 2215, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("StoneHarpy", "Stone Harpy", "StoneHarpy", BestiaryCategory.Beasts, 2413, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Corpser", "Corpser", "Corpser", BestiaryCategory.Beasts, 2215, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Reaper", "Reaper", "Reaper", BestiaryCategory.Beasts, 2215, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("BogThing", "Bog Thing", "BogThing", BestiaryCategory.Beasts, 2212, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Bogling", "Bogling", "Bogling", BestiaryCategory.Beasts, 2212, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("PlagueBeast", "Plague Beast", "PlagueBeast", BestiaryCategory.Beasts, 1267, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("PlagueBeastLord", "Plague Beast Lord", "PlagueBeastLord", BestiaryCategory.Beasts, 1267, 0.03); + BestiaryRegistry.Register("Quagmire", "Quagmire", "Quagmire", BestiaryCategory.Beasts, 2212, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Slime", "Slime", "Slime", BestiaryCategory.Beasts, 2210, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("FrostOoze", "Frost Ooze", "FrostOoze", BestiaryCategory.Beasts, 1152, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("SeaSerpent", "Sea Serpent", "SeaSerpent", BestiaryCategory.Beasts, 1165, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("DeepSeaSerpent", "Deep Sea Serpent", "DeepSeaSerpent", BestiaryCategory.Beasts, 1165, HunterBestiaryEngine.DefaultDropChance); + BestiaryRegistry.Register("Kraken", "Kraken", "Kraken", BestiaryCategory.Beasts, 1165, 0.03); + BestiaryRegistry.Register("Leviathan", "Leviathan", "Leviathan", BestiaryCategory.Beasts, 1165, 0.05); + + + BestiaryRegistry.Register("DemonKnight", "Demon Knight (Dark Father)", "DemonKnight", BestiaryCategory.Bosses, 1161, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("DarknightCreeper", "Darknight Creeper", "DarknightCreeper", BestiaryCategory.Bosses, 1161, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("FleshRenderer", "Flesh Renderer", "FleshRenderer", BestiaryCategory.Bosses, 1161, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("Impaler", "Impaler", "Impaler", BestiaryCategory.Bosses, 1161, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("ShadowKnight", "Shadow Knight", "ShadowKnight", BestiaryCategory.Bosses, 1161, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("AbysmalHorror", "Abysmal Horror", "AbysmalHorror", BestiaryCategory.Bosses, 1161, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("Harrower", "The Harrower", "Harrower", BestiaryCategory.Bosses, 1175, 0.10); + BestiaryRegistry.Register("SlasherOfVeils", "Slasher of Veils", "SlasherOfVeils", BestiaryCategory.Bosses, 1170, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("StygianDragon", "Stygian Dragon", "StygianDragon", BestiaryCategory.Bosses, 1172, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("Medusa", "Medusa", "Medusa", BestiaryCategory.Bosses, 1168, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("PrimevalLich", "Primeval Lich", "PrimevalLich", BestiaryCategory.Bosses, 1153, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("AbyssalInfernal", "Abyssal Infernal", "AbyssalInfernal", BestiaryCategory.Bosses, 1255, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("Rikktor", "Rikktor", "Rikktor", BestiaryCategory.Bosses, 1365, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("Mephitis", "Mephitis", "Mephitis", BestiaryCategory.Bosses, 1175, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("Semidar", "Semidar", "Semidar", BestiaryCategory.Bosses, 1258, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("Barracoon", "Barracoon the Piper", "Barracoon", BestiaryCategory.Bosses, 2307, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("Neira", "Neira the Necromancer", "Neira", BestiaryCategory.Bosses, 1175, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("LordOaks", "Lord Oaks", "LordOaks", BestiaryCategory.Bosses, 2215, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("Silvani", "Silvani", "Silvani", BestiaryCategory.Bosses, 1160, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("Ilhenir", "Ilhenir the Stained", "Ilhenir", BestiaryCategory.Bosses, 1267, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("Serado", "Serado the Scourge", "Serado", BestiaryCategory.Bosses, 1267, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("ChiefParoxysmus", "Chief Paroxysmus", "ChiefParoxysmus", BestiaryCategory.Bosses, 1267, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("DreadHorn", "Dread Horn", "DreadHorn", BestiaryCategory.Bosses, 1160, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("Travesty", "Travesty", "Travesty", BestiaryCategory.Bosses, 1153, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("ShimmeringEffusion", "Shimmering Effusion", "ShimmeringEffusion", BestiaryCategory.Bosses, 1152, HunterBestiaryEngine.BossDropChance); + BestiaryRegistry.Register("MonstrousInterredGrizzle", "Monstrous Interred Grizzle", "MonstrousInterredGrizzle", BestiaryCategory.Bosses, 1175, HunterBestiaryEngine.BossDropChance); + } +} diff --git a/Projects/UOContent/Engines/ExileHunterBestiary/Core/BestiaryRegistry.cs b/Projects/UOContent/Engines/ExileHunterBestiary/Core/BestiaryRegistry.cs new file mode 100644 index 000000000..bb909d1b0 --- /dev/null +++ b/Projects/UOContent/Engines/ExileHunterBestiary/Core/BestiaryRegistry.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using Server.Logging; + +namespace Server.Custom.ExileHunterBestiary; + +public static class BestiaryRegistry +{ + private static readonly ILogger _logger = LogFactory.GetLogger(typeof(BestiaryRegistry)); + + private static Dictionary _byId = new(StringComparer.OrdinalIgnoreCase); + private static Dictionary _byType = []; + private static Dictionary> _byCategory = []; + + + + private static readonly Dictionary _resolutionCache = []; + + public static int TotalCount => _byId.Count; + + public static IReadOnlyDictionary> ByCategory => _byCategory; + + public static void Initialize() + { + _byId = new Dictionary(StringComparer.OrdinalIgnoreCase); + _byType = new Dictionary(); + _byCategory = new Dictionary>(); + _resolutionCache.Clear(); + + foreach (var category in Enum.GetValues()) + { + _byCategory[category] = []; + } + + BestiaryData.RegisterAll(); + + _logger.Information( + "Initialized with {Count} creatures across {Categories} categories.", + _byId.Count, _byCategory.Count + ); + } + + public static void Register( + string id, string displayName, string typeName, + BestiaryCategory category, int bookHue = 0, + double dropChance = HunterBestiaryEngine.DefaultDropChance) + { + var type = AssemblyHandler.FindTypeByName(typeName); + + if (type == null) + { + _logger.Warning("Type '{TypeName}' not found - bestiary entry '{Id}' skipped.", typeName, id); + return; + } + + if (_byType.ContainsKey(type)) + { + _logger.Warning("Duplicate type '{TypeName}' - bestiary entry '{Id}' skipped.", typeName, id); + return; + } + + var entry = new HunterCreatureEntry(id, displayName, type, category, bookHue, dropChance); + _byId[id] = entry; + _byType[type] = entry; + _byCategory[category].Add(entry); + } + + public static bool TryGetById(string id, out HunterCreatureEntry entry) => + _byId.TryGetValue(id ?? "", out entry); + + + + + + public static HunterCreatureEntry FindEntry(Type creatureType) + { + if (creatureType == null) + { + return null; + } + + if (_resolutionCache.TryGetValue(creatureType, out var cached)) + { + return cached; + } + + HunterCreatureEntry found = null; + for (var current = creatureType; current != null && current != typeof(object); current = current.BaseType) + { + if (_byType.TryGetValue(current, out found)) + { + break; + } + } + + _resolutionCache[creatureType] = found; + return found; + } +} diff --git a/Projects/UOContent/Engines/ExileHunterBestiary/Core/DamageMode.cs b/Projects/UOContent/Engines/ExileHunterBestiary/Core/DamageMode.cs new file mode 100644 index 000000000..8458b63d2 --- /dev/null +++ b/Projects/UOContent/Engines/ExileHunterBestiary/Core/DamageMode.cs @@ -0,0 +1,7 @@ +namespace Server.Custom.ExileHunterBestiary; + +public enum DamageMode +{ + Mark, + Multiplier +} diff --git a/Projects/UOContent/Engines/ExileHunterBestiary/Core/HunterBestiaryEngine.cs b/Projects/UOContent/Engines/ExileHunterBestiary/Core/HunterBestiaryEngine.cs new file mode 100644 index 000000000..2569af31a --- /dev/null +++ b/Projects/UOContent/Engines/ExileHunterBestiary/Core/HunterBestiaryEngine.cs @@ -0,0 +1,567 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text.Json; +using Server.Commands; +using Server.Custom.ExileHunterBestiary.Gumps; +using Server.Custom.ExileHunterBestiary.Hooks; +using Server.Logging; +using Server.Mobiles; + +namespace Server.Custom.ExileHunterBestiary; + +public static class HunterBestiaryEngine +{ + private static readonly ILogger _logger = + LogFactory.GetLogger(typeof(HunterBestiaryEngine)); + + private static readonly Dictionary _profiles = []; + + public const double DefaultDropChance = 0.02; + public const double BossDropChance = 0.05; + + public static bool Enabled { get; private set; } = true; + + public static double PerCreatureBonus { get; private set; } = 0.10; + + public static double CategoryMilestoneBonus { get; private set; } = 0.20; + + public static double TotalBonusCap { get; private set; } = 0.25; + + public static int PityThreshold { get; private set; } = 40; + + public static DamageMode DamageMode { get; private set; } = DamageMode.Mark; + + public static double GlobalDropChance { get; private set; } = -1.0; + + public static bool Debug { get; private set; } = true; + + public static void Configure() + { + Enabled = GetBool("exileBestiary.enabled", true); + PerCreatureBonus = GetDouble( + "exileBestiary.damage.perCreatureBonus", + 0.10 + ); + CategoryMilestoneBonus = GetDouble( + "exileBestiary.damage.categoryMilestoneBonus", + 0.20 + ); + TotalBonusCap = GetDouble( + "exileBestiary.damage.totalCap", + 0.25 + ); + PityThreshold = GetInt( + "exileBestiary.drop.pityThreshold", + 40 + ); + DamageMode = GetEnum( + "exileBestiary.damage.mode", + DamageMode.Mark + ); + GlobalDropChance = GetDouble( + "exileBestiary.drop.chance", + -1.0 + ); + Debug = GetBool( + "exileBestiary.debug", + true + ); + + _logger.Information( + "Config ({Source}): enabled={Enabled} mode={Mode} dropChance={DropChance} perCreature={PerCreature} milestone={Milestone} cap={Cap} pity={Pity} debug={Debug}", + _configSource, + Enabled, + DamageMode, + GlobalDropChance, + PerCreatureBonus, + CategoryMilestoneBonus, + TotalBonusCap, + PityThreshold, + Debug + ); + + BestiaryRegistry.Initialize(); + + if (!Enabled) + { + _logger.Warning( + "System disabled via configuration (exileBestiary.enabled = false)." + ); + + return; + } + + EventSink.WorldSave += OnWorldSave; + EventSink.WorldLoad += OnWorldLoad; + + EventSink.AggressiveAction += static args => + HunterCombatTracker.Note(args.Aggressor, args.Aggressed); + + HunterCombatTracker.StartSweeper(); + + ReloadProfiles(); + + CommandSystem.Register( + "Bestiary", + AccessLevel.Player, + OnBestiaryCommand + ); + + CommandSystem.Register( + "Bestiario", + AccessLevel.Player, + OnBestiaryCommand + ); + + CommandSystem.Register( + "BestiaryTest", + AccessLevel.GameMaster, + OnBestiaryTestCommand + ); + + CommandSystem.Register( + "BestiaryDiag", + AccessLevel.GameMaster, + OnBestiaryDiagCommand + ); + + if (DamageMode == DamageMode.Mark) + { + HunterMarkHook.Configure(); + } + } + + private static void ReloadProfiles() + { + _profiles.Clear(); + + foreach (var (serial, profile) in HunterProfileStore.Load()) + { + _profiles[serial] = profile; + } + } + + public static HunterProfile GetProfile(Mobile m) + { + if (m == null) + { + return null; + } + + if (!_profiles.TryGetValue(m.Serial, out var profile)) + { + profile = new HunterProfile(m.Serial); + _profiles[m.Serial] = profile; + } + + return profile; + } + + public static bool TryGetProfile( + Mobile m, + out HunterProfile profile + ) + { + if (m == null) + { + profile = null; + return false; + } + + return _profiles.TryGetValue(m.Serial, out profile); + } + + public static bool TryGetEffectiveBonus( + Mobile attacker, + BaseCreature victim, + out double bonus + ) + { + bonus = 0; + + if (!Enabled || attacker == null || victim == null) + { + return false; + } + + var entry = BestiaryRegistry.FindEntry(victim.GetType()); + + if (entry == null || + !_profiles.TryGetValue(attacker.Serial, out var profile)) + { + return false; + } + + if (profile.HasLearned(entry.Id)) + { + bonus += PerCreatureBonus; + } + + if (BestiaryRegistry.ByCategory.TryGetValue( + entry.Category, + out var categoryEntries + ) && + categoryEntries.Count > 0) + { + bonus += CategoryMilestoneBonus * + profile.GetCategoryMasteredCount(entry.Category) / + categoryEntries.Count; + } + + if (bonus > TotalBonusCap) + { + bonus = TotalBonusCap; + } + + return bonus > 0; + } + + [Usage("Bestiary")] + [Description("Opens the Hunter's Bestiary.")] + private static void OnBestiaryCommand(CommandEventArgs e) + { + if (e.Mobile is { Deleted: false } from) + { + HunterBestiaryGump.DisplayTo(from); + } + } + + private static void OnWorldSave() + { + HunterProfileStore.Save(_profiles); + } + + private static void OnWorldLoad() + { + ReloadProfiles(); + HunterCombatTracker.Clear(); + HunterDeathHook.ClearProcessedDeaths(); + } + + private static string _configSource = + "modernuo.json NOT FOUND - defaults in use"; + + private static readonly Dictionary _rawSettings = + LoadRawSettings(); + + private static Dictionary LoadRawSettings() + { + var dict = new Dictionary( + StringComparer.OrdinalIgnoreCase + ); + + var searched = new List(); + + try + { + string path = null; + + foreach (var candidate in ConfigCandidates(searched)) + { + if (File.Exists(candidate)) + { + path = candidate; + break; + } + } + + if (path == null) + { + _configSource = + "modernuo.json NOT FOUND; searched: " + + string.Join(" | ", searched); + + return dict; + } + + _configSource = path; + + using var doc = JsonDocument.Parse( + File.ReadAllText(path) + ); + + if (!doc.RootElement.TryGetProperty( + "settings", + out var settings + )) + { + return dict; + } + + foreach (var prop in settings.EnumerateObject()) + { + dict[prop.Name] = + prop.Value.ValueKind == JsonValueKind.String + ? prop.Value.GetString() + : prop.Value.ToString(); + } + } + catch (Exception ex) + { + _configSource = + "modernuo.json read error: " + ex.Message; + } + + return dict; + } + + private static IEnumerable ConfigCandidates( + List searched + ) + { + var roots = new List(); + + foreach (var root in new[] + { + Core.BaseDirectory, + Directory.GetCurrentDirectory(), + AppContext.BaseDirectory + }) + { + try + { + if (string.IsNullOrEmpty(root)) + { + continue; + } + + var full = Path.GetFullPath(root); + + if (!roots.Contains(full)) + { + roots.Add(full); + } + } + catch + { + // Ignore invalid path. + } + } + + var seenDirs = new HashSet( + StringComparer.OrdinalIgnoreCase + ); + + foreach (var root in roots) + { + var dir = root; + + while (!string.IsNullOrEmpty(dir) && + seenDirs.Add(dir)) + { + searched.Add(dir); + + foreach (var sub in new[] + { + "", + "Distribution", + "Configuration", + "Config", + "Configs", + "Server", + "Run" + }) + { + yield return Path.Combine( + dir, + sub, + "modernuo.json" + ); + } + + dir = Directory.GetParent(dir)?.FullName; + } + } + } + + public static bool GetBool( + string key, + bool defaultValue + ) => + _rawSettings.TryGetValue(key, out var s) && + bool.TryParse(s, out var value) + ? value + : defaultValue; + + public static int GetInt( + string key, + int defaultValue + ) => + _rawSettings.TryGetValue(key, out var s) && + int.TryParse( + s, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var value + ) + ? value + : defaultValue; + + public static double GetDouble( + string key, + double defaultValue + ) + { + if (_rawSettings.TryGetValue(key, out var s)) + { + if (double.TryParse( + s, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var value + ) || + double.TryParse( + s, + NumberStyles.Float, + CultureInfo.CurrentCulture, + out value + )) + { + return value; + } + } + + return defaultValue; + } + + public static string GetString( + string key, + string defaultValue + ) => + _rawSettings.TryGetValue(key, out var value) + ? value + : defaultValue; + + public static TimeSpan GetTimeSpan( + string key, + TimeSpan defaultValue + ) => + _rawSettings.TryGetValue(key, out var s) && + TimeSpan.TryParse( + s, + CultureInfo.InvariantCulture, + out var value + ) + ? value + : defaultValue; + + public static TEnum GetEnum( + string key, + TEnum defaultValue + ) + where TEnum : struct => + _rawSettings.TryGetValue(key, out var s) && + Enum.TryParse( + s, + true, + out var value + ) + ? value + : defaultValue; + + public static void DebugLog(string message) + { + if (Debug) + { + _logger.Information("{Msg}", message); + } + } + + [Usage("BestiaryDiag")] + [Description("Shows combat-event counters for bestiary hook diagnostics.")] + private static void OnBestiaryDiagCommand(CommandEventArgs e) + { + if (e.Mobile is not { } from) + { + return; + } + + from.SendMessage( + $"AggressiveAction events seen: " + + $"{HunterCombatTracker.HitCount}; " + + $"tracked creatures: " + + $"{HunterCombatTracker.TrackedCount}." + ); + + from.SendMessage( + "Hit a mob 2-3 times in combat, then run this again. " + + "If the counter does not grow, no AggressiveAction event is received." + ); + } + + [Usage("BestiaryTest")] + [Description("Runs the treatise drop pipeline on the nearest registered creature.")] + private static void OnBestiaryTestCommand(CommandEventArgs e) + { + if (e.Mobile is not PlayerMobile from) + { + return; + } + + BaseCreature target = null; + + foreach (var bc in from.GetMobilesInRange(10)) + { + if (!bc.Deleted && + !bc.Summoned && + !bc.IsBonded && + bc.Hits > 0 && + BestiaryRegistry.FindEntry(bc.GetType()) != null) + { + target = bc; + break; + } + } + + if (target == null) + { + from.SendMessage( + "No registered creature within 10 tiles." + ); + + return; + } + + var entry = BestiaryRegistry.FindEntry(target.GetType()); + + var effectiveChance = GlobalDropChance >= 0 + ? GlobalDropChance + : entry?.DropChance ?? 0; + + from.SendMessage( + $"Running drop pipeline on {target.GetType().Name} " + + $"(chance {effectiveChance:P0})..." + ); + + var result = HunterDeathHook.OnCreatureDeath( + target, + from + ); + + HunterDeathHook.ClearProcessedDeath(target.Serial); + HunterCombatTracker.Forget(target.Serial); + + if (result == null) + { + from.SendMessage( + 0x44, + $"DROP EXECUTED at {effectiveChance:P0} chance. " + + "Check the corpse or the ground." + ); + } + else + { + from.SendMessage( + 0x22, + "NO DROP: " + result + ); + } + + DebugLog( + $"[Bestiary] TEST on {target.GetType().Name}: " + + $"{result ?? "DROP executed"}" + ); + } +} \ No newline at end of file diff --git a/Projects/UOContent/Engines/ExileHunterBestiary/Core/HunterCombatTracker.cs b/Projects/UOContent/Engines/ExileHunterBestiary/Core/HunterCombatTracker.cs new file mode 100644 index 000000000..4087ca9ea --- /dev/null +++ b/Projects/UOContent/Engines/ExileHunterBestiary/Core/HunterCombatTracker.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using Server.Custom.ExileHunterBestiary.Hooks; +using Server.Mobiles; + +namespace Server.Custom.ExileHunterBestiary; + +public static class HunterCombatTracker +{ + private static readonly TimeSpan Expiry = TimeSpan.FromSeconds(60); + + private sealed class Hit + { + public BaseCreature Creature; + public Mobile Master; + public Point3D Location; + public Map Map; + public DateTime When; + } + + private static readonly Dictionary _hits = []; + + private static bool _sweeping; + + public static int HitCount { get; private set; } + + public static int TrackedCount => _hits.Count; + + public static void Note(Mobile aggressor, Mobile aggressed) + { + if (aggressor == null || aggressed is not BaseCreature creature) + { + return; + } + + if (creature.Deleted || creature.Summoned || creature.IsBonded) + { + return; + } + + var master = aggressor is BaseCreature + { + Controlled: true, + ControlMaster: { } controlMaster + } + ? controlMaster + : aggressor; + + if (master is not PlayerMobile) + { + return; + } + + if (!_hits.TryGetValue(creature.Serial, out var hit)) + { + hit = new Hit(); + _hits[creature.Serial] = hit; + } + + hit.Creature = creature; + hit.Master = master; + hit.Location = creature.Location; + hit.Map = creature.Map; + hit.When = DateTime.UtcNow; + + HitCount++; + } + + public static Mobile GetLastHitter(BaseCreature creature) + { + if (creature == null) + { + return null; + } + + if (_hits.TryGetValue(creature.Serial, out var hit) && + DateTime.UtcNow - hit.When <= Expiry && + hit.Master is { Deleted: false }) + { + return hit.Master; + } + + return null; + } + + public static bool TryGetDeathLocation( + BaseCreature creature, + out Point3D location, + out Map map + ) + { + location = Point3D.Zero; + map = null; + + if (creature == null) + { + return false; + } + + if (_hits.TryGetValue(creature.Serial, out var hit) && + DateTime.UtcNow - hit.When <= Expiry && + hit.Map != null && + hit.Map != Map.Internal) + { + location = hit.Location; + map = hit.Map; + + return true; + } + + return false; + } + + public static void Forget(Serial serial) + { + if (!_sweeping) + { + _hits.Remove(serial); + } + } + + public static void Clear() + { + _hits.Clear(); + HitCount = 0; + } + + public static void StartSweeper() + { + Timer.DelayCall( + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(1), + Sweep + ); + } + + private static void Sweep() + { + if (_hits.Count == 0 || _sweeping) + { + return; + } + + _sweeping = true; + + try + { + var now = DateTime.UtcNow; + List remove = null; + + foreach (var (serial, hit) in _hits) + { + var creature = hit.Creature; + + if (creature == null || + creature.Deleted || + now - hit.When > Expiry) + { + (remove ??= []).Add(serial); + continue; + } + + if (creature.Hits > 0) + { + continue; + } + + HunterDeathHook.OnCreatureDeath( + creature, + hit.Master + ); + + (remove ??= []).Add(serial); + } + + if (remove == null) + { + return; + } + + foreach (var serial in remove) + { + _hits.Remove(serial); + } + } + finally + { + _sweeping = false; + } + } +} \ No newline at end of file diff --git a/Projects/UOContent/Engines/ExileHunterBestiary/Core/HunterCreatureDeathEvent.cs b/Projects/UOContent/Engines/ExileHunterBestiary/Core/HunterCreatureDeathEvent.cs new file mode 100644 index 000000000..71f52ff8a --- /dev/null +++ b/Projects/UOContent/Engines/ExileHunterBestiary/Core/HunterCreatureDeathEvent.cs @@ -0,0 +1,22 @@ +using ModernUO.CodeGeneratedEvents; +using Server.Custom.ExileHunterBestiary.Hooks; +using Server.Mobiles; + +namespace Server.Custom.ExileHunterBestiary; + +public static class HunterCreatureDeathEvent +{ + [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] + public static void OnCreatureDeath(BaseCreature creature) + { + if (creature == null) + { + return; + } + + HunterDeathHook.OnCreatureDeath( + creature, + creature.LastKiller + ); + } +} diff --git a/Projects/UOContent/Engines/ExileHunterBestiary/Core/HunterCreatureEntry.cs b/Projects/UOContent/Engines/ExileHunterBestiary/Core/HunterCreatureEntry.cs new file mode 100644 index 000000000..c1e163b44 --- /dev/null +++ b/Projects/UOContent/Engines/ExileHunterBestiary/Core/HunterCreatureEntry.cs @@ -0,0 +1,11 @@ +using System; + +namespace Server.Custom.ExileHunterBestiary; + +public sealed record HunterCreatureEntry( + string Id, + string DisplayName, + Type CreatureType, + BestiaryCategory Category, + int BookHue = 0, + double DropChance = HunterBestiaryEngine.DefaultDropChance); diff --git a/Projects/UOContent/Engines/ExileHunterBestiary/Core/HunterProfile.cs b/Projects/UOContent/Engines/ExileHunterBestiary/Core/HunterProfile.cs new file mode 100644 index 000000000..9e551a6e5 --- /dev/null +++ b/Projects/UOContent/Engines/ExileHunterBestiary/Core/HunterProfile.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; + +namespace Server.Custom.ExileHunterBestiary; + +public sealed class HunterProfile +{ + private readonly Dictionary _killCounters = new(StringComparer.OrdinalIgnoreCase); + + public Serial MobileSerial { get; } + + public HashSet LearnedCreatures { get; } = new(StringComparer.OrdinalIgnoreCase); + + public int TotalMastered => LearnedCreatures.Count; + + public HunterProfile(Serial serial) => MobileSerial = serial; + + public bool HasLearned(string creatureId) => + !string.IsNullOrEmpty(creatureId) && LearnedCreatures.Contains(creatureId); + + + public bool Learn(string creatureId) + { + if (string.IsNullOrEmpty(creatureId) || !BestiaryRegistry.TryGetById(creatureId, out _)) + { + return false; + } + + return LearnedCreatures.Add(creatureId); + } + + public int GetCategoryMasteredCount(BestiaryCategory category) + { + var count = 0; + if (BestiaryRegistry.ByCategory.TryGetValue(category, out var list)) + { + foreach (var entry in list) + { + if (LearnedCreatures.Contains(entry.Id)) + { + count++; + } + } + } + + return count; + } + + + + + + public int GetKillCount(string creatureId) => + _killCounters.TryGetValue(creatureId ?? "", out var kills) ? kills : 0; + + public void SetKillCount(string creatureId, int kills) + { + if (!string.IsNullOrEmpty(creatureId) && kills > 0) + { + _killCounters[creatureId] = kills; + } + } + + + public bool RegisterKill(string creatureId, int pityThreshold) + { + if (string.IsNullOrEmpty(creatureId) || pityThreshold <= 0) + { + return false; + } + + var kills = GetKillCount(creatureId) + 1; + if (kills < pityThreshold) + { + _killCounters[creatureId] = kills; + return false; + } + + _killCounters.Remove(creatureId); + return true; + } + + public IEnumerable> KillCounters => _killCounters; +} diff --git a/Projects/UOContent/Engines/ExileHunterBestiary/Core/HunterProfileStore.cs b/Projects/UOContent/Engines/ExileHunterBestiary/Core/HunterProfileStore.cs new file mode 100644 index 000000000..612c6a1c6 --- /dev/null +++ b/Projects/UOContent/Engines/ExileHunterBestiary/Core/HunterProfileStore.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Xml; +using Server.Logging; + +namespace Server.Custom.ExileHunterBestiary; + +public static class HunterProfileStore +{ + private static readonly ILogger _logger = LogFactory.GetLogger(typeof(HunterProfileStore)); + + private static string SavePath => Path.Combine(Core.BaseDirectory, "Data", "ExileHunterProfiles.xml"); + + public static void Save(IReadOnlyDictionary profiles) + { + try + { + var dir = Path.GetDirectoryName(SavePath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + var doc = new XmlDocument(); + var root = doc.CreateElement("HunterProfiles"); + doc.AppendChild(root); + + foreach (var (serial, profile) in profiles) + { + var hasCounters = false; + foreach (var (_, kills) in profile.KillCounters) + { + if (kills > 0) + { + hasCounters = true; + break; + } + } + + if (profile.TotalMastered == 0 && !hasCounters) + { + continue; + } + + var pElem = doc.CreateElement("Profile"); + pElem.SetAttribute("serial", serial.Value.ToString()); + + foreach (var id in profile.LearnedCreatures) + { + var cElem = doc.CreateElement("Creature"); + cElem.SetAttribute("id", id); + + var kills = profile.GetKillCount(id); + if (kills > 0) + { + cElem.SetAttribute("kills", kills.ToString()); + } + + pElem.AppendChild(cElem); + } + + foreach (var (id, kills) in profile.KillCounters) + { + if (kills <= 0 || profile.HasLearned(id)) + { + continue; + } + + var cElem = doc.CreateElement("Creature"); + cElem.SetAttribute("id", id); + cElem.SetAttribute("kills", kills.ToString()); + pElem.AppendChild(cElem); + } + + root.AppendChild(pElem); + } + + doc.Save(SavePath); + } + catch (Exception ex) + { + _logger.Error(ex, "Error saving bestiary profiles."); + } + } + + public static Dictionary Load() + { + var profiles = new Dictionary(); + + try + { + if (!File.Exists(SavePath)) + { + return profiles; + } + + var doc = new XmlDocument(); + doc.Load(SavePath); + + var list = doc.SelectNodes("//HunterProfiles/Profile"); + if (list == null) + { + return profiles; + } + + foreach (XmlElement pElem in list) + { + if (!int.TryParse(pElem.GetAttribute("serial"), out var sVal)) + { + continue; + } + + var profile = new HunterProfile((Serial)(uint)sVal); + var hasData = false; + + var cList = pElem.SelectNodes("Creature"); + if (cList != null) + { + foreach (XmlElement cElem in cList) + { + var id = cElem.GetAttribute("id"); + + + if (!BestiaryRegistry.TryGetById(id, out _)) + { + continue; + } + + profile.LearnedCreatures.Add(id); + + if (int.TryParse(cElem.GetAttribute("kills"), out var kills) && kills > 0) + { + profile.SetKillCount(id, kills); + } + + hasData = true; + } + } + + if (hasData) + { + profiles[profile.MobileSerial] = profile; + } + } + + _logger.Information("Loaded {Count} bestiary profiles.", profiles.Count); + } + catch (Exception ex) + { + _logger.Error(ex, "Error loading bestiary profiles."); + } + + return profiles; + } +} diff --git a/Projects/UOContent/Engines/ExileHunterBestiary/Gumps/HunterBestiaryGump.cs b/Projects/UOContent/Engines/ExileHunterBestiary/Gumps/HunterBestiaryGump.cs new file mode 100644 index 000000000..4119e79d6 --- /dev/null +++ b/Projects/UOContent/Engines/ExileHunterBestiary/Gumps/HunterBestiaryGump.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using Server.Gumps; +using Server.Network; + +namespace Server.Custom.ExileHunterBestiary.Gumps; + +public sealed class HunterBestiaryGump : DynamicGump +{ + private const int EntriesPerPage = 10; + + private const int ButtonClose = 0; + private const int ButtonNext = 10; + private const int ButtonPrev = 11; + private const int ButtonToggleFilter = 12; + private const int ButtonSearch = 13; + private const int TextSearch = 1; + private const int ButtonCategoryBase = 100; + private const int ButtonCategoryLimit = 150; + + private readonly Mobile _player; + private readonly BestiaryCategory _category; + private readonly int _page; + private readonly string _search; + private readonly bool _hideMastered; + + public override bool Singleton => true; + + private HunterBestiaryGump(Mobile player, BestiaryCategory category, int page, string search, bool hideMastered) + : base(100, 40) + { + _player = player; + _category = category; + _page = page; + _search = search ?? ""; + _hideMastered = hideMastered; + } + + public static void DisplayTo( + Mobile from, BestiaryCategory category = BestiaryCategory.Undead, + int page = 0, string search = "", bool hideMastered = false) + { + if (from is not { Deleted: false } || !HunterBestiaryEngine.Enabled) + { + return; + } + + from.SendGump(new HunterBestiaryGump(from, category, page, search, hideMastered)); + } + + protected override void BuildLayout(ref DynamicGumpBuilder builder) + { + var profile = HunterBestiaryEngine.TryGetProfile(_player, out var p) ? p : null; + var totalMastered = profile?.TotalMastered ?? 0; + var grandTotal = BestiaryRegistry.TotalCount; + var grandPct = grandTotal > 0 ? totalMastered * 100.0 / grandTotal : 0; + + builder.AddPage(); + builder.AddBackground(0, 0, 660, 560, 9270); + builder.AddAlphaRegion(10, 10, 640, 540); + builder.AddBackground(14, 14, 632, 532, 9200); + builder.AddAlphaRegion(18, 18, 624, 524); + + + builder.AddItem(25, 20, 0x2252, 1153); + builder.AddHtml(85, 20, 310, 24, "The Grand Hunter's Bestiary"); + builder.AddHtml(85, 46, 310, 20, "Studied Treatises & Anatomical Lore"); + builder.AddHtml( + 400, 24, 240, 22, + $"Overall: {totalMastered}/{grandTotal} ({grandPct:F0}%)" + ); + builder.AddHtml( + 400, 46, 240, 20, + $"Cap +{HunterBestiaryEngine.TotalBonusCap:P0}" + + $" - {(HunterBestiaryEngine.DamageMode == DamageMode.Mark ? "Hunter's Mark" : "Multiplier")}" + ); + + builder.AddImageTiled(20, 72, 620, 2, 9354); + builder.AddImageTiled(195, 78, 2, 428, 9354); + + + var catY = 82; + foreach (var cat in Enum.GetValues()) + { + var selected = cat == _category; + var catTotal = BestiaryRegistry.ByCategory[cat].Count; + var catMastered = profile?.GetCategoryMasteredCount(cat) ?? 0; + + builder.AddButton(24, catY + 4, selected ? 4006 : 4005, 4007, ButtonCategoryBase + (int)cat); + + var catColor = selected ? "#00FFCC" : catMastered == catTotal && catTotal > 0 ? "#69F0AE" : "#ECEFF1"; + builder.AddHtml( + 60, catY + 5, 130, 20, + $"{cat} ({catMastered}/{catTotal})" + ); + + catY += 34; + } + + + builder.AddButton(24, catY + 8, _hideMastered ? 4006 : 4005, 4007, ButtonToggleFilter); + builder.AddHtml( + 60, catY + 9, 130, 20, + $"Hide mastered" + ); + + + var catList = BestiaryRegistry.ByCategory[_category]; + var catLearned = profile?.GetCategoryMasteredCount(_category) ?? 0; + var catPct = catList.Count > 0 ? catLearned * 100.0 / catList.Count : 0; + + builder.AddHtml(205, 82, 430, 24, $"{_category} Studies"); + builder.AddHtml( + 205, 106, 430, 20, + $"Mastery: {catLearned} / {catList.Count} ({catPct:F0}%)" + + $" - milestone bonus up to +{HunterBestiaryEngine.CategoryMilestoneBonus:P0}" + ); + + + builder.AddTextEntry(205, 132, 300, 22, 0x480, TextSearch, _search); + builder.AddButton(512, 132, 4005, 4007, ButtonSearch); + builder.AddHtml(548, 134, 80, 20, "Search"); + + builder.AddImageTiled(200, 160, 435, 2, 9354); + + + var filtered = new List(catList.Count); + foreach (var entry in catList) + { + var learned = profile != null && profile.HasLearned(entry.Id); + if (_hideMastered && learned) + { + continue; + } + + if (_search.Length > 0 && + entry.DisplayName.Contains(_search, StringComparison.OrdinalIgnoreCase) == false) + { + continue; + } + + filtered.Add(entry); + } + + filtered.Sort(static (a, b) => string.CompareOrdinal(a.DisplayName, b.DisplayName)); + + var maxPages = Math.Max(1, (filtered.Count + EntriesPerPage - 1) / EntriesPerPage); + var page = Math.Clamp(_page, 0, maxPages - 1); + var start = page * EntriesPerPage; + var end = Math.Min(start + EntriesPerPage, filtered.Count); + + var categoryFraction = catList.Count > 0 ? (double)catLearned / catList.Count : 0; + + var entryY = 170; + for (var i = start; i < end; i++) + { + var creature = filtered[i]; + var learned = profile != null && profile.HasLearned(creature.Id); + + + var bonus = (learned ? HunterBestiaryEngine.PerCreatureBonus : 0.0) + + HunterBestiaryEngine.CategoryMilestoneBonus * categoryFraction; + bonus = Math.Min(bonus, HunterBestiaryEngine.TotalBonusCap); + + builder.AddBackground(200, entryY, 435, 32, learned ? 9200 : 9300); + builder.AddAlphaRegion(202, entryY + 2, 431, 28); + builder.AddImage(207, entryY + 5, learned ? 4017 : 4020); + + var nameColor = learned ? "#FFFFFF" : "#B0BEC5"; + builder.AddHtml(250, entryY + 6, 185, 20, $"{creature.DisplayName}"); + builder.AddHtml( + 440, entryY + 6, 190, 20, + learned + ? $"Mastered - +{HunterBestiaryEngine.PerCreatureBonus:P0} (+{bonus:P0} total)" + : $"Pending Treatise - +{bonus:P0} milestone" + ); + + entryY += 36; + } + + + builder.AddImageTiled(20, 520, 620, 2, 9354); + + builder.AddButton(30, 528, 4017, 4019, ButtonClose); + builder.AddHtml(65, 530, 50, 20, "Close"); + + if (page > 0) + { + builder.AddButton(200, 528, 4014, 4016, ButtonPrev); + builder.AddHtml(236, 530, 60, 20, "Previous"); + } + + builder.AddHtml(360, 530, 120, 20, $"Page {page + 1} of {maxPages}"); + + if (page < maxPages - 1) + { + builder.AddButton(560, 528, 4005, 4007, ButtonNext); + builder.AddHtml(590, 530, 40, 20, "Next"); + } + } + + public override void OnResponse(NetState sender, in RelayInfo info) + { + if (sender.Mobile is not { } from) + { + return; + } + + var search = info.GetTextEntry(TextSearch)?.Trim() ?? _search; + + switch (info.ButtonID) + { + case ButtonClose: + return; + case ButtonNext: + DisplayTo(from, _category, _page + 1, search, _hideMastered); + return; + case ButtonPrev: + DisplayTo(from, _category, Math.Max(0, _page - 1), search, _hideMastered); + return; + case ButtonToggleFilter: + DisplayTo(from, _category, 0, search, !_hideMastered); + return; + case ButtonSearch: + DisplayTo(from, _category, 0, search, _hideMastered); + return; + default: + { + if (info.ButtonID is >= ButtonCategoryBase and < ButtonCategoryLimit) + { + DisplayTo(from, (BestiaryCategory)(info.ButtonID - ButtonCategoryBase), 0, search, _hideMastered); + } + + return; + } + } + } +} diff --git a/Projects/UOContent/Engines/ExileHunterBestiary/Hooks/HunterDamageHook.cs b/Projects/UOContent/Engines/ExileHunterBestiary/Hooks/HunterDamageHook.cs new file mode 100644 index 000000000..0c016ce92 --- /dev/null +++ b/Projects/UOContent/Engines/ExileHunterBestiary/Hooks/HunterDamageHook.cs @@ -0,0 +1,46 @@ +using System; +using Server.Mobiles; + +namespace Server.Custom.ExileHunterBestiary.Hooks; + +public static class HunterDamageHook +{ + public static long ApplyCount { get; private set; } + + public static void Apply(BaseCreature victim, Mobile attacker, ref int damage) + { + ApplyCount++; + + if (victim == null || attacker == null || damage <= 0) + { + return; + } + + var master = attacker is BaseCreature { Controlled: true, ControlMaster: { } controlMaster } + ? controlMaster + : attacker; + + if (master is PlayerMobile && + HunterBestiaryEngine.TryGetEffectiveBonus(master, victim, out var bonus)) + { + var scaled = (int)Math.Round(damage * (1.0 + bonus)); + damage = Math.Max(damage + 1, scaled); + } + + if (victim.Hits > 0 && victim.Hits <= damage) + { + var creature = victim; + var killer = attacker; + + Timer.DelayCall(TimeSpan.FromMilliseconds(100), () => + { + if (creature.Deleted || creature.Hits > 0) + { + return; + } + + HunterDeathHook.OnCreatureDeath(creature, killer); + }); + } + } +} \ No newline at end of file diff --git a/Projects/UOContent/Engines/ExileHunterBestiary/Hooks/HunterDeathHook.cs b/Projects/UOContent/Engines/ExileHunterBestiary/Hooks/HunterDeathHook.cs new file mode 100644 index 000000000..35f861ed0 --- /dev/null +++ b/Projects/UOContent/Engines/ExileHunterBestiary/Hooks/HunterDeathHook.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Generic; +using Server.Custom.ExileHunterBestiary.Items; +using Server.Items; +using Server.Mobiles; + +namespace Server.Custom.ExileHunterBestiary.Hooks; + +public static class HunterDeathHook +{ + private static readonly HashSet _processedDeaths = []; + + public static string OnCreatureDeath(BaseCreature creature, Mobile killer) + { + if (!HunterBestiaryEngine.Enabled) + { + return "system disabled"; + } + + if (creature == null) + { + return "creature is null"; + } + + if (creature.Summoned || creature.IsBonded) + { + return "creature filtered (summoned/bonded)"; + } + + var entry = BestiaryRegistry.FindEntry(creature.GetType()); + + if (entry == null) + { + return $"NOT REGISTERED: {creature.GetType().FullName}"; + } + + if (killer == null || killer.Deleted) + { + killer = creature.LastKiller; + } + + if (killer == null || killer.Deleted) + { + killer = HunterCombatTracker.GetLastHitter(creature); + } + + if (killer == null || killer.Deleted) + { + return $"{entry.Id}: no killer resolved"; + } + + var master = killer; + + if (killer is BaseCreature killerCreature) + { + master = killerCreature.GetMaster() ?? killer; + } + + if (master is not PlayerMobile player) + { + HunterCombatTracker.Forget(creature.Serial); + return $"{entry.Id}: killer is not a player"; + } + + if (!_processedDeaths.Add(creature.Serial)) + { + return $"{entry.Id}: already processed"; + } + + var location = creature.Location; + var map = creature.Map; + + if (map == null || map == Map.Internal) + { + HunterCombatTracker.TryGetDeathLocation( + creature, + out location, + out map + ); + } + + HunterCombatTracker.Forget(creature.Serial); + + var profile = HunterBestiaryEngine.GetProfile(player); + + if (profile == null) + { + _processedDeaths.Remove(creature.Serial); + return $"{entry.Id}: profile is null"; + } + + if (profile.HasLearned(entry.Id)) + { + return $"{entry.Id}: already learned by this player"; + } + + var chance = HunterBestiaryEngine.GlobalDropChance >= 0 + ? HunterBestiaryEngine.GlobalDropChance + : entry.DropChance; + + chance = Math.Clamp(chance, 0.0, 1.0); + + var pityDrop = profile.RegisterKill( + entry.Id, + HunterBestiaryEngine.PityThreshold + ); + + if (!pityDrop && Utility.RandomDouble() >= chance) + { + return $"{entry.Id}: rolled above drop chance ({chance:P0})"; + } + + if (map == null || map == Map.Internal) + { + _processedDeaths.Remove(creature.Serial); + return $"{entry.Id}: death location unavailable"; + } + + var treatise = new HunterTreatise(entry.Id); + + var corpse = FindCorpseAt(location, map, creature); + + if (corpse is { Deleted: false }) + { + corpse.DropItem(treatise); + return null; + } + + DropIntoCorpseLater( + treatise, + entry.Id, + creature.Serial, + location, + map, + 0 + ); + + return null; + } + + private static void DropIntoCorpseLater( + HunterTreatise treatise, + string creatureId, + Serial creatureSerial, + Point3D location, + Map map, + int attempt + ) + { + Timer.DelayCall( + TimeSpan.FromMilliseconds(100), + () => + { + if (treatise.Deleted) + { + return; + } + + var corpse = FindCorpseAt(location, map, null); + + if (corpse is { Deleted: false }) + { + corpse.DropItem(treatise); + return; + } + + if (attempt < 10) + { + DropIntoCorpseLater( + treatise, + creatureId, + creatureSerial, + location, + map, + attempt + 1 + ); + + return; + } + + treatise.Delete(); + _processedDeaths.Remove(creatureSerial); + } + ); + } + + private static Corpse FindCorpseAt( + Point3D location, + Map map, + BaseCreature owner + ) + { + if (map == null || map == Map.Internal) + { + return null; + } + + Corpse nearest = null; + var nearestDistance = int.MaxValue; + + foreach (var item in World.Items.Values) + { + if (item is not Corpse corpse || + corpse.Deleted || + corpse.Map != map) + { + continue; + } + + var corpseLocation = corpse.Location; + var distance = Math.Max( + Math.Abs(corpseLocation.X - location.X), + Math.Abs(corpseLocation.Y - location.Y) + ); + + if (distance > 3) + { + continue; + } + + if (owner != null && corpse.Owner == owner) + { + return corpse; + } + + if (distance < nearestDistance) + { + nearest = corpse; + nearestDistance = distance; + } + } + + return nearest; + } + + public static void ClearProcessedDeath(Serial serial) + { + _processedDeaths.Remove(serial); + } + + public static void ClearProcessedDeaths() + { + _processedDeaths.Clear(); + } +} \ No newline at end of file diff --git a/Projects/UOContent/Engines/ExileHunterBestiary/Hooks/HunterMarkHook.cs b/Projects/UOContent/Engines/ExileHunterBestiary/Hooks/HunterMarkHook.cs new file mode 100644 index 000000000..6cd570852 --- /dev/null +++ b/Projects/UOContent/Engines/ExileHunterBestiary/Hooks/HunterMarkHook.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using Server.Logging; +using Server.Mobiles; + +namespace Server.Custom.ExileHunterBestiary.Hooks; + +public static class HunterMarkHook +{ + private static readonly ILogger _logger = LogFactory.GetLogger(typeof(HunterMarkHook)); + + private sealed class MarkState + { + public BaseCreature Creature; + public Mobile Master; + public DateTime ExpiresAt; + public DateTime NextTickAt; + } + + private static readonly Dictionary _marks = []; + + + + private static bool _applyingMark; + + public static TimeSpan MarkDuration { get; private set; } = TimeSpan.FromSeconds(10); + public static TimeSpan TickInterval { get; private set; } = TimeSpan.FromSeconds(2); + public static double TickPercentOfMaxHits { get; private set; } = 0.01; + + public static void Configure() + { + MarkDuration = HunterBestiaryEngine.GetTimeSpan("exileBestiary.damage.markDuration", TimeSpan.FromSeconds(10)); + TickInterval = HunterBestiaryEngine.GetTimeSpan("exileBestiary.damage.markTickSeconds", TimeSpan.FromSeconds(2)); + TickPercentOfMaxHits = HunterBestiaryEngine.GetDouble("exileBestiary.damage.markTickPercent", 0.01); + + EventSink.AggressiveAction += OnAggressiveAction; + Timer.DelayCall(TickInterval, TickInterval, Tick); + + _logger.Information( + "Hunter's Mark enabled: {Duration}s mark, {Interval}s ticks, {Percent:P0} of max hits per tick.", + MarkDuration, TickInterval, TickPercentOfMaxHits + ); + } + + private static void OnAggressiveAction(AggressiveActionEventArgs args) + { + if (_applyingMark || + !HunterBestiaryEngine.Enabled || + args.Aggressed is not BaseCreature creature || + creature is { Deleted: true } or { Summoned: true } or { IsBonded: true }) + { + return; + } + + var entry = BestiaryRegistry.FindEntry(creature.GetType()); + if (entry == null) + { + return; + } + + var master = args.Aggressor is BaseCreature { Controlled: true, ControlMaster: { } controlMaster } + ? controlMaster + : args.Aggressor; + + if (master is not PlayerMobile || + !HunterBestiaryEngine.TryGetEffectiveBonus(master, creature, out var bonus)) + { + return; + } + + var now = DateTime.UtcNow; + if (!_marks.TryGetValue(creature.Serial, out var state)) + { + state = new MarkState { Creature = creature }; + _marks[creature.Serial] = state; + } + + state.Master = master; + state.ExpiresAt = now + MarkDuration; + if (state.NextTickAt < now) + { + state.NextTickAt = now + TickInterval; + } + } + + private static void Tick() + { + if (_marks.Count == 0) + { + return; + } + + var now = DateTime.UtcNow; + List expired = null; + + foreach (var (serial, state) in _marks) + { + var creature = state.Creature; + var master = state.Master; + + if (creature.Deleted || creature.Hits <= 0 || + master is not { Deleted: false } || + now >= state.ExpiresAt || + !master.InRange(creature.Location, 16)) + { + (expired ??= []).Add(serial); + continue; + } + + if (now < state.NextTickAt) + { + continue; + } + + state.NextTickAt = now + TickInterval; + + if (!HunterBestiaryEngine.TryGetEffectiveBonus(master, creature, out var bonus)) + { + (expired ??= []).Add(serial); + continue; + } + + + + var intensity = HunterBestiaryEngine.TotalBonusCap > 0 + ? bonus / HunterBestiaryEngine.TotalBonusCap + : 0; + + var amount = (int)Math.Max(1, Math.Round(creature.HitsMax * TickPercentOfMaxHits * intensity)); + + _applyingMark = true; + try + { + creature.Damage(amount, master); + } + finally + { + _applyingMark = false; + } + } + + if (expired != null) + { + foreach (var serial in expired) + { + _marks.Remove(serial); + } + } + } +} diff --git a/Projects/UOContent/Engines/ExileHunterBestiary/INSTALLATION_GUIDE.txt b/Projects/UOContent/Engines/ExileHunterBestiary/INSTALLATION_GUIDE.txt new file mode 100644 index 000000000..79fc96d96 --- /dev/null +++ b/Projects/UOContent/Engines/ExileHunterBestiary/INSTALLATION_GUIDE.txt @@ -0,0 +1,278 @@ +=============================================== + EXILE HUNTER'S BESTIARY - ModernUO EDITION + Final stable version + Author: Imagine + Refactored: Expa +=============================================== + +INSTALL + + 1. Copy the ExileHunterBestiary folder to: + + Projects/UOContent/Custom/ExileHunterBestiary/ + + 2. Make sure the generated death-event file exists: + + HunterCreatureDeathEvent.cs + + 3. Build the project: + + dotnet build + + 4. Start the server. + + IMPORTANT: + + The final version does not require any changes to BaseCreature.cs. + + Do not add: + - EventSink.CreatureDeath + - a custom Kill() override + - a custom OnDeath() override + - a BaseCreature damage patch + + Do not keep this legacy file: + + BaseCreature.ExileHunterBestiary.cs + + Do not keep old patch files such as: + + PATCH_BaseCreature.txt + + +ACTIVE DEATH HANDLER + + Creature deaths are handled by the generated ModernUO event: + + BaseCreature.CreatureDeathEvent + + The handler is located in: + + HunterCreatureDeathEvent.cs + + This is not an EventSink event. Do not use: + + EventSink.CreatureDeath + + +COMBAT TRACKING + + HunterCombatTracker receives player combat activity through: + + EventSink.AggressiveAction + + The tracker stores: + + - the last player attacker; + - the player's pet owner when applicable; + - the creature serial; + - the creature location; + - the creature map; + - the time of the last hit. + + The stored location is used because ModernUO can remove the creature + before the generated death event is handled. + + +CORPSE DROPS + + The treatise is placed into the creature's corpse. + + If the corpse is not available immediately, the system retries for a + short period. + + The system does not intentionally place the treatise on the ground. + + If no corpse can be found after the retry period, the drop is canceled. + + +CONFIGURATION + + Add these values inside the "settings" object in modernuo.json: + + "exileBestiary.enabled": "True", + "exileBestiary.debug": "False", + "exileBestiary.damage.mode": "Mark", + "exileBestiary.damage.perCreatureBonus": "0.10", + "exileBestiary.damage.categoryMilestoneBonus": "0.20", + "exileBestiary.damage.totalCap": "0.25", + "exileBestiary.damage.markDuration": "00:00:10", + "exileBestiary.damage.markTickSeconds": "00:00:02", + "exileBestiary.damage.markTickPercent": "0.01", + "exileBestiary.drop.chance": "-1.0", + "exileBestiary.drop.pityThreshold": "40" + + Drop chance values: + + -1.0 Use the creature's registered chance + 1.0 100% chance + 0.5 50% chance + 0.02 2% chance + 0.0 No random drops + + Example for guaranteed drops: + + "exileBestiary.drop.chance": "1.0" + + The configuration must be inside: + + { + "settings": { + ... + } + } + + +DAMAGE MODE + + The recommended mode is: + + "exileBestiary.damage.mode": "Mark" + + Mark mode is patch-free and does not require BaseCreature.cs changes. + + The mark system uses: + + - EventSink.AggressiveAction + - a timer + - public ModernUO APIs + + It applies periodic bonus damage while the hunter remains near + the marked creature. + + +COMMANDS + + [Bestiary + + Opens the Hunter's Bestiary. + + [Bestiario + + Alias for [Bestiary. + + [BestiaryTest + + Game Master command that runs the treatise pipeline against the nearest + registered creature. + + The command uses the configured chance and is intended for testing. + + [BestiaryDiag + + Game Master diagnostic command that displays combat-tracker counters. + + +STARTUP CHECK + + The server should report a bestiary initialization message similar to: + + Initialized with N creatures across 8 categories. + + When Mark mode is enabled, it should also report: + + Hunter's Mark enabled ... + + +TROUBLESHOOTING + + No treatise appears: + + 1. Confirm the creature exists in BestiaryData.cs. + 2. Confirm the creature type was registered at startup. + 3. Confirm exileBestiary.enabled is True. + 4. Confirm the player actually damaged the creature. + 5. Confirm EventSink.AggressiveAction is receiving events. + 6. Confirm the generated event file exists. + 7. Confirm no old BaseCreature patch file remains. + 8. Set exileBestiary.drop.chance to 1.0. + 9. Temporarily set exileBestiary.debug to True. + + No combat events are tracked: + + Run: + + [BestiaryDiag + + The AggressiveAction counter should increase after attacking a creature. + + Log says "killer not resolved": + + The system did not receive LastKiller and did not find a valid player + in HunterCombatTracker. + + Log says "killer is not a player": + + The kill was credited to an NPC, an uncontrolled creature, or another + non-player source. + + Log says "creature not registered": + + The creature type is not present in BestiaryData.cs or could not be + resolved by AssemblyHandler.FindTypeByName. + + Treatise is already learned: + + A player receives only one treatise for a creature that has already + been mastered. Additional drops are blocked for that player. + + Treatise is not visible: + + Open the creature corpse and check its contents. The treatise is not + automatically placed into the player's backpack. + + +SAVE DATA + + Profiles are stored at: + + Data/ExileHunterProfiles.xml + + Saved data includes: + + - learned creature IDs; + - pity counters; + - player serials. + + Invalid creature IDs are ignored during loading. + + +FILES TO KEEP + + BestiaryCategory.cs + BestiaryData.cs + BestiaryRegistry.cs + DamageMode.cs + HunterBestiaryEngine.cs + HunterCombatTracker.cs + HunterCreatureDeathEvent.cs + HunterCreatureEntry.cs + HunterProfile.cs + HunterProfileStore.cs + HunterBestiaryGump.cs + HunterDamageHook.cs + HunterDeathHook.cs + HunterMarkHook.cs + HunterBestiary.cs + HunterTreatise.cs + + +LEGACY FILES TO REMOVE + + BaseCreature.ExileHunterBestiary.cs + PATCH_BaseCreature.txt + + Also remove any legacy bestiary file that overrides: + + BaseCreature.Kill() + BaseCreature.OnBeforeDeath() + BaseCreature.OnDeath() + + +PRODUCTION SETTINGS + + After confirming the system works, use: + + "exileBestiary.debug": "False" + + Keep debug enabled only while diagnosing the drop pipeline. \ No newline at end of file diff --git a/Projects/UOContent/Engines/ExileHunterBestiary/Items/HunterBestiary.cs b/Projects/UOContent/Engines/ExileHunterBestiary/Items/HunterBestiary.cs new file mode 100644 index 000000000..7c796393e --- /dev/null +++ b/Projects/UOContent/Engines/ExileHunterBestiary/Items/HunterBestiary.cs @@ -0,0 +1,66 @@ +using ModernUO.Serialization; +using Server.Custom.ExileHunterBestiary.Gumps; +using Server.Network; + +namespace Server.Custom.ExileHunterBestiary.Items; + +[SerializationGenerator(0)] +public partial class HunterBestiary : Item +{ + public const int DefaultBestiaryHue = 1153; + + [Constructible] + public HunterBestiary() : base(0x2252) + { + Name = "The Grand Hunter's Bestiary"; + Hue = DefaultBestiaryHue; + Weight = 2.0; + LootType = LootType.Blessed; + } + + public HunterBestiary(Serial serial) : base(serial) + { + } + + public override void OnDoubleClick(Mobile from) + { + if (!IsChildOf(from.Backpack)) + { + from.SendLocalizedMessage(1042001); + return; + } + + HunterBestiaryGump.DisplayTo(from); + } + + public override void GetProperties(IPropertyList list) + { + base.GetProperties(list); + + list.Add(1070722, $"{"Grand Grimoire of Monster Lore"}"); + list.Add(1070722, $"{"Tracks mastered treatises and combat bonuses"}"); + list.Add(1070722, $"{"Double-click to open Bestiary"}"); + } + + public override void SendPropertiesTo(NetState ns) + { + if (ns?.Mobile is not { } from) + { + return; + } + + var list = new ObjectPropertyList(this); + GetProperties(list); + + var mastered = HunterBestiaryEngine.TryGetProfile(from, out var profile) ? profile.TotalMastered : 0; + var total = BestiaryRegistry.TotalCount; + + list.Add( + 1070722, + $"{$"Mastery Progress: {mastered} / {total} Creatures"}" + ); + + list.Terminate(); + ns.Send(list.Buffer); + } +} diff --git a/Projects/UOContent/Engines/ExileHunterBestiary/Items/HunterTreatise.cs b/Projects/UOContent/Engines/ExileHunterBestiary/Items/HunterTreatise.cs new file mode 100644 index 000000000..38a49d06b --- /dev/null +++ b/Projects/UOContent/Engines/ExileHunterBestiary/Items/HunterTreatise.cs @@ -0,0 +1,130 @@ +using ModernUO.Serialization; +using Server.Network; + +namespace Server.Custom.ExileHunterBestiary.Items; + +[SerializationGenerator(0)] +public partial class HunterTreatise : Item +{ + [SerializableField(0)] + [InvalidateProperties] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private string _creatureId; + + public HunterCreatureEntry Entry => + BestiaryRegistry.TryGetById(_creatureId, out var entry) ? entry : null; + + [Constructible] + public HunterTreatise(string creatureId) : base(0x1F5F) + { + _creatureId = creatureId; + Weight = 1.0; + LootType = LootType.Regular; + UpdateAppearance(false); + } + + [Constructible] + public HunterTreatise() : this("Lich") + { + } + + public HunterTreatise(Serial serial) : base(serial) + { + } + + [AfterDeserialization] + private void AfterDeserialization() => UpdateAppearance(false); + + private void UpdateAppearance(bool invalidate) + { + ItemID = 0x1F5F; + + if (Entry is { } entry) + { + Name = $"Hunter's Treatise: {entry.DisplayName}"; + Hue = entry.BookHue != 0 ? entry.BookHue : 2101; + } + else + { + Name = $"Hunter's Treatise: {_creatureId}"; + Hue = 2101; + } + + if (invalidate) + { + InvalidateProperties(); + } + } + + public override void OnDoubleClick(Mobile from) + { + if (!IsChildOf(from.Backpack)) + { + from.SendLocalizedMessage(1042001); + return; + } + + var profile = HunterBestiaryEngine.GetProfile(from); + if (profile == null) + { + return; + } + + var displayName = Entry?.DisplayName ?? _creatureId ?? "Unknown"; + + if (profile.HasLearned(_creatureId)) + { + from.SendMessage(0x22, $"You have already mastered the study of {displayName}. Keep this treatise to trade with other hunters!"); + from.PlaySound(0x5C); + return; + } + + if (profile.Learn(_creatureId)) + { + from.SendMessage( + 0x3F, + $"You thoroughly study the treatise and permanently unlock +{HunterBestiaryEngine.PerCreatureBonus:P0} damage against {displayName}!" + ); + from.FixedParticles(0x373A, 10, 15, 5012, EffectLayer.Waist); + from.PlaySound(0x249); + Consume(); + } + } + + public override void GetProperties(IPropertyList list) + { + base.GetProperties(list); + + var creatureName = Entry?.DisplayName ?? _creatureId ?? "Unknown"; + var category = Entry?.Category.ToString() ?? "Unknown"; + + list.Add( + 1070722, + $"{$"Hunter's Study: +{HunterBestiaryEngine.PerCreatureBonus:P0} vs {creatureName}"}" + ); + list.Add(1070722, $"{$"Category: {category}"}"); + list.Add(1070722, $"{"Double-click to permanently master"}"); + } + + public override void SendPropertiesTo(NetState ns) + { + if (ns?.Mobile is not { } from) + { + return; + } + + var list = new ObjectPropertyList(this); + GetProperties(list); + + var learned = HunterBestiaryEngine.TryGetProfile(from, out var profile) && profile.HasLearned(_creatureId); + list.Add( + 1070722, + learned + ? $"{"[Already Learned]"}" + : $"{"[Not Learned Yet]"}" + ); + + list.Terminate(); + ns.Send(list.Buffer); + } +}