#W# Added: Expa's Exile Hunter Bestiary system

This commit is contained in:
WarrentyExpired 2026-09-21 10:16:46 -04:00
parent 86d28f32a2
commit 625b9eee1b
18 changed files with 2586 additions and 0 deletions

View file

@ -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.

View file

@ -0,0 +1,13 @@
namespace Server.Custom.ExileHunterBestiary;
public enum BestiaryCategory
{
Undead,
Daemons,
Reptiles,
Elementals,
Humanoids,
Arachnids,
Beasts,
Bosses
}

View file

@ -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);
}
}

View file

@ -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<string, HunterCreatureEntry> _byId = new(StringComparer.OrdinalIgnoreCase);
private static Dictionary<Type, HunterCreatureEntry> _byType = [];
private static Dictionary<BestiaryCategory, List<HunterCreatureEntry>> _byCategory = [];
private static readonly Dictionary<Type, HunterCreatureEntry> _resolutionCache = [];
public static int TotalCount => _byId.Count;
public static IReadOnlyDictionary<BestiaryCategory, List<HunterCreatureEntry>> ByCategory => _byCategory;
public static void Initialize()
{
_byId = new Dictionary<string, HunterCreatureEntry>(StringComparer.OrdinalIgnoreCase);
_byType = new Dictionary<Type, HunterCreatureEntry>();
_byCategory = new Dictionary<BestiaryCategory, List<HunterCreatureEntry>>();
_resolutionCache.Clear();
foreach (var category in Enum.GetValues<BestiaryCategory>())
{
_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;
}
}

View file

@ -0,0 +1,7 @@
namespace Server.Custom.ExileHunterBestiary;
public enum DamageMode
{
Mark,
Multiplier
}

View file

@ -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<Serial, HunterProfile> _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<string, string> _rawSettings =
LoadRawSettings();
private static Dictionary<string, string> LoadRawSettings()
{
var dict = new Dictionary<string, string>(
StringComparer.OrdinalIgnoreCase
);
var searched = new List<string>();
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<string> ConfigCandidates(
List<string> searched
)
{
var roots = new List<string>();
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<string>(
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<TEnum>(
string key,
TEnum defaultValue
)
where TEnum : struct =>
_rawSettings.TryGetValue(key, out var s) &&
Enum.TryParse<TEnum>(
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<BaseCreature>(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"}"
);
}
}

View file

@ -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<Serial, Hit> _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<Serial> 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;
}
}
}

View file

@ -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
);
}
}

View file

@ -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);

View file

@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
namespace Server.Custom.ExileHunterBestiary;
public sealed class HunterProfile
{
private readonly Dictionary<string, int> _killCounters = new(StringComparer.OrdinalIgnoreCase);
public Serial MobileSerial { get; }
public HashSet<string> 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<KeyValuePair<string, int>> KillCounters => _killCounters;
}

View file

@ -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<Serial, HunterProfile> 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<Serial, HunterProfile> Load()
{
var profiles = new Dictionary<Serial, HunterProfile>();
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;
}
}

View file

@ -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, "<BASEFONT COLOR=#FFFFFF SIZE=5>The Grand Hunter's Bestiary</BASEFONT>");
builder.AddHtml(85, 46, 310, 20, "<BASEFONT COLOR=#D1C4E9 SIZE=3>Studied Treatises & Anatomical Lore</BASEFONT>");
builder.AddHtml(
400, 24, 240, 22,
$"<BASEFONT COLOR=#ECEFF1>Overall: <BASEFONT COLOR=#00FFCC>{totalMastered}/{grandTotal}</BASEFONT> ({grandPct:F0}%)</BASEFONT>"
);
builder.AddHtml(
400, 46, 240, 20,
$"<BASEFONT COLOR=#FFE082>Cap +{HunterBestiaryEngine.TotalBonusCap:P0}"
+ $" - {(HunterBestiaryEngine.DamageMode == DamageMode.Mark ? "Hunter's Mark" : "Multiplier")}</BASEFONT>"
);
builder.AddImageTiled(20, 72, 620, 2, 9354);
builder.AddImageTiled(195, 78, 2, 428, 9354);
var catY = 82;
foreach (var cat in Enum.GetValues<BestiaryCategory>())
{
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,
$"<BASEFONT COLOR={catColor}>{cat}</BASEFONT> <BASEFONT COLOR=#90A4AE>({catMastered}/{catTotal})</BASEFONT>"
);
catY += 34;
}
builder.AddButton(24, catY + 8, _hideMastered ? 4006 : 4005, 4007, ButtonToggleFilter);
builder.AddHtml(
60, catY + 9, 130, 20,
$"<BASEFONT COLOR={(_hideMastered ? "#00FFCC" : "#ECEFF1")}>Hide mastered</BASEFONT>"
);
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, $"<BASEFONT COLOR=#FFFFFF SIZE=4>{_category} Studies</BASEFONT>");
builder.AddHtml(
205, 106, 430, 20,
$"<BASEFONT COLOR=#ECEFF1>Mastery: <BASEFONT COLOR=#00FFCC>{catLearned} / {catList.Count}</BASEFONT> ({catPct:F0}%)"
+ $" - milestone bonus up to +{HunterBestiaryEngine.CategoryMilestoneBonus:P0}</BASEFONT>"
);
builder.AddTextEntry(205, 132, 300, 22, 0x480, TextSearch, _search);
builder.AddButton(512, 132, 4005, 4007, ButtonSearch);
builder.AddHtml(548, 134, 80, 20, "<BASEFONT COLOR=#ECEFF1>Search</BASEFONT>");
builder.AddImageTiled(200, 160, 435, 2, 9354);
var filtered = new List<HunterCreatureEntry>(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, $"<BASEFONT COLOR={nameColor}>{creature.DisplayName}</BASEFONT>");
builder.AddHtml(
440, entryY + 6, 190, 20,
learned
? $"<BASEFONT COLOR=#69F0AE>Mastered - +{HunterBestiaryEngine.PerCreatureBonus:P0} (+{bonus:P0} total)</BASEFONT>"
: $"<BASEFONT COLOR=#78909C>Pending Treatise - +{bonus:P0} milestone</BASEFONT>"
);
entryY += 36;
}
builder.AddImageTiled(20, 520, 620, 2, 9354);
builder.AddButton(30, 528, 4017, 4019, ButtonClose);
builder.AddHtml(65, 530, 50, 20, "<BASEFONT COLOR=#FFFFFF>Close</BASEFONT>");
if (page > 0)
{
builder.AddButton(200, 528, 4014, 4016, ButtonPrev);
builder.AddHtml(236, 530, 60, 20, "<BASEFONT COLOR=#ECEFF1>Previous</BASEFONT>");
}
builder.AddHtml(360, 530, 120, 20, $"<BASEFONT COLOR=#B0BEC5>Page {page + 1} of {maxPages}</BASEFONT>");
if (page < maxPages - 1)
{
builder.AddButton(560, 528, 4005, 4007, ButtonNext);
builder.AddHtml(590, 530, 40, 20, "<BASEFONT COLOR=#ECEFF1>Next</BASEFONT>");
}
}
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;
}
}
}
}

View file

@ -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);
});
}
}
}

View file

@ -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<Serial> _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();
}
}

View file

@ -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<Serial, MarkState> _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<Serial> 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);
}
}
}
}

View file

@ -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.

View file

@ -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, $"{"<BASEFONT COLOR=#D1C4E9>Grand Grimoire of Monster Lore<BASEFONT COLOR=#FFFFFF>"}");
list.Add(1070722, $"{"<BASEFONT COLOR=#ECEFF1>Tracks mastered treatises and combat bonuses<BASEFONT COLOR=#FFFFFF>"}");
list.Add(1070722, $"{"<BASEFONT COLOR=#FFE082>Double-click to open Bestiary<BASEFONT COLOR=#FFFFFF>"}");
}
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,
$"{$"<BASEFONT COLOR=#00FFCC>Mastery Progress: {mastered} / {total} Creatures<BASEFONT COLOR=#FFFFFF>"}"
);
list.Terminate();
ns.Send(list.Buffer);
}
}

View file

@ -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,
$"{$"<BASEFONT COLOR=#D1C4E9>Hunter's Study: +{HunterBestiaryEngine.PerCreatureBonus:P0} vs {creatureName}<BASEFONT COLOR=#FFFFFF>"}"
);
list.Add(1070722, $"{$"<BASEFONT COLOR=#ECEFF1>Category: {category}<BASEFONT COLOR=#FFFFFF>"}");
list.Add(1070722, $"{"<BASEFONT COLOR=#FFE082>Double-click to permanently master<BASEFONT COLOR=#FFFFFF>"}");
}
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
? $"{"<BASEFONT COLOR=#00E676>[Already Learned]<BASEFONT COLOR=#FFFFFF>"}"
: $"{"<BASEFONT COLOR=#00E5FF>[Not Learned Yet]<BASEFONT COLOR=#FFFFFF>"}"
);
list.Terminate();
ns.Send(list.Buffer);
}
}