#W# Added: Expa's Exile Hunter Bestiary system
This commit is contained in:
parent
86d28f32a2
commit
625b9eee1b
18 changed files with 2586 additions and 0 deletions
69
Projects/UOContent/Engines/ExileHunterBestiary/CHANGES_v2.md
Normal file
69
Projects/UOContent/Engines/ExileHunterBestiary/CHANGES_v2.md
Normal 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.
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
namespace Server.Custom.ExileHunterBestiary;
|
||||
|
||||
public enum BestiaryCategory
|
||||
{
|
||||
Undead,
|
||||
Daemons,
|
||||
Reptiles,
|
||||
Elementals,
|
||||
Humanoids,
|
||||
Arachnids,
|
||||
Beasts,
|
||||
Bosses
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace Server.Custom.ExileHunterBestiary;
|
||||
|
||||
public enum DamageMode
|
||||
{
|
||||
Mark,
|
||||
Multiplier
|
||||
}
|
||||
|
|
@ -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"}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue