diff --git a/ToAdd/AutoBandCommand.cs b/ToAdd/AutoBandCommand.cs new file mode 100644 index 000000000..abd6226d4 --- /dev/null +++ b/ToAdd/AutoBandCommand.cs @@ -0,0 +1,180 @@ +/********************************** +* Author: Expa (refactored) * +* Script Name: AutoBandCommand.cs * +* Target: ModernUO (.NET 10/C#14) * +**********************************/ +using System; +using ModernUO.Serialization; +using Server; +using Server.Commands; +using Server.Items; +using Server.Mobiles; +using Server.Targeting; + +namespace Server.Scripts.Commands; + +public static class AutoBandCommand +{ + public static void Initialize() + { + CommandSystem.Register("bas", AccessLevel.Player, OnBandSelf); + CommandSystem.Register("bat", AccessLevel.Player, OnBandTarget); + } + + public static void OnBandSelf(CommandEventArgs e) + { + if (e.Mobile is not PlayerMobile pm) return; + Start(pm, pm); + } + + public static void OnBandTarget(CommandEventArgs e) + => e.Mobile.BeginTarget(-1, false, TargetFlags.None, OnTarget); + + private static void OnTarget(Mobile from, object targeted) + { + if (from is not PlayerMobile pm || targeted is not Mobile target || target.Deleted || !target.Alive) + { + from.SendMessage("Invalid target."); + return; + } + + Start(pm, target); + } + + private static void Start(PlayerMobile pm, Mobile target) + { + if (pm.AutoBandageTimer is AutoBandageTimer existing && existing.Targets(target)) + { + StopTimer(pm); + SendHealthMessage(pm, $"Bandage auto-heal DISABLED{(pm == target ? "" : $" on {target.Name}")}."); + return; + } + + StopTimer(pm); + + var band = pm.Backpack?.FindItemByType(); + if (band == null || band.Amount <= 0) + { + pm.SendMessage("You have no bandages!"); + return; + } + + var timer = new AutoBandageTimer(pm, target); + pm.AutoBandageTimer = timer; + timer.Start(); + + SendHealthMessage(pm, $"Bandage auto-heal ENABLED{(pm == target ? "" : $" on {target.Name}")}. Health: {target.Hits}/{target.HitsMax}"); + } + + internal static void SendHealthMessage(Mobile m, string msg) + { + var denom = Math.Max(1, m.HitsMax); + double p = (double)m.Hits / denom; + var hue = p < 0.3 ? 33 : p < 0.7 ? 53 : 63; + m.SendMessage(hue, msg); + } + + private static void StopTimer(PlayerMobile pm) + { + pm.AutoBandageTimer?.Stop(); + pm.AutoBandageTimer = null; + } +} + +[SerializationGenerator(0)] +public partial class AutoBandageTimer : Timer +{ + private const double ResumeAt = 90.0; + private const double PauseAt = 100.0; + private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(100); + + private readonly WeakReference _casterRef; + private readonly WeakReference _targetRef; + private bool _paused; + private DateTime _next; + + public AutoBandageTimer(Mobile caster, Mobile target) + : base(TimeSpan.Zero, TickInterval) + { + _casterRef = new(caster); + _targetRef = new(target); + _next = Core.Now; + } + + public bool Targets(Mobile m) + => m is not null && _targetRef.TryGetTarget(out var t) && t == m; + + protected override void OnTick() + { + if (!_casterRef.TryGetTarget(out var caster) || caster.Deleted || !caster.Alive || caster.Map == Map.Internal) + { + Stop(); + Cleanup(caster); + return; + } + + if (!_targetRef.TryGetTarget(out var target) || target.Deleted || !target.Alive || target.Map == Map.Internal) + { + caster.SendMessage("Target is no longer valid. Automatic bandaging stopped."); + Stop(); + Cleanup(caster); + return; + } + + if (BandageContext.GetContext(caster) != null) + return; + + var denom = Math.Max(1, target.HitsMax); + double hpPercent = (double)target.Hits / denom * 100.0; + + if (!_paused && hpPercent >= PauseAt) + { + _paused = true; + caster.SendMessage("Bandaging paused. Target's health is full."); + return; + } + + if (_paused && hpPercent < ResumeAt) + { + _paused = false; + caster.SendMessage($"Bandaging resumed. Target's health dropped below {ResumeAt}%."); + _next = Core.Now; + } + + if (_paused || Core.Now < _next) return; + + var band = caster.Backpack?.FindItemByType(); + if (band == null || band.Amount <= 0) + { + caster.SendMessage("Out of bandages. Automatic bandaging stopped."); + Stop(); + Cleanup(caster); + return; + } + + int preHits = target.Hits; + double delaySec = 5.0 + 0.05 * Math.Max(0, 120 - caster.Dex); + _next = Core.Now + TimeSpan.FromSeconds(delaySec); + + Bandage.BandageTargetRequest(caster, band, target); + Timer.DelayCall(TimeSpan.FromSeconds(delaySec * 0.95), () => ShowResult(caster, target, preHits)); + } + + private static void ShowResult(Mobile? caster, Mobile? target, int preHits) + { + if (caster == null || target == null) return; + if (caster.Deleted || !caster.Alive || target.Deleted || !target.Alive) return; + + int healed = target.Hits - preHits; + string msg = healed > 0 + ? $"Healing {target.Name}: {target.Hits}/{target.HitsMax} HP (+{healed})" + : $"Healing {target.Name}: {target.Hits}/{target.HitsMax} HP"; + + AutoBandCommand.SendHealthMessage(caster, msg); + } + + private static void Cleanup(Mobile? caster) + { + if (caster is PlayerMobile pm) pm.AutoBandageTimer = null; + } +} \ No newline at end of file diff --git a/ToAdd/GraveRobbing/GraveRobbingCore.cs b/ToAdd/GraveRobbing/GraveRobbingCore.cs new file mode 100644 index 000000000..edb42f172 --- /dev/null +++ b/ToAdd/GraveRobbing/GraveRobbingCore.cs @@ -0,0 +1,815 @@ +/********************************** +* Author: Unknow * +* Update/Fixed/Refactoring: Expa * +* Script: GraveRobbingCore.cs * +* Optimized for ModernUO 0.15.4.18* +**********************************/ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using ModernUO.Serialization; +using Server; +using Server.Items; +using Server.Mobiles; +using Server.Engines.Harvest; +using Server.Network; + +static class GRUtil +{ + public static double GetSkillValue(Mobile m, SkillName s) => m?.Skills?[s]?.Value ?? 0.0; +} + +namespace Server.Engines.Harvest +{ + [SerializationGenerator(0, false)] + public partial class GraveRobbing : HarvestSystem + { + static GraveRobbing _system; + public static GraveRobbing System => _system ??= new GraveRobbing(); + + readonly HarvestDefinition _def; + public HarvestDefinition Definition => _def; + + private GraveRobbing() + { + _def = new HarvestDefinition + { + BankWidth = 2, + BankHeight = 2, + MinTotal = 1, + MaxTotal = 2, + MinRespawn = TimeSpan.FromMinutes(20), + MaxRespawn = TimeSpan.FromMinutes(30), + Skill = SkillName.Mining, + MaxRange = 2, + ConsumedPerHarvest = 1, + ConsumedPerFeluccaHarvest = 1, + EffectActions = new[] { 13 }, + EffectSounds = new[] { 0x13E }, + EffectCounts = new[] { 1 }, + StaticTiles = new[] { 0xED3, 0xEDF, 0xEE0, 0xEE1, 0xEE2, 0xEE8 }, + RangedTiles = false, + NoResourcesMessage = "There is nothing here to dig up.", + FailMessage = "You dig but find nothing of value.", + OutOfRangeMessage = 500446, + TimedOutOfRangeMessage = 503041, + DoubleHarvestMessage = 503042, + PackFullMessage = 1010481, + ToolBrokeMessage = 1044038 + }; + + _def.Resources = new[] + { + new HarvestResource(60, 10, 150, "You dig up some bones.", typeof(Bone)), + new HarvestResource(40, 30, 150, "You uncover some gold.", typeof(Gold)) + }; + + _def.Veins = new[] + { + new HarvestVein(70, 0, _def.Resources[0], null), + new HarvestVein(30, 0, _def.Resources[1], null) + }; + + Definitions = new[] { _def }; + + PandoraChestConfig.Load(); + PandoraChestLogger.Initialize(); + PandoraChestSpawnGuard.Initialize(); + } + + public override bool CheckHarvest(Mobile from, Item tool) + { + var region = from?.Region; + var regionName = region?.Name ?? "(null)"; + var ok = false; + + if (region != null) + { + try + { + if (region.IsPartOf("Cemetery")) + ok = true; + } + catch (Exception ex) + { + PandoraChestLogger.LogError("GraveRobbing.CheckHarvest.IsPartOf", ex, $"RegionName={regionName}", from); + } + + if (!ok && !string.IsNullOrEmpty(regionName)) + { + var rn = regionName.ToLowerInvariant(); + if (rn.Contains("grave") || rn.Contains("graveyard") || rn.Contains("cemetery")) + ok = true; + } + } + + if (!ok) + { + from?.SendMessage($"You may only rob graves in a cemetery. Region: {regionName}"); + return false; + } + + if (tool == null) + { + from?.SendMessage("You need to use a shovel or pickaxe to dig here."); + return false; + } + + return base.CheckHarvest(from, tool); + } + + public override void OnHarvestFinished(Mobile from, Item tool, HarvestDefinition def, HarvestVein vein, HarvestBank bank, HarvestResource resource, object harvested) + { + base.OnHarvestFinished(from, tool, def, vein, bank, resource, harvested); + + GetHarvestDetails(from, tool, harvested, out _, out var map, out var loc, out _); + + if (from != null) + { + from.Karma -= Utility.RandomMinMax(50, 150); + } + + var hour = DateTime.Now.Hour; + var night = hour < 6 || hour > 20; + + MoonPhase? moon = null; + if (map != null) + { + try + { + moon = Clock.GetMoonPhase(map, loc.X, loc.Y); + } + catch (Exception ex) + { + PandoraChestLogger.LogError("GraveRobbing.OnHarvestFinished.GetMoonPhase", ex, $"Map={map},Loc={loc}", from); + moon = null; + } + } + + double baseSpiritChance = 0.10; + if (moon == MoonPhase.FullMoon) baseSpiritChance += 0.15; + if (night) baseSpiritChance += 0.10; + + double spiritSpeak = GRUtil.GetSkillValue(from, SkillName.SpiritSpeak); + double necromancy = GRUtil.GetSkillValue(from, SkillName.Necromancy); + double skillReduction = (spiritSpeak + necromancy) / 2000.0; + double spiritChanceFinal = Math.Max(0.01, baseSpiritChance - skillReduction); + + if (Utility.RandomDouble() < spiritChanceFinal) + { + var spirit = new VengefulSpirit(); + if (night) + { + spirit.Hits += 20; + spirit.DamageMin += 2; + spirit.DamageMax += 2; + } + + Point3D spawnLoc; + Map spawnMap; + if (map != null) + { + spawnLoc = loc; + spawnMap = map; + } + else + { + spawnLoc = from?.Location ?? Point3D.Zero; + spawnMap = from?.Map; + } + + if (spawnMap != null) + { + try + { + spirit.MoveToWorld(spawnLoc, spawnMap); + spirit.Combatant = from; + spirit.Say("You should not have disturbed the dead!"); + } + catch (Exception ex) + { + PandoraChestLogger.LogError("GraveRobbing.OnHarvestFinished.SpawnSpirit", ex, $"Player={from?.Name},Map={spawnMap},Loc={spawnLoc}", from); + } + } + } + + if (from != null && Utility.RandomDouble() < 0.25) + { + from.SendMessage(0x21, "A grave curse weakens you!"); + try + { + from.AddStatMod(new StatMod(StatType.Str, "GraveCurse", -5, TimeSpan.FromMinutes(5))); + } + catch (Exception ex) + { + PandoraChestLogger.LogError("GraveRobbing.OnHarvestFinished.AddStatMod", ex, $"Player={from?.Name}", from); + } + } + + TrySpawnPandoraChest(from, loc, map); + } + + void TrySpawnPandoraChest(Mobile from, Point3D loc, Map map) + { + if (from == null || map == null) return; + var cfg = PandoraChestConfig.Current; + if (!PandoraChestSpawnGuard.CanSpawnAt(map, loc, cfg)) return; + + string acc = "(null)"; + try + { + acc = from?.Account?.Username ?? from?.Account?.ToString() ?? "(null)"; + } + catch (Exception ex) + { + PandoraChestLogger.LogError("GraveRobbing.TrySpawnPandoraChest.GetAccount", ex, $"Player={from?.Name}", from); + acc = "(null)"; + } + + if (!PandoraChestSpawnGuard.CanSpawnForAccount(acc, cfg)) return; + + double mining = GRUtil.GetSkillValue(from, SkillName.Mining); + double spiritSpeak = GRUtil.GetSkillValue(from, SkillName.SpiritSpeak); + double necro = GRUtil.GetSkillValue(from, SkillName.Necromancy); + double spawn = cfg.Spawn.BaseSpawnChance + mining * cfg.Spawn.MiningFactor + spiritSpeak * cfg.Spawn.SpiritSpeakFactor + necro * cfg.Spawn.NecromancyFactor; + spawn = Math.Min(spawn, cfg.Spawn.MaxSpawnChance); + if (Utility.RandomDouble() >= spawn) return; + + int level = DetermineChestLevel(mining, spiritSpeak, necro, cfg); + var chest = new PandoraChest(level, from); + try + { + chest.MoveToWorld(loc, map); + chest.Populate(from, map); + } + catch (Exception ex) + { + PandoraChestLogger.LogError("GraveRobbing.TrySpawnPandoraChest.SpawnChest", ex, $"Player={from?.Name},Map={map},Loc={loc},Level={level}", from); + } + + PandoraChestLogger.LogSpawn(from, map, loc, level, spawn); + PandoraChestSpawnGuard.RecordAt(map, loc); + PandoraChestSpawnGuard.RecordAccount(acc); + + double guardChance = cfg.Guardian.BaseChance + level * cfg.Guardian.LevelMultiplier; + guardChance = Math.Min(guardChance, cfg.Guardian.MaxChance); + if (Utility.RandomDouble() < guardChance) + { + int count = 1 + (level / 3); + for (int i = 0; i < count; i++) + { + var g = new VengefulSpirit(); + g.Hits += (int)(g.Hits * (1.0 + cfg.Guardian.HpMultiplierPerQuality * (level - 1))); + g.DamageMin += (int)(cfg.Guardian.DamageMultiplierPerQuality * (level - 1)); + g.DamageMax += (int)(cfg.Guardian.DamageMultiplierPerQuality * (level - 1) * 2); + try + { + g.MoveToWorld(new Point3D(loc.X + Utility.RandomMinMax(-2, 2), loc.Y + Utility.RandomMinMax(-2, 2), loc.Z), map); + g.Combatant = from; + } + catch (Exception ex) + { + PandoraChestLogger.LogError("GraveRobbing.TrySpawnPandoraChest.SpawnGuard", ex, $"Player={from?.Name},Map={map},Loc={loc},Level={level}", from); + } + } + } + } + + int DetermineChestLevel(double mining, double spirit, double necro, PandoraChestConfig cfg) + { + double weighted = mining * cfg.Weights.Mining + spirit * cfg.Weights.SpiritSpeak + necro * cfg.Weights.Necromancy; + double norm = Math.Min(weighted / 120.0, 1.0); + var q = cfg.LevelQualityBaseCached; + int levels = Math.Max(1, Math.Min(cfg.Spawn.Levels, q.Length)); + double[] adj = new double[levels]; + for (int i = 0; i < levels; i++) + { + double qq = (i < q.Length) ? q[i] : q.Last(); + adj[i] = Math.Max(0.001, (0.2 + qq) * (1.0 + norm * (i / (double)levels) * 3.0)); + } + double sum = adj.Sum(); + double r = Utility.RandomDouble() * sum; + double acc = 0; + for (int i = 0; i < adj.Length; i++) + { + acc += adj[i]; + if (r <= acc) return i + 1; + } + return levels; + } + } + + public static class GraveRobbingInit { public static void Initialize() { _ = GraveRobbing.System; } } +} + +namespace Server.Items +{ + [SerializationGenerator(0, false)] + [Flippable(0x09AA, 0x0E7D)] + public partial class PandoraChest : LockableContainer + { + int _level; + bool _isCursed; + bool _isTrapped; + int _trapPower; + Mobile _owner; + enum TrapType { None, Explosion, Poison, SummonUndead } + TrapType _trapType = TrapType.None; + + [Constructible] public PandoraChest() : this(1, null) { } + public PandoraChest(int level, Mobile owner) : base(0x09AA) + { + Weight = 5.0; + LootType = LootType.Regular; + _owner = owner; + + var cfg = PandoraChestConfig.Current; + _level = Math.Max(1, Math.Min(level, cfg.Spawn.Levels)); + Name = $"Pandora's Chest [L{_level}] {(_level >= 4 ? "(Jeweled)" : _level >= 3 ? "(Polished)" : "(Simple)")}"; + int hue = (cfg.HueByLevelCached != null && cfg.HueByLevelCached.Length >= _level) ? cfg.HueByLevelCached[_level - 1] : 0; + if (hue != 0) Hue = hue; + + _isCursed = Utility.RandomDouble() < cfg.BaseCursedChance; + Locked = Utility.RandomDouble() < cfg.BaseLockedChance; + _isTrapped = Utility.RandomDouble() < cfg.BaseTrappedChance; + LockLevel = 10 * _level; + MaxLockLevel = Math.Max(LockLevel, 120); + RequiredSkill = 10 * _level; + _trapPower = _isTrapped ? (10 + 15 * _level) : 0; + if (_isCursed) Name = $"Cursed {Name}"; + } + + public void Populate(Mobile opener, Map map) => FillWithLoot(_level, opener, map); + + void FillWithLoot(int level, Mobile opener, Map map) + { + if (Items.Count > 0) return; + + var cfg = PandoraChestConfig.Current; + var ranges = cfg.GoldRangeByLevelCached; + int idx = Math.Max(0, Math.Min(level - 1, ranges.Length - 1)); + int gold = Utility.RandomMinMax(ranges[idx].Min, ranges[idx].Max); + DropItem(new Gold(gold)); + var dropped = new List { $"Gold:{gold}" }; + + int minR = cfg.ReagentsMinCached.Length > idx ? cfg.ReagentsMinCached[idx] : 1; + int maxR = cfg.ReagentsMaxCached.Length > idx ? cfg.ReagentsMaxCached[idx] : Math.Max(1, minR); + int reagCount = Utility.RandomMinMax(minR, maxR); + + var reagentStacks = new Dictionary(); + for (int i = 0; i < reagCount; i++) + { + var r = ReagentForLevel(level, cfg); + if (r == null) continue; + var t = r.GetType(); + reagentStacks.TryGetValue(t, out var cur); + reagentStacks[t] = cur + (r.Amount > 0 ? r.Amount : 1); + } + + foreach (var kv in reagentStacks) + { + try + { + var inst = (Item)Activator.CreateInstance(kv.Key)!; + try { inst.Amount = Math.Max(1, kv.Value); } catch (Exception ex) { PandoraChestLogger.LogError("PandoraChest.FillWithLoot.SetReagentAmount", ex, $"Type={kv.Key.FullName},Level={level}", opener); } + DropItem(inst); + dropped.Add(inst.GetType().Name + (inst.Amount > 1 ? $"x{inst.Amount}" : "")); + } + catch (Exception ex) + { + PandoraChestLogger.LogError("PandoraChest.FillWithLoot.CreateReagentInstance", ex, $"Type={kv.Key.FullName},Level={level}", opener); + } + } + + if (level >= 4) + { + if (level >= 5) + { + try + { + var bp = new BlackPearl(); + try { bp.Amount = 1; } catch (Exception ex) { PandoraChestLogger.LogError("PandoraChest.FillWithLoot.SetBlackPearlAmount", ex, $"Level={level}", opener); } + DropItem(bp); + dropped.Add("Guaranteed:BlackPearl"); + } + catch (Exception ex) + { + PandoraChestLogger.LogError("PandoraChest.FillWithLoot.CreateBlackPearl", ex, $"Level={level}", opener); + } + } + else + { + try + { + var sa = new SulfurousAsh(); + try { sa.Amount = 1; } catch (Exception ex) { PandoraChestLogger.LogError("PandoraChest.FillWithLoot.SetSulfurousAshAmount", ex, $"Level={level}", opener); } + DropItem(sa); + dropped.Add("Guaranteed:SulfurousAsh"); + } + catch (Exception ex) + { + PandoraChestLogger.LogError("PandoraChest.FillWithLoot.CreateSulfurousAsh", ex, $"Level={level}", opener); + } + } + } + + if (Utility.Random(100) < Math.Min(95, cfg.Weights.Level * 10 + 30 + level * 8)) + { + DropItem(new NightSightPotion()); + dropped.Add("NightSightPotion"); + } + + if (Utility.Random(100) < Math.Min(95, 20 + level * 6)) + { + DropItem(new LesserHealPotion()); + dropped.Add("LesserHealPotion"); + } + + double jewelryChance = Math.Min(95.0, 8 + level * 6 + cfg.Weights.Level * 10); + bool jewelryDropped = false; + if (Utility.Random(100) < jewelryChance) + { + Item j = (level >= 4) ? MakeJeweledJewelry(level) : (level >= 3) ? MakePolishedJewelry(level) : MakeSimpleJewelry(level); + if (j != null) { DropItem(j); dropped.Add(j.GetType().Name + ":" + (j.Name ?? "j")); jewelryDropped = true; } + } + + if (level >= 5 && !jewelryDropped) + { + var j = MakeJeweledJewelry(level); + if (j != null) { DropItem(j); dropped.Add("GuaranteedJeweled:" + (j.Name ?? j.GetType().Name)); } + } + + if (Utility.Random(100) < (20 + level * 8)) { DropItem(new Bone()); dropped.Add("Bone"); } + + if (Utility.RandomDouble() < cfg.RareDrop.BaseChance * level) { DropItem(new SpecialRareBoneItem()); dropped.Add("SpecialRareBoneItem"); } + + if (Utility.Random(100) < 10 + level * 4) { DropItem(new Lockpick()); dropped.Add("Lockpick"); } + + if (cfg.Magic.Enabled) + { + double mapMult = MapMultiplier(map, cfg); + double luck = opener?.Luck ?? 0; + double skillSum = (GRUtil.GetSkillValue(opener, SkillName.Mining) + GRUtil.GetSkillValue(opener, SkillName.SpiritSpeak) + GRUtil.GetSkillValue(opener, SkillName.Necromancy)) / 3.0; + double skillFactor = 1.0 + skillSum / 200.0; + double chance = cfg.Magic.BaseChanceByLevelMultiplier * level * mapMult * (1.0 + luck / 1000.0) * skillFactor; + chance = Math.Min(chance, 0.95); + if (Utility.RandomDouble() < chance) + { + Item? magic = null; + try + { + magic = GenerateMagicItem(level, opener, cfg); + if (magic != null) + { + DropItem(magic); + PandoraChestLogger.LogMagicDrop(opener, level, magic); + dropped.Add("Magic:" + (magic.Name ?? magic.GetType().Name)); + int minutes = Math.Max(1, cfg.Magic.DecayMinutesIfDropped); + Timer.DelayCall(TimeSpan.FromMinutes(minutes), () => + { + try { if (magic != null && !magic.Deleted && magic.Parent == this) magic.Delete(); } catch (Exception ex) { PandoraChestLogger.LogError("PandoraChest.FillWithLoot.MagicDecayDelete", ex, $"Player={opener?.Name},Level={level},Item={magic?.GetType().Name}", opener); } + }); + } + } + catch (Exception ex) + { + PandoraChestLogger.LogError("PandoraChest.FillWithLoot.GenerateMagicItem", ex, $"Player={opener?.Name},Level={level}", opener); + } + } + + if (Utility.RandomDouble() < cfg.Magic.TreasureMapChancePerLevel * level * MapMultiplier(map, cfg)) + { + try { var tm = new TreasureMap(level, map); DropItem(tm); dropped.Add("TreasureMap:L" + level); } catch (Exception ex) { PandoraChestLogger.LogError("PandoraChest.FillWithLoot.TreasureMap", ex, $"Player={opener?.Name},Level={level}", opener); } + } + } + + var tcfg = PandoraChestConfig.Current.Trap; + if (tcfg != null) + { + double mapMult = MapMultiplier(map, PandoraChestConfig.Current); + double spirit = GRUtil.GetSkillValue(_owner, SkillName.SpiritSpeak); + double necro = GRUtil.GetSkillValue(_owner, SkillName.Necromancy); + double luck = _owner?.Luck ?? 0; + double reduction = (spirit + necro) / 300.0 + luck / 2000.0; + double trapBase = tcfg.BaseChancePerLevel * level * mapMult; + if (Utility.RandomDouble() < Math.Max(0.02, trapBase - reduction)) + { + try + { + int total = tcfg.TypeWeights.Values.Sum(); + int r = Utility.Random(total); + foreach (var kv in tcfg.TypeWeights) + { + r -= kv.Value; + if (r < 0) + { + if (Enum.TryParse(kv.Key, out var tt)) _trapType = tt; + break; + } + } + _isTrapped = true; + _trapPower = 1 + (int)Math.Round(level * tcfg.PowerMultiplierPerLevel); + } + catch (Exception ex) + { + PandoraChestLogger.LogError("PandoraChest.FillWithLoot.DetermineTrap", ex, $"Player={_owner?.Name},Level={level}", _owner); + } + } + } + + PandoraChestLogger.LogContents(opener, _level, dropped); + } + + static double MapMultiplier(Map m, PandoraChestConfig cfg) + { + if (m == null) return 1.0; + var nm = m.ToString().ToLowerInvariant(); + if (nm.Contains("felucca")) return cfg.MapMultiplier.Felucca; + if (nm.Contains("trammel")) return cfg.MapMultiplier.Trammel; + return cfg.MapMultiplier.Trammel; + } + + static Item? GenerateMagicItem(int level, Mobile opener, PandoraChestConfig cfg) + { + try + { + double pick = Utility.RandomDouble(); + Item it = (pick < 0.55) ? Loot.RandomWeapon() : (pick < 0.90) ? Loot.RandomArmor() : Loot.RandomJewelry(); + if (it == null) return null; + + it.Name = $"Enchanted {it.GetType().Name} (L{level})"; + if (level >= 4) it.Hue = 0x496; + else if (level >= 3) it.Hue = 0x47E; + + int baseAff = cfg.Magic.MaxAffixesByLevelBase; + int extra = level >= 5 ? 1 : 0; + int affixes = baseAff + extra; + + double luck = opener?.Luck ?? 0; + double skillSum = (GRUtil.GetSkillValue(opener, SkillName.Mining) + GRUtil.GetSkillValue(opener, SkillName.SpiritSpeak) + GRUtil.GetSkillValue(opener, SkillName.Necromancy)) / 3.0; + double mult = 1.0 + (level - 1) * 0.15 + luck / 500.0 + skillSum / 200.0; + + var keys = cfg.Magic.AffixRanges?.Keys.ToArray() ?? Array.Empty(); + for (int i = 0; i < affixes && keys.Length > 0; i++) + { + var key = keys[Utility.Random(keys.Length)]; + var range = cfg.Magic.AffixRanges[key]; + int raw = Utility.RandomMinMax(range.Min, range.Max); + int val = Math.Max(1, (int)Math.Round(raw * mult)); + ApplyAffixToItem(it, key, val); + } + return it; + } + catch (Exception ex) + { + PandoraChestLogger.LogError("PandoraChest.GenerateMagicItem", ex, $"Level={level},Player={opener?.Name}", opener); + return null; + } + } + + static void ApplyAffixToItem(Item it, string affixName, int value) + { + if (it == null) return; + try + { + if (it is BaseWeapon weapon) + { + if (weapon.Attributes != null) + { + switch (affixName) + { + case "BonusStr": weapon.Attributes.BonusStr += value; return; + case "BonusDex": weapon.Attributes.BonusDex += value; return; + case "BonusHits": weapon.Attributes.BonusHits += value; return; + case "WeaponDamage": weapon.Attributes.WeaponDamage += value; return; + } + } + + var wattrsProp = weapon.GetType().GetProperty("WeaponAttributes", BindingFlags.Public | BindingFlags.Instance); + if (wattrsProp != null) + { + var wattrs = wattrsProp.GetValue(weapon); + if (wattrs != null) + { + var p = wattrs.GetType().GetProperty(affixName, BindingFlags.Public | BindingFlags.Instance); + if (p != null && p.PropertyType == typeof(int)) + { + int old = (int)p.GetValue(wattrs); + p.SetValue(wattrs, old + value); + return; + } + } + } + } + + if (it is BaseArmor armor) + { + if (armor.Attributes != null) + { + switch (affixName) + { + case "BonusStr": armor.Attributes.BonusStr += value; return; + case "BonusDex": armor.Attributes.BonusDex += value; return; + case "BonusHits": armor.Attributes.BonusHits += value; return; + } + } + + var aattrsProp = armor.GetType().GetProperty("ArmorAttributes", BindingFlags.Public | BindingFlags.Instance); + if (aattrsProp != null) + { + var aattrs = aattrsProp.GetValue(armor); + if (aattrs != null) + { + var p = aattrs.GetType().GetProperty(affixName, BindingFlags.Public | BindingFlags.Instance); + if (p != null && p.PropertyType == typeof(int)) + { + int old = (int)p.GetValue(aattrs); + p.SetValue(aattrs, old + value); + return; + } + } + } + } + + if (it is BaseJewel jewel) + { + if (jewel.Attributes != null) + { + switch (affixName) + { + case "BonusStr": jewel.Attributes.BonusStr += value; return; + case "BonusDex": jewel.Attributes.BonusDex += value; return; + case "BonusHits": jewel.Attributes.BonusHits += value; return; + case "WeaponDamage": jewel.Attributes.WeaponDamage += value; return; + } + } + } + + var attrsProp = it.GetType().GetProperty("Attributes", BindingFlags.Public | BindingFlags.Instance); + if (attrsProp != null) + { + var attrsObj = attrsProp.GetValue(it); + if (attrsObj != null) + { + var prop = attrsObj.GetType().GetProperty(affixName, BindingFlags.Public | BindingFlags.Instance); + if (prop != null && prop.PropertyType == typeof(int)) + { + int old = (int)prop.GetValue(attrsObj); + prop.SetValue(attrsObj, old + value); + return; + } + } + } + + it.Name = $"{it.Name} [+{value} {affixName}]"; + } + catch (Exception ex) + { + PandoraChestLogger.LogError("PandoraChest.ApplyAffixToItem", ex, $"Item={it?.GetType().FullName},Affix={affixName},Value={value}"); + try { it.Name = $"{it.Name} [+{value} {affixName}]"; } catch { } + } + } + + static Item ReagentForLevel(int level, PandoraChestConfig cfg) + { + int r = Utility.Random(100); + if (level >= 5 && r < 25) return new BlackPearl(); + if (level >= 4 && r < 30) return new SulfurousAsh(); + Item[] common = { new Garlic(), new Ginseng(), new MandrakeRoot(), new Bloodmoss() }; + return common[Utility.Random(common.Length)]; + } + + static Item MakeSimpleJewelry(int level) => Utility.RandomBool() ? new GoldRing() : new GoldNecklace(); + + static Item MakePolishedJewelry(int level) + { + var it = MakeSimpleJewelry(level); + if (it is GoldRing r) { r.Name = "Polished Gold Ring"; r.Hue = 0x47E; } + if (it is GoldNecklace n) { n.Name = "Polished Gold Necklace"; n.Hue = 0x47E; } + return it; + } + + static Item MakeJeweledJewelry(int level) + { + var it = MakeSimpleJewelry(level); + if (it is GoldRing r) { r.Name = "Jeweled Gold Ring"; r.Hue = 0x496; } + if (it is GoldNecklace n) { n.Name = "Jeweled Gold Necklace"; n.Hue = 0x496; } + return it; + } + + public override bool CheckLocked(Mobile from) + { + if (_isCursed && from != null) + { + double necro = GRUtil.GetSkillValue(from, SkillName.Necromancy); + double spirit = GRUtil.GetSkillValue(from, SkillName.SpiritSpeak); + double resist = (necro + spirit) / 240.0; + if (Utility.RandomDouble() > resist) + { + from.SendMessage(0x22, "An ancient curse flares as you touch the chest..."); + try { from.ApplyPoison(from, Poison.Deadly); } + catch (Exception ex) { PandoraChestLogger.LogError("PandoraChest.CheckLocked.ApplyPoison", ex, $"Player={from?.Name}", from); } + } + } + return base.CheckLocked(from); + } + + public override void OnDoubleClick(Mobile from) + { + if (from == null || from.Deleted) return; + if (Locked) { base.OnDoubleClick(from); return; } + + if (_isTrapped && _trapType != TrapType.None) + { + double trapChance = _trapPower / 100.0; + double spirit = GRUtil.GetSkillValue(from, SkillName.SpiritSpeak); + double necro = GRUtil.GetSkillValue(from, SkillName.Necromancy); + double resist = (spirit + necro) / 240.0; + trapChance = Math.Max(0.05, trapChance - resist); + if (Utility.RandomDouble() < trapChance) { TriggerTrap(from); _isTrapped = false; Locked = true; return; } + } + + if (_isCursed) + { + from.SendMessage(0x21, "A dark curse hits you for opening a desecrated chest!"); + try { from.AddStatMod(new StatMod(StatType.Dex, "PandoraCurseDex", -3, TimeSpan.FromMinutes(3))); } + catch (Exception ex) { PandoraChestLogger.LogError("PandoraChest.OnDoubleClick.AddStatMod.Dex", ex, $"Player={from?.Name}", from); } + try { from.AddStatMod(new StatMod(StatType.Str, "PandoraCurseStr", -3, TimeSpan.FromMinutes(3))); } + catch (Exception ex) { PandoraChestLogger.LogError("PandoraChest.OnDoubleClick.AddStatMod.Str", ex, $"Player={from?.Name}", from); } + if (Utility.RandomDouble() < 0.35) try { from.ApplyPoison(from, Poison.Lesser); } catch (Exception ex) { PandoraChestLogger.LogError("PandoraChest.OnDoubleClick.ApplyPoison", ex, $"Player={from?.Name}", from); } + } + + base.OnDoubleClick(from); + } + + void TriggerTrap(Mobile from) + { + if (from == null || from.Deleted) return; + var tcfg = PandoraChestConfig.Current.Trap; + switch (_trapType) + { + case TrapType.Explosion: + try + { + int min = tcfg.ExplosionBaseMin + (_trapPower * 2); + int max = tcfg.ExplosionBaseMax + (_trapPower * 4); + int dmg = Utility.RandomMinMax(min, max); + from.Damage(dmg); + foreach (var m in from.GetMobilesInRange(2)) + if (m != from && m.Alive) try { m.Damage(dmg / 2); } catch (Exception ex) { PandoraChestLogger.LogError("PandoraChest.TriggerTrap.DamageNearby", ex, $"Player={from?.Name},Target={m?.Name}", from); } + } + catch (Exception ex) + { + PandoraChestLogger.LogError("PandoraChest.TriggerTrap.Explosion", ex, $"Player={from?.Name},TrapPower={_trapPower},Level={_level}", from); + } + break; + case TrapType.Poison: + try + { + var p = _trapPower >= 3 ? Poison.Deadly : (_trapPower == 2 ? Poison.Greater : Poison.Lesser); + from.ApplyPoison(from, p); + } + catch (Exception ex) + { + PandoraChestLogger.LogError("PandoraChest.TriggerTrap.Poison", ex, $"Player={from?.Name},TrapPower={_trapPower},Level={_level}", from); + } + break; + case TrapType.SummonUndead: + try + { + int count = Math.Max(1, PandoraChestConfig.Current.Trap.SummonPerPower * _trapPower); + for (int i = 0; i < count; i++) + { + var z = new VengefulSpirit(); + z.Hits += 10 * _trapPower; + z.DamageMin += _trapPower; + z.DamageMax += _trapPower * 2; + try { z.MoveToWorld(from.Location, from.Map); z.Combatant = from; } catch (Exception ex) { PandoraChestLogger.LogError("PandoraChest.TriggerTrap.SummonUndead.Move", ex, $"Player={from?.Name}", from); } + } + } + catch (Exception ex) + { + PandoraChestLogger.LogError("PandoraChest.TriggerTrap.SummonUndead", ex, $"Player={from?.Name},TrapPower={_trapPower},Level={_level}", from); + } + break; + } + try { from.SendMessage(0x22, "A hidden trap explodes as you touch the chest!"); } catch (Exception ex) { PandoraChestLogger.LogError("PandoraChest.TriggerTrap.SendMessage", ex, $"Player={from?.Name}", from); } + } + } + + [SerializationGenerator(0, false)] + public partial class SpecialRareBoneItem : Item { [Constructible] public SpecialRareBoneItem() : base(0x1B) { Name = "Ancient Bone Relic"; Hue = 0x485; Weight = 1.0; } } + + [SerializationGenerator(0, false)] + public partial class VengefulSpirit : BaseCreature + { + [Constructible] + public VengefulSpirit() : base(AIType.AI_Mage, FightMode.Closest, 10, 1) + { + Name = "Vengeful Spirit"; Body = 26; Hue = 0x4001; SetStr(50); SetDex(50); SetInt(100); SetHits(40); SetDamage(8, 12); Fame = 4000; Karma = -4000; VirtualArmor = 30; + } + public override bool AlwaysAttackable => true; + public override bool BleedImmune => true; + } +} \ No newline at end of file diff --git a/ToAdd/GraveRobbing/PandoraChestConfig.cs b/ToAdd/GraveRobbing/PandoraChestConfig.cs new file mode 100644 index 000000000..7cee599e9 --- /dev/null +++ b/ToAdd/GraveRobbing/PandoraChestConfig.cs @@ -0,0 +1,280 @@ +/********************************** +* Author: Expa * +* Script: PandoraChestConfig.cs * +* Optimized for ModernUO 0.15.4.18* +**********************************/ +using System; +using System.IO; +using System.Linq; +using System.Text.Json; + +public class PandoraChestConfig +{ + private static PandoraChestConfig _current; + private static readonly object _lock = new(); + + public static PandoraChestConfig Current + { + get + { + if (_current == null) Load(); + return _current!; + } + } + + public SpawnSection Spawn { get; set; } = new(); + public double[] LevelQualityBase { get; set; } = new double[] { 0.0, 0.10, 0.25, 0.45, 0.85 }; + public GoldRange[] GoldRangeByLevel { get; set; } = new[] + { + new GoldRange{ Min = 10, Max = 60}, + new GoldRange{ Min = 30, Max = 120}, + new GoldRange{ Min = 80, Max = 300}, + new GoldRange{ Min = 200, Max = 1200}, + new GoldRange{ Min = 1500, Max = 5000} + }; + public RareDropSection RareDrop { get; set; } = new RareDropSection(); + public ReagentsSection Reagents { get; set; } = new ReagentsSection(); + public WeightsSection Weights { get; set; } = new WeightsSection(); + public GuardianSection Guardian { get; set; } = new GuardianSection(); + public LoggingSection Logging { get; set; } = new LoggingSection(); + public MagicSection Magic { get; set; } = new MagicSection(); + public TrapSection Trap { get; set; } = new TrapSection(); + public MapMultiplierSection MapMultiplier { get; set; } = new MapMultiplierSection(); + public int[] HueByLevel { get; set; } = new int[] { 0, 0x47E, 0x47E, 0x496, 0x496 }; + public double BaseCursedChance { get; set; } = 0.10; + public double BaseTrappedChance { get; set; } = 0.12; + public double BaseLockedChance { get; set; } = 0.65; + + public double[] LevelQualityBaseCached = Array.Empty(); + public GoldRange[] GoldRangeByLevelCached = Array.Empty(); + public int[] ReagentsMinCached = Array.Empty(); + public int[] ReagentsMaxCached = Array.Empty(); + public int[] HueByLevelCached = Array.Empty(); + + public class SpawnSection + { + public int Levels { get; set; } = 5; + public double BaseSpawnChance { get; set; } = 0.08; + public double MiningFactor { get; set; } = 0.0006; + public double SpiritSpeakFactor { get; set; } = 0.0008; + public double NecromancyFactor { get; set; } = 0.0008; + public double MaxSpawnChance { get; set; } = 0.60; + public bool AllowIfLoggingDisabled { get; set; } = true; + } + + public class GoldRange + { + public int Min { get; set; } + public int Max { get; set; } + } + + public class RareDropSection + { + public double BaseChance { get; set; } = 0.02; + } + + public class ReagentsSection + { + public int[] MinPerLevel { get; set; } = new int[] { 1, 2, 3, 4, 6 }; + public int[] MaxPerLevel { get; set; } = new int[] { 2, 4, 6, 8, 12 }; + } + + public class WeightsSection + { + public double Mining { get; set; } = 0.6; + public double SpiritSpeak { get; set; } = 1.2; + public double Necromancy { get; set; } = 1.2; + public double Level { get; set; } = 0.1; + } + + public class GuardianSection + { + public double BaseChance { get; set; } = 0.05; + public double LevelMultiplier { get; set; } = 0.06; + public double MaxChance { get; set; } = 0.95; + public double HpMultiplierPerQuality { get; set; } = 1.0; + public double DamageMultiplierPerQuality { get; set; } = 0.8; + } + + public class LoggingSection + { + public bool Enabled { get; set; } = true; + public long MaxFileSizeBytes { get; set; } = 10 * 1024 * 1024; + public int FlushIntervalMs { get; set; } = 500; + public int LocationCooldownSeconds { get; set; } = 60; + public int AccountCooldownSeconds { get; set; } = 30; + } + + public class MagicSection + { + public bool Enabled { get; set; } = true; + public double BaseChanceByLevelMultiplier { get; set; } = 0.02; + public int DecayMinutesIfDropped { get; set; } = 30; + public int MaxAffixesByLevelBase { get; set; } = 1; + public System.Collections.Generic.Dictionary AffixRanges { get; set; } = new() + { + { "BonusStr", new AffixRange{ Min = 1, Max = 5 } }, + { "BonusDex", new AffixRange{ Min = 1, Max = 5 } }, + { "BonusHits", new AffixRange{ Min = 1, Max = 10 } }, + { "WeaponDamage", new AffixRange{ Min = 1, Max = 10 } } + }; + public double TreasureMapChancePerLevel { get; set; } = 0.03; + } + + public class TrapSection + { + public double BaseChancePerLevel { get; set; } = 0.12; + public System.Collections.Generic.Dictionary TypeWeights { get; set; } = new() + { + { "Explosion", 30 }, + { "Poison", 40 }, + { "SummonUndead", 30 } + }; + public double PowerMultiplierPerLevel { get; set; } = 0.6; + public int ExplosionBaseMin { get; set; } = 8; + public int ExplosionBaseMax { get; set; } = 16; + public int SummonPerPower { get; set; } = 1; + } + + public class AffixRange + { + public int Min { get; set; } + public int Max { get; set; } + } + + public class MapMultiplierSection + { + public double Felucca { get; set; } = 1.15; + public double Trammel { get; set; } = 1.0; + } + + public static void Load(string path = null) + { + lock (_lock) + { + try + { + string baseDir = AppDomain.CurrentDomain.BaseDirectory ?? Environment.CurrentDirectory; + string cfgPath = path ?? Path.Combine(baseDir, "PandoraChestConfig.json"); + if (!File.Exists(cfgPath)) + { + _current = new PandoraChestConfig(); + _current.FinalizeCache(); + return; + } + + string json = File.ReadAllText(cfgPath); + var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var cfg = JsonSerializer.Deserialize(json, opts) ?? new PandoraChestConfig(); + + cfg.Spawn ??= new SpawnSection(); + cfg.RareDrop ??= new RareDropSection(); + cfg.Reagents ??= new ReagentsSection(); + cfg.Weights ??= new WeightsSection(); + cfg.Guardian ??= new GuardianSection(); + cfg.Logging ??= new LoggingSection(); + cfg.Magic ??= new MagicSection(); + cfg.Trap ??= new TrapSection(); + cfg.MapMultiplier ??= new MapMultiplierSection(); + + if (cfg.LevelQualityBase == null) cfg.LevelQualityBase = Enumerable.Repeat(0.2, cfg.Spawn.Levels).ToArray(); + + if (cfg.GoldRangeByLevel == null || cfg.GoldRangeByLevel.Length < cfg.Spawn.Levels) + { + var arr = new GoldRange[cfg.Spawn.Levels]; + var src = cfg.GoldRangeByLevel ?? Array.Empty(); + for (int i = 0; i < arr.Length; i++) + arr[i] = (i < src.Length && src[i] != null) ? src[i] : new GoldRange { Min = 10, Max = 60 }; + cfg.GoldRangeByLevel = arr; + } + + if (cfg.Reagents.MinPerLevel == null || cfg.Reagents.MinPerLevel.Length < cfg.Spawn.Levels) + { + cfg.Reagents.MinPerLevel = new int[cfg.Spawn.Levels]; + for (int i = 0; i < cfg.Spawn.Levels; i++) cfg.Reagents.MinPerLevel[i] = Math.Max(1, 1 + i); + } + + if (cfg.Reagents.MaxPerLevel == null || cfg.Reagents.MaxPerLevel.Length < cfg.Spawn.Levels) + { + cfg.Reagents.MaxPerLevel = new int[cfg.Spawn.Levels]; + for (int i = 0; i < cfg.Spawn.Levels; i++) cfg.Reagents.MaxPerLevel[i] = Math.Max(cfg.Reagents.MinPerLevel[i], 2 + i * 2); + } + + ValidateAndClamp(cfg); + cfg.FinalizeCache(); + _current = cfg; + } + catch (Exception ex) + { + try { Console.Error.WriteLine($"PandoraChestConfig.Load failed: {ex}"); } catch { } + _current = new PandoraChestConfig(); + _current.FinalizeCache(); + } + } + } + + public static void Reload() => Load(); + + private static void ValidateAndClamp(PandoraChestConfig cfg) + { + cfg.Spawn ??= new SpawnSection(); + cfg.RareDrop ??= new RareDropSection(); + cfg.Reagents ??= new ReagentsSection(); + cfg.Weights ??= new WeightsSection(); + cfg.Guardian ??= new GuardianSection(); + cfg.Logging ??= new LoggingSection(); + cfg.Magic ??= new MagicSection(); + cfg.Trap ??= new TrapSection(); + cfg.MapMultiplier ??= new MapMultiplierSection(); + + cfg.Spawn.BaseSpawnChance = Clamp(cfg.Spawn.BaseSpawnChance, 0.0, 1.0); + cfg.Spawn.MiningFactor = Math.Max(0.0, cfg.Spawn.MiningFactor); + cfg.Spawn.SpiritSpeakFactor = Math.Max(0.0, cfg.Spawn.SpiritSpeakFactor); + cfg.Spawn.NecromancyFactor = Math.Max(0.0, cfg.Spawn.NecromancyFactor); + cfg.Spawn.MaxSpawnChance = Clamp(cfg.Spawn.MaxSpawnChance, 0.0, 1.0); + + cfg.RareDrop.BaseChance = Clamp(cfg.RareDrop.BaseChance, 0.0, 1.0); + cfg.BaseCursedChance = Clamp(cfg.BaseCursedChance, 0.0, 1.0); + cfg.BaseTrappedChance = Clamp(cfg.BaseTrappedChance, 0.0, 1.0); + cfg.BaseLockedChance = Clamp(cfg.BaseLockedChance, 0.0, 1.0); + cfg.Guardian.BaseChance = Clamp(cfg.Guardian.BaseChance, 0.0, 1.0); + cfg.Guardian.MaxChance = Clamp(cfg.Guardian.MaxChance, 0.0, 1.0); + + if (cfg.GoldRangeByLevel != null) + foreach (var gr in cfg.GoldRangeByLevel) + if (gr != null) + { + if (gr.Min < 0) gr.Min = 0; + if (gr.Max < gr.Min) gr.Max = gr.Min; + } + + if (cfg.Reagents.MinPerLevel == null || cfg.Reagents.MinPerLevel.Length < cfg.Spawn.Levels) + { + cfg.Reagents.MinPerLevel = new int[cfg.Spawn.Levels]; + for (int i = 0; i < cfg.Spawn.Levels; i++) cfg.Reagents.MinPerLevel[i] = Math.Max(1, 1 + i); + } + + if (cfg.Reagents.MaxPerLevel == null || cfg.Reagents.MaxPerLevel.Length < cfg.Spawn.Levels) + { + cfg.Reagents.MaxPerLevel = new int[cfg.Spawn.Levels]; + for (int i = 0; i < cfg.Spawn.Levels; i++) cfg.Reagents.MaxPerLevel[i] = Math.Max(cfg.Reagents.MinPerLevel[i], 2 + i * 2); + } + + if (cfg.Logging.MaxFileSizeBytes <= 0) cfg.Logging.MaxFileSizeBytes = 10 * 1024 * 1024; + if (cfg.Logging.FlushIntervalMs < 50) cfg.Logging.FlushIntervalMs = 50; + if (cfg.Logging.LocationCooldownSeconds < 1) cfg.Logging.LocationCooldownSeconds = 60; + if (cfg.Logging.AccountCooldownSeconds < 1) cfg.Logging.AccountCooldownSeconds = 30; + if (cfg.Magic.DecayMinutesIfDropped < 1) cfg.Magic.DecayMinutesIfDropped = 30; + } + + private static double Clamp(double v, double min, double max) => v < min ? min : (v > max ? max : v); + + public void FinalizeCache() + { + LevelQualityBaseCached = LevelQualityBase ?? Array.Empty(); + GoldRangeByLevelCached = GoldRangeByLevel ?? Array.Empty(); + ReagentsMinCached = Reagents?.MinPerLevel ?? Array.Empty(); + ReagentsMaxCached = Reagents?.MaxPerLevel ?? Array.Empty(); + HueByLevelCached = HueByLevel ?? Array.Empty(); + } +} \ No newline at end of file diff --git a/ToAdd/GraveRobbing/PandoraChestConfig.json b/ToAdd/GraveRobbing/PandoraChestConfig.json new file mode 100644 index 000000000..54ea37b59 --- /dev/null +++ b/ToAdd/GraveRobbing/PandoraChestConfig.json @@ -0,0 +1,62 @@ +{ + "Spawn": { + "Levels": 5, + "BaseSpawnChance": 0.08, + "MiningFactor": 0.0006, + "SpiritSpeakFactor": 0.0008, + "NecromancyFactor": 0.0008, + "MaxSpawnChance": 0.6 + }, + "LevelQualityBase": [0.0, 0.10, 0.25, 0.45, 0.85], + "GoldRangeByLevel": [ + { "Min": 10, "Max": 60 }, + { "Min": 30, "Max": 120 }, + { "Min": 80, "Max": 300 }, + { "Min": 200, "Max": 1200 }, + { "Min": 1500, "Max": 5000 } + ], + "RareDrop": { "BaseChance": 0.02 }, + "Reagents": { + "MinPerLevel": [1, 2, 3, 4, 6], + "MaxPerLevel": [2, 4, 6, 8, 12] + }, + "Weights": { + "Mining": 0.6, + "SpiritSpeak": 1.2, + "Necromancy": 1.2, + "Level": 0.1 + }, + "Guardian": { + "BaseChance": 0.05, + "LevelMultiplier": 0.06, + "MaxChance": 0.95, + "HpMultiplierPerQuality": 1.0, + "DamageMultiplierPerQuality": 0.8 + }, + "Logging": { + "Enabled": true, + "MaxFileSizeBytes": 10485760, + "FlushIntervalMs": 500, + "LocationCooldownSeconds": 60, + "AccountCooldownSeconds": 30 + }, + "Magic": { + "Enabled": true, + "BaseChanceByLevelMultiplier": 0.02, + "DecayMinutesIfDropped": 30, + "MaxAffixesByLevelBase": 1, + "TreasureMapChancePerLevel": 0.03, + "AffixRanges": { + "BonusStr": { "Min": 1, "Max": 5 }, + "BonusDex": { "Min": 1, "Max": 5 }, + "BonusHits": { "Min": 1, "Max": 10 }, + "WeaponDamage": { "Min": 1, "Max": 10 }, + "ArmorPhysical": { "Min": 1, "Max": 10 } + } + }, + "MapMultiplier": { + "Felucca": 1.15, + "Trammel": 1.0 + }, + "HueByLevel": [0, 114, 114, 118, 118] +} \ No newline at end of file diff --git a/ToAdd/GraveRobbing/PandoraChestLogger.cs b/ToAdd/GraveRobbing/PandoraChestLogger.cs new file mode 100644 index 000000000..b0b6d92b6 --- /dev/null +++ b/ToAdd/GraveRobbing/PandoraChestLogger.cs @@ -0,0 +1,270 @@ +/********************************** +* Author: Unknow * +* Update/Fixed/Refactoring: Expa * +* Script: PandoraChestLogger.cs * +* Optimized for ModernUO 0.15.4.18* +**********************************/ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Server; +using Server.Items; +using Server.Mobiles; + +static class PandoraChestLogger +{ + static readonly ConcurrentQueue Q = new(); + static CancellationTokenSource _cts; + static Task _worker; + static string _logFile; + static long _maxSize; + static int _flushInterval; + static readonly object _initLock = new(); + static readonly JsonSerializerOptions _jsonOpts = new() { WriteIndented = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + public static void Initialize() + { + lock (_initLock) + { + if (_worker != null && !_worker.IsCompleted) return; + var baseDir = AppDomain.CurrentDomain.BaseDirectory ?? Environment.CurrentDirectory; + var logsDir = Path.Combine(baseDir, "Logs"); + try { Directory.CreateDirectory(logsDir); } catch (Exception ex) { try { Console.Error.WriteLine($"PandoraChestLogger.Initialize: CreateDirectory failed: {ex}"); } catch { } } + _logFile = Path.Combine(logsDir, "PandoraChestSpawns.log"); + var cfg = PandoraChestConfig.Current ?? new PandoraChestConfig(); + _maxSize = cfg.Logging?.MaxFileSizeBytes ?? (10 * 1024 * 1024); + _flushInterval = Math.Max(50, cfg.Logging?.FlushIntervalMs ?? 500); + _cts = new CancellationTokenSource(); + _worker = Task.Run(() => Worker(_cts.Token)); + } + } + + static async Task Worker(CancellationToken token) + { + var batch = new List(); + while (!token.IsCancellationRequested) + { + try { await Task.Delay(_flushInterval, token).ConfigureAwait(false); } catch { break; } + batch.Clear(); + while (Q.TryDequeue(out var l)) { batch.Add(l); if (batch.Count >= 1000) break; } + if (batch.Count == 0) continue; + try + { + RotateIfNeeded(); + if (!string.IsNullOrEmpty(_logFile)) File.AppendAllLines(_logFile, batch, Encoding.UTF8); + } + catch (Exception ex) + { + try { Console.Error.WriteLine($"PandoraChestLogger.Worker AppendAllLines failed: {ex}"); } catch { } + foreach (var l in batch) try { Console.WriteLine(l); } catch { } + } + } + var rem = new List(); + while (Q.TryDequeue(out var l)) rem.Add(l); + if (rem.Count > 0) + { + try { RotateIfNeeded(); if (!string.IsNullOrEmpty(_logFile)) File.AppendAllLines(_logFile, rem, Encoding.UTF8); } catch (Exception ex) { try { Console.Error.WriteLine($"PandoraChestLogger.Worker final flush failed: {ex}"); } catch { } } + } + } + + static void RotateIfNeeded() + { + try + { + if (string.IsNullOrEmpty(_logFile)) return; + var fi = new FileInfo(_logFile); + if (fi.Exists && fi.Length > _maxSize) + { + var dest = _logFile + "." + DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + try { File.Move(_logFile, dest); } catch (Exception ex) { try { Console.Error.WriteLine($"PandoraChestLogger.RotateIfNeeded File.Move failed: {ex}"); } catch { } } + } + } + catch (Exception ex) { try { Console.Error.WriteLine($"PandoraChestLogger.RotateIfNeeded failed: {ex}"); } catch { } } + } + + static string HashShort(string input) + { + if (string.IsNullOrEmpty(input)) return "(null)"; + try + { + using var sha = SHA256.Create(); + var h = sha.ComputeHash(Encoding.UTF8.GetBytes(input)); + var hex = BitConverter.ToString(h).Replace("-", "").ToLowerInvariant(); + return hex.Length >= 16 ? hex.Substring(0, 16) : hex; + } + catch (Exception ex) { try { Console.Error.WriteLine($"PandoraChestLogger.HashShort failed: {ex}"); } catch { } return "(err)"; } + } + + static void EnqueueStructured(object obj) + { + try + { + var line = JsonSerializer.Serialize(obj, _jsonOpts); + Q.Enqueue(line); + } + catch (Exception ex) + { + try { Console.Error.WriteLine($"PandoraChestLogger.EnqueueStructured failed: {ex}"); } catch { } + } + } + + public static void LogInfo(string tag, string context = null, Mobile who = null, object extra = null) + { + try + { + var entry = new + { + timestamp = DateTime.UtcNow, + level = "INFO", + tag, + context, + player = who?.Name ?? "(null)", + accountHash = SafeAccountHash(who), + extra + }; + EnqueueStructured(entry); + } + catch { } + } + + public static void LogWarn(string tag, string context = null, Mobile who = null, object extra = null) + { + try + { + var entry = new + { + timestamp = DateTime.UtcNow, + level = "WARN", + tag, + context, + player = who?.Name ?? "(null)", + accountHash = SafeAccountHash(who), + extra + }; + EnqueueStructured(entry); + } + catch { } + } + + public static void LogError(string tag, Exception ex, string context = null, Mobile who = null) + { + try + { + var entry = new + { + timestamp = DateTime.UtcNow, + level = "ERROR", + tag, + context, + player = who?.Name ?? "(null)", + accountHash = SafeAccountHash(who), + exception = ex?.ToString() + }; + EnqueueStructured(entry); + } + catch (Exception inner) + { + try { Console.Error.WriteLine($"PandoraChestLogger.LogError failed to enqueue: {inner}. Original: {tag} {context} {ex}"); } catch { } + } + } + + static string SafeAccountHash(Mobile who) + { + try { var acc = who?.Account?.Username ?? who?.Account?.ToString() ?? "(null)"; return HashShort(acc); } catch { return "(err)"; } + } + + public static void LogSpawn(Mobile who, Map map, Point3D loc, int level, double spawnChance) + { + try + { + if (!PandoraChestConfig.Current.Logging.Enabled) return; + } + catch { /* best-effort: fall through */ } + + try + { + var entry = new + { + timestamp = DateTime.UtcNow, + level = "INFO", + tag = "SPAWN", + player = who?.Name ?? "(null)", + accountHash = SafeAccountHash(who), + map = map?.ToString(), + loc = new { x = loc.X, y = loc.Y, z = loc.Z }, + levelNumber = level, + chance = spawnChance + }; + EnqueueStructured(entry); + } + catch (Exception ex) { LogError("PandoraChestLogger.LogSpawn", ex, null, who); } + } + + public static void LogContents(Mobile who, int level, IEnumerable items) + { + try + { + if (!PandoraChestConfig.Current.Logging.Enabled) return; + } + catch { } + + try + { + var entry = new + { + timestamp = DateTime.UtcNow, + level = "INFO", + tag = "CONTENTS", + player = who?.Name ?? "(null)", + accountHash = SafeAccountHash(who), + levelNumber = level, + items = items + }; + EnqueueStructured(entry); + } + catch (Exception ex) { LogError("PandoraChestLogger.LogContents", ex, null, who); } + } + + public static void LogMagicDrop(Mobile who, int level, Item it) + { + try + { + if (!PandoraChestConfig.Current.Logging.Enabled) return; + } + catch { } + + try + { + var entry = new + { + timestamp = DateTime.UtcNow, + level = "INFO", + tag = "MAGIC", + player = who?.Name ?? "(null)", + accountHash = SafeAccountHash(who), + levelNumber = level, + itemType = it?.GetType().Name, + itemName = it?.Name, + hue = it?.Hue ?? 0 + }; + EnqueueStructured(entry); + } + catch (Exception ex) { LogError("PandoraChestLogger.LogMagicDrop", ex, null, who); } + } + + public static void Shutdown(int waitMs = 3000) + { + try + { + _cts?.Cancel(); + _worker?.Wait(waitMs); + } + catch (Exception ex) { try { Console.Error.WriteLine($"PandoraChestLogger.Shutdown error: {ex}"); } catch { } } + } +} \ No newline at end of file diff --git a/ToAdd/GraveRobbing/PandoraChestSpawnGuard.cs b/ToAdd/GraveRobbing/PandoraChestSpawnGuard.cs new file mode 100644 index 000000000..82d1862ef --- /dev/null +++ b/ToAdd/GraveRobbing/PandoraChestSpawnGuard.cs @@ -0,0 +1,66 @@ +/********************************** +* Author: Unknow * +* Update/Fixed/Refactoring: Expa * +* Script: PandoraChestSpawnGuard.cs* +* Optimized for ModernUO 0.15.4.18 * +***********************************/ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading.Tasks; +using Server; + +static class PandoraChestSpawnGuard +{ + static readonly ConcurrentDictionary locs = new(); + static readonly ConcurrentDictionary accs = new(); + + public static void Initialize() => Task.Run(Cleanup); + + static async Task Cleanup() + { + while (true) + { + try + { + await Task.Delay(30000).ConfigureAwait(false); + var now = DateTime.UtcNow; + + foreach (var kv in locs.ToArray()) + { + if ((now - kv.Value).TotalSeconds > 3600) locs.TryRemove(kv.Key, out _); + } + + foreach (var kv in accs.ToArray()) + { + if ((now - kv.Value).TotalSeconds > 3600) accs.TryRemove(kv.Key, out _); + } + } + catch { } + } + } + + static string LocKey(Map? m, Point3D p, int bucket = 2) + { + if (m == null) return $"(null):{p.X / bucket}:{p.Y / bucket}"; + return $"{m}:{p.X / bucket}:{p.Y / bucket}"; + } + + public static bool CanSpawnAt(Map m, Point3D p, PandoraChestConfig cfg) + { + var k = LocKey(m, p); + if (locs.TryGetValue(k, out var t) && (DateTime.UtcNow - t).TotalSeconds < cfg.Logging.LocationCooldownSeconds) return false; + return true; + } + + public static void RecordAt(Map m, Point3D p) => locs[LocKey(m, p)] = DateTime.UtcNow; + + public static bool CanSpawnForAccount(string a, PandoraChestConfig cfg) + { + if (string.IsNullOrEmpty(a)) a = "(null)"; + if (accs.TryGetValue(a, out var t) && (DateTime.UtcNow - t).TotalSeconds < cfg.Logging.AccountCooldownSeconds) return false; + return true; + } + + public static void RecordAccount(string a) { if (string.IsNullOrEmpty(a)) a = "(null)"; accs[a] = DateTime.UtcNow; } +} \ No newline at end of file diff --git a/ToAdd/LockTrapBox.cs b/ToAdd/LockTrapBox.cs new file mode 100644 index 000000000..b6e98deb2 --- /dev/null +++ b/ToAdd/LockTrapBox.cs @@ -0,0 +1,220 @@ +/*********************************** +* Author: Zik (12/21/2013) * +* Refactoring: Expa (05/08/2026) * +* Optimized for ModernUO 0.15.6.48 * +***********************************/ +using ModernUO.Serialization; +using Server; +using Server.Mobiles; +using System; +using System.Runtime.CompilerServices; + +namespace Server.Items; + +public static class TrainingConfig +{ + public const int MaxDistance = 1; + public const int AntiMacroDelayMs = 5000; + public const double MaxSkill = 100.0; + public const double MaxSkillDifficulty = 100.0; +} + +public abstract class SkillTrainingBoxBase : LockableContainer +{ + protected SkillTrainingBoxBase(int itemID) : base(itemID) + { + RequiredSkill = 0; + Movable = false; + } + + protected SkillTrainingBoxBase(Serial serial) : base(serial) + { + } + + private sealed class TrainTimeRecord + { + public DateTime LastTime { get; set; } + } + + private static readonly ConditionalWeakTable _lastTrainTime = new(); + private static readonly object _syncLock = new(); + + protected bool ValidateTraining(Mobile from) + { + if (!from.Alive) + { + from.SendMessage("You are dead."); + return false; + } + + if (!InRange(from.Location, TrainingConfig.MaxDistance)) + { + from.SendMessage("You are too far away."); + return false; + } + + return CheckAntiMacro(from); + } + + private static bool CheckAntiMacro(Mobile from) + { + lock (_syncLock) + { + var now = DateTime.UtcNow; + + if (_lastTrainTime.TryGetValue(from, out var record)) + { + if ((now - record.LastTime).TotalMilliseconds < TrainingConfig.AntiMacroDelayMs) + return false; + } + + return true; + } + } + + protected static void UpdateTrainingTime(Mobile from) + { + lock (_syncLock) + { + if (_lastTrainTime.TryGetValue(from, out var record)) + record.LastTime = DateTime.UtcNow; + else + _lastTrainTime.Add(from, new TrainTimeRecord { LastTime = DateTime.UtcNow }); + } + } +} + +[SerializationGenerator(1, false)] +[Flippable(0xE86, 0xE85)] +public sealed partial class LockpickTrainingBox : SkillTrainingBoxBase +{ + [Constructible] + public LockpickTrainingBox() : base(0x9A8) + { + Name = "Lockpick Training chest"; + Locked = true; + LockLevel = 1; + } + + public LockpickTrainingBox(Serial serial) : base(serial) + { + } + + public override void OnDoubleClick(Mobile from) + { + if (!ValidateTraining(from)) + return; + + var skill = from.Skills.Lockpicking; + + if (skill.Base >= TrainingConfig.MaxSkill) + { + from.SendMessage($"Your Lockpicking skill is already at the maximum level of {TrainingConfig.MaxSkill}."); + return; + } + + var skillValue = Math.Min(skill.Value, TrainingConfig.MaxSkillDifficulty); + RequiredSkill = (int)skillValue; + LockLevel = RequiredSkill; + from.SendMessage($"Training Chest adjusted to {skillValue:F1} Lockpicking"); + Locked = true; + UpdateTrainingTime(from); + } + + public override void LockPick(Mobile from) + { + if (!ValidateTraining(from)) + return; + + var difficulty = RequiredSkill > 0 ? RequiredSkill : 50; + + from.CheckSkill(SkillName.Lockpicking, difficulty * 0.8, difficulty * 1.2); + + Locked = true; + } + + public override void OnSingleClick(Mobile from) + { + LabelTo(from, $"Set to {RequiredSkill} Lockpicking"); + LabelTo(from, Name); + from.SendMessage("Double click the box to set it to your Lockpicking Skill."); + } + + private void Deserialize(IGenericReader reader, int version) + { + } +} + +[SerializationGenerator(1, false)] +[Flippable(0xE86, 0xE85)] +public sealed partial class RemoveTrapTrainingBox : SkillTrainingBoxBase +{ + [Constructible] + public RemoveTrapTrainingBox() : base(0x9A8) + { + Name = "Remove Trap Training chest"; + Locked = false; + LockLevel = 0; + TrapOnLockpick = true; + TrapLevel = 1; + TrapType = TrapType.ExplosionTrap; + } + + public RemoveTrapTrainingBox(Serial serial) : base(serial) + { + } + + public override void OnDoubleClick(Mobile from) + { + if (!ValidateTraining(from)) + return; + + var skill = from.Skills.RemoveTrap; + + if (skill.Base >= TrainingConfig.MaxSkill) + { + from.SendMessage($"Your Remove Trap skill is already at the maximum level of {TrainingConfig.MaxSkill}."); + return; + } + + var skillValue = Math.Min(skill.Value, TrainingConfig.MaxSkillDifficulty); + RequiredSkill = (int)skillValue; + TrapLevel = RequiredSkill; + from.SendMessage($"Training Chest adjusted to {skillValue:F1} Remove Trap"); + Locked = false; + TrapOnLockpick = true; + TrapType = TrapType.ExplosionTrap; + UpdateTrainingTime(from); + } + + public override void LockPick(Mobile from) + { + if (!ValidateTraining(from)) + return; + + var difficulty = TrapLevel > 0 ? TrapLevel : 50; + + if (from.CheckSkill(SkillName.RemoveTrap, difficulty * 0.8, difficulty * 1.2)) + { + } + else + { + ExecuteTrap(from); + } + + TrapType = TrapType.ExplosionTrap; + TrapLevel = RequiredSkill > 0 ? RequiredSkill : 1; + TrapOnLockpick = true; + } + + public override void OnSingleClick(Mobile from) + { + LabelTo(from, $"Set to {RequiredSkill} Remove Trap"); + LabelTo(from, Name); + from.SendMessage("Double click the box to set it to your Remove Trap Skill."); + } + + private void Deserialize(IGenericReader reader, int version) + { + } +} \ No newline at end of file diff --git a/ToAdd/LoginStats.cs b/ToAdd/LoginStats.cs new file mode 100644 index 000000000..5556c9527 --- /dev/null +++ b/ToAdd/LoginStats.cs @@ -0,0 +1,83 @@ +/********************************* +* Author: Anna * +* Fixed and Refactoring: Expa * +* Optimized ModernUO .Net10/C#14 * +**********************************/ +using System; +using System.Linq; +using System.Diagnostics; +using Server; +using Server.Mobiles; +using Server.Network; + +namespace Server.Misc; + +public static class LoginStats +{ + static readonly TimeSpan ReadyDelay = TimeSpan.FromSeconds(1); + static readonly TimeSpan InvulDuration = TimeSpan.FromSeconds(10); + const int HuePersonal = 0x35, HueGlobalLoginLogout = 0x3B2, HueDisconnected = 0x22; + const int MaxReadyAttempts = 10; + + public static void Initialize() + { + EventSink.Connected += OnConnected; + EventSink.BeforeDisconnected += OnBeforeDisconnected; + EventSink.Logout += OnDisconnect; + EventSink.Disconnected += OnDisconnect; + Trace.WriteLine("[LoginStats] initialized."); + } + + static void OnConnected(Mobile m) + { + if (m is PlayerMobile pm && pm.AccessLevel == AccessLevel.Player) + Timer.StartTimer(ReadyDelay, () => HandleLogin(pm, 0)); + } + + static void OnBeforeDisconnected(Mobile m) + { + if (m is PlayerMobile pm) + try { pm.NetState?.Disconnect("Connection lost"); } catch { } + } + + static void OnDisconnect(Mobile m) + { + if (m is PlayerMobile pm) + { + World.Broadcast(HueDisconnected, false, $"{pm.Name} has disconnected."); + Trace.WriteLine($"[LoginStats] Disconnected: {pm.Name}"); + } + } + + static void HandleLogin(PlayerMobile pm, int attempt) + { + if (pm == null || pm.Deleted) return; + if ((pm.NetState == null || pm.SessionStart == default) && attempt < MaxReadyAttempts) + { + Timer.StartTimer(ReadyDelay, () => HandleLogin(pm, attempt + 1)); return; + } + if (pm.NetState == null || pm.SessionStart == default) return; + + var gt = pm.GameTime; + if (gt.TotalDays > 10000) gt = TimeSpan.Zero; + pm.SendMessage(HuePersonal, $"Welcome back {pm.Name}! Total play time: {FormatPlaytime(gt)}"); + + Timer.StartTimer(TimeSpan.FromMilliseconds(500), () => + { + if (pm == null || pm.Deleted) return; + var text = $"{pm.Name} has logged in."; + foreach (var ns in NetState.Instances.ToArray()) + { + var mob = ns?.Mobile; + if (mob == null || mob == pm) continue; + try { mob.SendMessage(HueGlobalLoginLogout, text); } catch { } + } + }); + + pm.Hidden = true; pm.Blessed = true; + Timer.StartTimer(InvulDuration, () => { if (pm != null && !pm.Deleted) { pm.Hidden = false; pm.Blessed = false; } }); + Trace.WriteLine($"[LoginStats] Login: {pm.Name}"); + } + + static string FormatPlaytime(TimeSpan ts) => $"{ts.Days}d {ts.Hours}h {ts.Minutes}m"; +} \ No newline at end of file diff --git a/ToAdd/PirateSystem.zip b/ToAdd/PirateSystem.zip new file mode 100644 index 000000000..95c3fd368 Binary files /dev/null and b/ToAdd/PirateSystem.zip differ diff --git a/ToAdd/SecretDoorLink.cs b/ToAdd/SecretDoorLink.cs new file mode 100644 index 000000000..625d82a20 --- /dev/null +++ b/ToAdd/SecretDoorLink.cs @@ -0,0 +1,239 @@ +using System; +using ModernUO.Serialization; +using Server; +using Server.Commands; +using Server.Items; +using Server.Targeting; + +namespace Server.Items +{ + [SerializationGenerator(0, false)] + public partial class SecretDoorLink : Item + { + [SerializableField(0)] + private Serial _triggerSerial; + + [SerializableField(1)] + private Serial _secretSerial; + + [SerializableField(2)] + private Point3D _triggerSpot; + + [SerializableField(3)] + private Map _triggerMap; + + [SerializableField(4)] + private Point3D _secretHomeLoc; + + [SerializableField(5)] + private Map _secretHomeMap; + + [SerializableField(6)] + private bool _secretIsHidden; + + private Timer _timer; + + [Constructible] + public SecretDoorLink() : base(0x1F14) + { + Name = "Secret Door Link (controller)"; + Movable = true; + StartTimer(); + } + + // ✅ This is the key fix for "doesn't work after restart" + [AfterDeserialization(false)] + private void AfterDeserialization() + { + StartTimer(); + Timer.DelayCall(TimeSpan.Zero, Tick); + } + + public override void OnAfterDelete() + { + base.OnAfterDelete(); + _timer?.Stop(); + _timer = null; + } + + private void StartTimer() + { + _timer?.Stop(); + _timer = Timer.DelayCall(TimeSpan.FromMilliseconds(400), TimeSpan.FromMilliseconds(400), Tick); + } + + private Item TriggerItem => _triggerSerial.IsValid ? World.FindItem(_triggerSerial) : null; + private Item SecretItem => _secretSerial.IsValid ? World.FindItem(_secretSerial) : null; + + private void Tick() + { + if (!_triggerSerial.IsValid || !_secretSerial.IsValid) + return; + + var trigger = TriggerItem; + var secret = SecretItem; + + if (trigger == null || trigger.Deleted || secret == null || secret.Deleted) + return; + + if (_triggerMap == null || _secretHomeMap == null) + return; + + bool onSpot = + trigger.Map == _triggerMap && + trigger.Location == _triggerSpot; + + // REVERSED behavior: + // Trigger ON spot => secret disappears + // Trigger OFF spot => secret appears + if (onSpot) + HideSecret(secret); + else + ShowSecret(secret); + } + + private void ShowSecret(Item secret) + { + if (!_secretIsHidden) + return; + + secret.MoveToWorld(_secretHomeLoc, _secretHomeMap); + secret.Visible = true; + + _secretIsHidden = false; + } + + private void HideSecret(Item secret) + { + if (_secretIsHidden) + return; + + secret.Visible = false; + secret.MoveToWorld(Point3D.Zero, Map.Internal); + + _secretIsHidden = true; + } + + // ---------- GM Commands ---------- + public static void Initialize() + { + CommandSystem.Register("SecretDoorLink", AccessLevel.GameMaster, SecretDoorLinkCommand); + CommandSystem.Register("SecretDoorStatus", AccessLevel.GameMaster, SecretDoorStatusCommand); + } + + [Usage("SecretDoorLink")] + [Description("Links a trigger item to a secret item that disappears when the trigger is placed on a chosen tile, and reappears when moved off.")] + private static void SecretDoorLinkCommand(CommandEventArgs e) + { + e.Mobile.SendMessage("Target the TRIGGER item (the one you will move)."); + e.Mobile.Target = new PickTriggerTarget(); + } + + [Usage("SecretDoorStatus")] + [Description("Target a SecretDoorLink controller to print its configuration.")] + private static void SecretDoorStatusCommand(CommandEventArgs e) + { + e.Mobile.SendMessage("Target the SecretDoorLink controller."); + e.Mobile.Target = new StatusTarget(); + } + + private class PickTriggerTarget : Target + { + public PickTriggerTarget() : base(12, false, TargetFlags.None) { } + + protected override void OnTarget(Mobile from, object targeted) + { + if (targeted is not Item trigger || trigger.Deleted) + { + from.SendMessage("That is not a valid item."); + return; + } + + from.SendMessage("Now target the SECRET item (the door/item that will appear/disappear)."); + from.Target = new PickSecretTarget(trigger); + } + } + + private class PickSecretTarget : Target + { + private readonly Item _trigger; + + public PickSecretTarget(Item trigger) : base(12, false, TargetFlags.None) + { + _trigger = trigger; + } + + protected override void OnTarget(Mobile from, object targeted) + { + if (targeted is not Item secret || secret.Deleted) + { + from.SendMessage("That is not a valid item."); + return; + } + + from.SendMessage("Now target the LOCATION (ground tile) where the trigger must be placed."); + from.Target = new PickLocationTarget(_trigger, secret); + } + } + + private class PickLocationTarget : Target + { + private readonly Item _trigger; + private readonly Item _secret; + + public PickLocationTarget(Item trigger, Item secret) : base(18, true, TargetFlags.None) + { + _trigger = trigger; + _secret = secret; + } + + protected override void OnTarget(Mobile from, object targeted) + { + if (targeted is not IPoint3D p) + { + from.SendMessage("Invalid location."); + return; + } + + var map = from.Map; + + var controller = new SecretDoorLink + { + _triggerSerial = _trigger.Serial, + _secretSerial = _secret.Serial, + _triggerSpot = new Point3D(p), + _triggerMap = map, + _secretHomeLoc = _secret.Location, + _secretHomeMap = _secret.Map, + _secretIsHidden = false + }; + + controller.MoveToWorld(from.Location, from.Map); + + // Force initial evaluation + controller.Tick(); + + from.SendMessage("Linked! Trigger ON spot => secret disappears. Trigger OFF spot => secret appears."); + } + } + + private class StatusTarget : Target + { + public StatusTarget() : base(12, false, TargetFlags.None) { } + + protected override void OnTarget(Mobile from, object targeted) + { + if (targeted is not SecretDoorLink link || link.Deleted) + { + from.SendMessage("That is not a SecretDoorLink controller."); + return; + } + + from.SendMessage($"Trigger: {link._triggerSerial} Secret: {link._secretSerial}"); + from.SendMessage($"Spot: {link._triggerSpot} on {link._triggerMap}"); + from.SendMessage($"Secret home: {link._secretHomeLoc} on {link._secretHomeMap}"); + from.SendMessage($"Hidden now: {link._secretIsHidden}"); + } + } + } +} diff --git a/ToAdd/SquireSystem.zip b/ToAdd/SquireSystem.zip new file mode 100644 index 000000000..b594e1ec2 Binary files /dev/null and b/ToAdd/SquireSystem.zip differ diff --git a/ToAdd/Traps/CloneTrap.cs b/ToAdd/Traps/CloneTrap.cs new file mode 100644 index 000000000..551250e3d --- /dev/null +++ b/ToAdd/Traps/CloneTrap.cs @@ -0,0 +1,309 @@ +/********************************** +* Author: Expa * +* Optimized ModernUO .Net 10/C#14 * +**********************************/ +using System; +using System.Collections.Concurrent; +using System.Linq.Expressions; +using ModernUO.Serialization; +using Server.Items; +using Server.Mobiles; + +namespace Server.Items +{ + [SerializationGenerator(0, false)] + public partial class CloneTrap : Item + { + private const int GraphicId = 0xA723; + private const int MoldHue = 2894; + + [Constructible] + public CloneTrap() : base(GraphicId) + { + Name = "a Clone trap"; + Movable = false; + Visible = false; + Hue = MoldHue; + } + + public override bool OnMoveOver(Mobile m) + { + if (m?.CanBeDamaged() != true) + return base.OnMoveOver(m); + + m.FixedParticles(0x36BD, 20, 10, 5044, EffectLayer.Head); + m.PlaySound(0x307); + _ = new MirrorClone(m) { Location = m.Location, Map = m.Map }; + Delete(); + + return base.OnMoveOver(m); + } + + private void Deserialize(IGenericReader reader, int version) { } + } + + [SerializationGenerator(0, false)] + public partial class MirrorClone : BaseCreature + { + private const int CloneHue = 2894; + private const int TimerMin = 120; + private const int TimerMax = 300; + private const double DmgMult = 2.0; + private const double ResMult = 0.5; + + [SerializableField(0)] + private Mobile _victim; + + private MirrorCloneTimer _timer; + + public MirrorClone(Mobile victim) : base(AIType.AI_Melee, FightMode.Closest, 10, 1) + { + _victim = victim; + Hue = CloneHue; + Copy(); + (_timer = new MirrorCloneTimer(this)).Start(); + } + + public override bool AlwaysMurderer => true; + + private void Copy() + { + var v = _victim; + if (v is null) + return; + + BodyMod = v.Body; + Name = v.Name; + Title = v.Title; + Fame = v.Fame; + Female = v.Female; + NameHue = v.NameHue; + SpeechHue = v.SpeechHue; + Criminal = v.Criminal; + Karma = v.Karma; + Kills = v.Kills; + + RawStr = Math.Clamp((int)(v.RawStr * DmgMult), 3, 225); + RawDex = v.RawDex; + RawInt = v.RawInt; + + StatCap = Math.Clamp((int)(v.StatCap * DmgMult), 60, 900); + SkillsCap = v.SkillsCap; + + Hits = Math.Clamp((int)(v.Hits * DmgMult), 1, 32767); + Mana = v.Mana; + Stam = v.Stam; + + ApplyResistanceBalance(); + CopySkills(); + CopyEquipment(); + CopyMount(); + } + + private void ApplyResistanceBalance() + { + if (_victim is null) + return; + + SetResistance(ResistanceType.Physical, Math.Clamp((int)(_victim.PhysicalResistance * ResMult), 0, 100)); + SetResistance(ResistanceType.Fire, Math.Clamp((int)(_victim.FireResistance * ResMult), 0, 100)); + SetResistance(ResistanceType.Cold, Math.Clamp((int)(_victim.ColdResistance * ResMult), 0, 100)); + SetResistance(ResistanceType.Poison, Math.Clamp((int)(_victim.PoisonResistance * ResMult), 0, 100)); + SetResistance(ResistanceType.Energy, Math.Clamp((int)(_victim.EnergyResistance * ResMult), 0, 100)); + } + + private void CopySkills() + { + if (_victim is null) + return; + + var skills = _victim.Skills; + var len = skills.Length; + for (int i = 0; i < len; i++) + { + Skills[i].Base = skills[i].Base; + Skills[i].Cap = skills[i].Cap; + } + } + + private void CopyEquipment() + { + var items = _victim?.Items; + if (items is null || items.Count == 0) + return; + + for (int i = 0, cnt = items.Count; i < cnt; i++) + { + var item = items[i]; + if (item?.Parent != _victim || item is IMountItem) + continue; + + var copy = ItemFactory.CreateInstance(item); + if (copy is not null) + { + CopyItemProperties(copy, item); + AddItem(copy); + } + } + } + + private void CopyMount() + { + var mount = _victim?.FindItemOnLayer(Layer.Mount); + if (mount is not IMountItem) + return; + + var copy = ItemFactory.CreateInstance(mount); + if (copy is null) + return; + + CopyItemProperties(copy, mount); + AddItem(copy); + + if (copy is IMountItem { Mount: BaseMount baseMount }) + baseMount.Rider = this; + } + + private static void CopyItemProperties(Item dest, Item src) + { + dest.ItemID = src.ItemID; + dest.Amount = src.Amount; + dest.Weight = src.Weight; + dest.Layer = src.Layer; + dest.Hue = src.Hue; + dest.Name = src.Name; + dest.Movable = src.Movable; + dest.LootType = src.LootType; + } + + public override void OnDeath(Container c) + { + Cleanup(); + base.OnDeath(c); + } + + public override void OnDelete() + { + Cleanup(); + base.OnDelete(); + } + + private void Cleanup() + { + _timer?.Stop(); + DismountAndClear(); + } + + private void DismountAndClear() + { + if (Mount is BaseMount mount) + { + mount.Rider = null; + mount.Delete(); + } + + var items = Items; + for (int i = items.Count - 1; i >= 0; i--) + items[i].Delete(); + + Corpse?.Delete(); + } + + private void Deserialize(IGenericReader reader, int version) + { + _victim = reader.ReadEntity(); + if (_victim is not null) + Delete(); + } + + private sealed class MirrorCloneTimer : Timer + { + private WeakReference _ref; + + public MirrorCloneTimer(MirrorClone clone) + : base(TimeSpan.FromSeconds(Utility.Random(TimerMin, TimerMax))) + { + _ref = new WeakReference(clone); + } + + protected override void OnTick() + { + if (!_ref.TryGetTarget(out var clone) || clone.Deleted) + { + Stop(); + return; + } + + clone.Warmode = false; + Effects.SendLocationParticles( + EffectItem.Create(clone.Location, clone.Map, EffectItem.DefaultDuration), + 0x3728, 10, 10, 2023 + ); + clone.Delete(); + Stop(); + } + } + + private static class ItemFactory + { + private static readonly ConcurrentDictionary> _ctors = new(); + + public static Item CreateInstance(Item src) + { + if (src is null) + return null; + + var type = src.GetType(); + var factory = _ctors.GetOrAdd(type, CreateFactory); + + if (factory is null) + { + try + { + return new Item(src.ItemID); + } + catch + { + return null; + } + } + + try + { + var item = factory(); + return item; + } + catch + { + try + { + return new Item(src.ItemID); + } + catch + { + return null; + } + } + } + + private static Func CreateFactory(Type t) + { + try + { + var ctor = t.GetConstructor(Type.EmptyTypes); + if (ctor == null) + return null; + + var newExp = Expression.New(ctor); + var conv = Expression.Convert(newExp, typeof(Item)); + var lambda = Expression.Lambda>(conv); + return lambda.Compile(); + } + catch + { + return null; + } + } + } + } +} \ No newline at end of file diff --git a/ToAdd/Traps/CurseItemsTrap.cs b/ToAdd/Traps/CurseItemsTrap.cs new file mode 100644 index 000000000..a2b26ff4e --- /dev/null +++ b/ToAdd/Traps/CurseItemsTrap.cs @@ -0,0 +1,301 @@ +/********************************** +* Author: Expa * +* Optimized ModernUO .Net 10/C#14 * +**********************************/ +using System; +using System.Collections.Generic; +using ModernUO.Serialization; +using Server; +using Server.Items; +using Server.Mobiles; +using Server.Targeting; + +namespace Server.Items; + +static class CurseConfig +{ + public const int ScrollId = 0x1F2E; + public const int ScrollHue = 0x58; + public const int TrapId = 0x1B7B; + public const int Range = 1; + public const int SoundRemove = 0x1F7; + public const int SoundTrap = 0x1E1; + public const int PartRemove = 0x373A; + public const int PartTrap = 0x374B; + public const string TrapName = "Hidden Curse Trap"; + public static readonly TimeSpan Cooldown = TimeSpan.FromMinutes(5); + public const bool ShowWhileCooling = true; + public const string Suffix = " [The Cursed!]"; + public const string MsgNotInPack = "Scroll must be in backpack."; + public const string MsgPrompt = "Choose cursed item."; + public const string MsgNotCursed = "Not cursed."; + public const string MsgSelect = "Select an item."; + public const string MsgRemovedPack = "Curse removed (backpack)."; + public const string MsgRemovedGround = "Curse removed (ground)."; + public const string MsgReload = "Trap reloading!"; + public const string MsgCursed = "Item cursed and moved."; +} + +[SerializationGenerator(0, false)] +public partial class CurseRemovalScroll : Item +{ + [Constructible] + public CurseRemovalScroll() : base(CurseConfig.ScrollId) => + Name = "Curse Removal Scroll"; + + public CurseRemovalScroll(Serial s) : base(s) { } + + public override void OnDoubleClick(Mobile from) + { + if (from?.Backpack is null || !this.IsChildOf(from.Backpack)) + { + from?.SendMessage(CurseConfig.MsgNotInPack); + return; + } + + from.Target = new T(this); + from.SendMessage(CurseConfig.MsgPrompt); + } + + private sealed class T : Target + { + private readonly CurseRemovalScroll _s; + + public T(CurseRemovalScroll s) : base(3, false, TargetFlags.None) => _s = s; + + protected override void OnTarget(Mobile from, object target) + { + if (_s?.Deleted != false || from?.Deleted != false) + return; + + if (target is not Item it) + { + from.SendMessage(CurseConfig.MsgSelect); + return; + } + + var isCursed = it.LootType == LootType.Cursed || + (it.Name?.EndsWith(CurseConfig.Suffix, StringComparison.Ordinal) == true); + + if (!isCursed) + { + from.SendMessage(CurseConfig.MsgNotCursed); + return; + } + + it.LootType = LootType.Regular; + it.Movable = true; + + if (!string.IsNullOrEmpty(it.Name) && it.Name.EndsWith(CurseConfig.Suffix, StringComparison.Ordinal)) + { + var baseName = it.Name[..^CurseConfig.Suffix.Length]; + it.Name = string.IsNullOrEmpty(baseName) ? null : baseName; + } + + from.PlaySound(CurseConfig.SoundRemove); + from.FixedParticles(CurseConfig.PartRemove, 10, 15, 5018, EffectLayer.Waist); + from.SendMessage(it.IsChildOf(from.Backpack) ? CurseConfig.MsgRemovedPack : CurseConfig.MsgRemovedGround); + _s.Delete(); + } + } +} + +[SerializationGenerator(0, false)] +public partial class CurseTrap : BaseTrap +{ + private DateTime _next = DateTime.Now; + + [Constructible] + public CurseTrap() : base(CurseConfig.TrapId) => InitTrap(); + + public CurseTrap(Serial s) : base(s) => InitTrap(); + + private void InitTrap() + { + Name = CurseConfig.TrapName; + Movable = false; + Visible = false; + } + + public override bool PassivelyTriggered => true; + public override TimeSpan PassiveTriggerDelay => TimeSpan.Zero; + public override int PassiveTriggerRange => CurseConfig.Range; + public override TimeSpan ResetDelay => TimeSpan.Zero; + + public override void OnTrigger(Mobile from) + { + if (from?.Alive != true || !from.Player) + return; + + if (Core.Now < _next) + { + from.SendMessage(CurseConfig.MsgReload); + return; + } + + var item = SelectTarget(from); + if (item == null) + return; + + var oldName = item.Name; + var oldLoot = item.LootType; + var oldMovable = item.Movable; + + try + { + if (item.Parent == from) + from.RemoveItem(item); + + item.LootType = LootType.Regular; + item.Movable = false; + + if (!(item.Name?.EndsWith(CurseConfig.Suffix) == true)) + { + var baseName = oldName ?? item.ItemData.Name ?? item.GetType().Name; + item.Name = baseName + CurseConfig.Suffix; + } + + if (!item.IsChildOf(from.Backpack) && !TryPlaceInBackpack(from, item)) + item.MoveToWorld(from.Location, from.Map); + + from.PlaySound(CurseConfig.SoundTrap); + from.FixedParticles(CurseConfig.PartTrap, 10, 10, 5044, EffectLayer.Head); + from.SendMessage(CurseConfig.MsgCursed); + _next = Core.Now + CurseConfig.Cooldown; + } + catch + { + if (item?.Deleted == false) + { + item.LootType = oldLoot; + item.Movable = oldMovable; + item.Name = oldName; + } + } + } + + private static bool TryPlaceInBackpack(Mobile to, Item it) + { + if (to?.Backpack is null || it.IsChildOf(to.Backpack)) + return false; + + try + { + return to.Backpack.TryDropItem(to, it, false); + } + catch + { + try + { + to.Backpack.DropItem(it); + return true; + } + catch + { + return false; + } + } + } + + private static Item SelectTarget(Mobile m) + { + if (m == null) + return null; + + double totalWeight = 0.0; + Item firstCandidate = null; + + foreach (var itm in m.Items) + { + if (itm?.Deleted != false) + continue; + + var inEquip = itm.Parent == m; + var inPack = m.Backpack != null && itm.IsChildOf(m.Backpack); + + if (!inEquip && !inPack) + continue; + + if (itm.LootType == LootType.Cursed || !itm.Movable) + continue; + + if (itm.Layer is Layer.Backpack or Layer.Hair or Layer.FacialHair) + continue; + + var weight = CalculateWeight(itm); + if (weight <= 0.0) + continue; + + if (firstCandidate == null) + firstCandidate = itm; + + totalWeight += weight; + } + + if (totalWeight <= 0.0) + return firstCandidate; + + var roll = Utility.RandomDouble() * totalWeight; + var accumulated = 0.0; + + foreach (var itm in m.Items) + { + if (itm?.Deleted != false) + continue; + + var inEquip = itm.Parent == m; + var inPack = m.Backpack != null && itm.IsChildOf(m.Backpack); + + if (!inEquip && !inPack) + continue; + + if (itm.LootType == LootType.Cursed || !itm.Movable) + continue; + + if (itm.Layer is Layer.Backpack or Layer.Hair or Layer.FacialHair) + continue; + + var weight = CalculateWeight(itm); + if (weight <= 0.0) + continue; + + accumulated += weight; + if (accumulated >= roll) + return itm; + } + + return firstCandidate; + } + + private static double CalculateWeight(Item it) + { + var baseWeight = it.Amount > 1 ? Math.Min(it.Amount, 100) : 1.0; + + return it switch + { + Gold g => baseWeight + Math.Min(g.Amount, 1000) / 10.0, + Bandage => baseWeight + 50, + Arrow => baseWeight + 50, + Bolt => baseWeight + 50, + SpellScroll => baseWeight + 40, + BasePotion => baseWeight + 40, + BaseReagent => baseWeight + 40, + Spellbook => baseWeight + 60, + Runebook => baseWeight + 80, + _ => GetByTypeName(it, baseWeight) + }; + } + + private static double GetByTypeName(Item it, double baseWeight) + { + var typeName = it.GetType().Name; + + if (typeName.Contains("scroll", StringComparison.OrdinalIgnoreCase)) + return baseWeight + 20; + + if (typeName.Contains("book", StringComparison.OrdinalIgnoreCase) && it is not Spellbook) + return baseWeight + 15; + + return baseWeight; + } +} \ No newline at end of file diff --git a/ToAdd/Traps/LandMobTrap.cs b/ToAdd/Traps/LandMobTrap.cs new file mode 100644 index 000000000..dc59f99d2 --- /dev/null +++ b/ToAdd/Traps/LandMobTrap.cs @@ -0,0 +1,167 @@ +/********************************** +* Author: Expa * +* Optimized ModernUO .Net 10/C#14 * +**********************************/ +using System; +using ModernUO.Serialization; +using Server; +using Server.Mobiles; + +namespace Server.Items; + +[SerializationGenerator(0, false)] +public partial class LandMobTrap : Item +{ + [SerializableField(0)] private int _minSpawn = 3; + [SerializableField(1)] private int _maxSpawn = 6; + [SerializableField(2)] private double _triggerDelaySeconds = 0.5; + [SerializableField(3)] private int _soundID = 0x1C6; + [SerializableField(4)] private int _particleItemId = 0x373A; + [SerializableField(5)] private byte _particleCount = 20; + [SerializableField(6)] private byte _particleSpeed = 10; + [SerializableField(7)] private byte _flags = 1; + + private const int MaxAllowedSpawn = 12; + private TimerExecutionToken _spawnToken; + + private bool Armed + { + get => (_flags & 1) != 0; + set => _flags = (byte)(value ? (_flags | 1) : (_flags & ~1)); + } + + private bool BlockMovement + { + get => (_flags & 2) != 0; + set => _flags = (byte)(value ? (_flags | 2) : (_flags & ~2)); + } + + [Constructible] + public LandMobTrap() : base(0x1B7A) + { + Movable = false; + Hue = 1058; + Visible = false; + Name = "land mob trap"; + } + + public override bool HandlesOnMovement => true; + + public override bool OnMoveOver(Mobile m) + { + var map = Map; + if (Deleted || map == null || map == Map.Internal) + return true; + + if (!Armed || !m.Player || m.AccessLevel > AccessLevel.Player) + return true; + + foreach (var bc in GetMobilesInRange(1)) + { + if (bc is Skeleton or SkeletalMage or SkeletalKnight) + return true; + } + + Armed = false; + InvalidateProperties(); + + var min = Math.Max(1, _minSpawn); + var max = Math.Clamp(_maxSpawn, min, MaxAllowedSpawn); + + if (_triggerDelaySeconds <= 0.0) + DoSpawn(m, min, max); + else + Timer.StartTimer(TimeSpan.FromSeconds(_triggerDelaySeconds), () => DoSpawn(m, min, max), out _spawnToken); + + return !BlockMovement; + } + + private void DoSpawn(Mobile trigger, int minSpawn, int maxSpawn) + { + try + { + var map = Map; + if (Deleted || map == null || map == Map.Internal) + return; + + var count = Utility.RandomMinMax(minSpawn, maxSpawn); + + for (var i = 0; i < count; ++i) + { + var cr = CreateRandomCreature(); + if (cr is null) + continue; + + try + { + if (TryFindSpawnLocation(map, out var loc)) + { + cr.MoveToWorld(loc, map); + cr.Combatant = trigger; + } + else + { + cr.Delete(); + } + } + catch + { + cr?.Delete(); + } + } + + Effects.SendLocationParticles( + EffectItem.Create(Location, Map, EffectItem.DefaultDuration), + _particleItemId, _particleCount, _particleSpeed, 5044 + ); + Effects.PlaySound(Location, Map, _soundID); + } + finally + { + Delete(); + } + } + + private static BaseCreature CreateRandomCreature() => + Utility.Random(5) switch + { + 0 or 1 => new Skeleton(), + 2 or 3 => new SkeletalMage(), + _ => new SkeletalKnight() + }; + + private bool TryFindSpawnLocation(Map map, out Point3D loc) + { + loc = Location; + + for (var i = 0; i < 10; ++i) + { + var x = X + Utility.Random(3) - 1; + var y = Y + Utility.Random(3) - 1; + var z = map.GetAverageZ(x, y); + + if (map.CanSpawnMobile(new Point2D(x, y), z)) + { + loc = new Point3D(x, y, z); + return true; + } + + if (map.CanFit(x, y, Z, 16, false, false)) + { + loc = new Point3D(x, y, Z); + return true; + } + } + + return false; + } + + public override void OnAfterDelete() + { + _spawnToken.Cancel(); + base.OnAfterDelete(); + } + + [AfterDeserialization] + private void AfterDeserialization() { } +} \ No newline at end of file diff --git a/ToAdd/Traps/MagnetItemsTrap.cs b/ToAdd/Traps/MagnetItemsTrap.cs new file mode 100644 index 000000000..3d90ec3cf --- /dev/null +++ b/ToAdd/Traps/MagnetItemsTrap.cs @@ -0,0 +1,215 @@ +/********************************** +* Author: Expa * +* Optimized ModernUO .Net 10/C#14 * +**********************************/ +using System; +using System.Collections.Generic; +using ModernUO.Serialization; +using Server; +using Server.Items; +using Server.Mobiles; + +namespace Server.Items; + +[SerializationGenerator(0, false)] +public partial class MagnetTrap : Item +{ + private TimerExecutionToken _timerToken; + private TimerExecutionToken _deleteTimerToken; + private readonly TimeSpan _autoDelete = TimeSpan.FromSeconds(30); + private readonly Dictionary _dropState = new(); + private DateTime _lastCleanup = Core.Now; + + private const int MetalDropChancePercent = 60; + private const int MaxDropsPerPlayer = 3; + private const int CleanupIntervalSeconds = 5; + private const int DropStateStaleSeconds = 10; + + private static readonly HashSet MetalResourceNames = new(StringComparer.OrdinalIgnoreCase) + { + "Iron", "Steel", "DullCopper", "ShadowIron", "Copper", "Bronze", + "Gold", "Silver", "Agapite", "Verite", "Valorite" + }; + + [Constructible] + public MagnetTrap() : base(0x1AC5) + { + Movable = false; + Name = "a magnetic trap"; + Hue = 1341; + Timer.StartTimer(TimeSpan.Zero, TimeSpan.FromSeconds(1), DoMagneticEffect, out _timerToken); + StartDeleteTimer(); + } + + private void DoMagneticEffect() + { + if (Deleted) + return; + + Effects.SendLocationParticles( + EffectItem.Create(Location, Map, EffectItem.DefaultDuration), + 0x376A, 9, 32, 5024, 0, 5024, 0 + ); + Effects.PlaySound(Location, Map, 0x1F0); + + foreach (var m in GetMobilesInRange(1)) + { + if (m is PlayerMobile pm) + TryDropRandomItem(pm); + } + + var now = Core.Now; + if ((now - _lastCleanup).TotalSeconds >= CleanupIntervalSeconds) + { + _lastCleanup = now; + CleanupDropState(now); + } + } + + private void TryDropRandomItem(PlayerMobile player) + { + if (player?.Deleted != false) + return; + + var now = Core.Now; + + if (!_dropState.TryGetValue(player, out var state)) + state = (0, now); + + if (state.Count >= MaxDropsPerPlayer) + return; + + _dropState[player] = (state.Count, now); + + var preferMetal = Utility.Random(100) < MetalDropChancePercent; + var dropped = preferMetal + ? TryDropFrom(player.Items, IsMetal, player) || TryDropFrom(player.Backpack?.Items, IsMetal, player) + : TryDropFrom(player.Backpack?.Items, IsMetal, player) || TryDropFrom(player.Items, IsMetal, player); + + if (dropped) + _dropState[player] = (state.Count + 1, now); + } + + private bool TryDropFrom(IEnumerable source, Func predicate, PlayerMobile player) + { + if (source is null) + return false; + + Item candidate = null; + var count = 0; + + foreach (var item in source) + { + if (item?.Movable != true || !CanDropItem(item) || !predicate(item)) + continue; + + count++; + if (Utility.Random(count) == 0) + candidate = item; + } + + return candidate is not null && DropItemToGround(candidate, player); + } + + private static bool CanDropItem(Item item) + { + if (item?.Movable != true) + return false; + + if (item.LootType == LootType.Blessed || item.LootType == LootType.Newbied) + return false; + + if (item is LockableContainer lockable && lockable.Locked) + return false; + + return true; + } + + private static bool IsMetal(Item item) + { + if (item is null) + return false; + + if (item is BaseWeapon weapon) + return IsMetalResource(weapon.Resource); + + if (item is BaseArmor armor) + return IsMetalResource(armor.Resource); + + if (item is BaseShield shield) + return IsMetalResource(shield.Resource); + + if (item is BaseJewel) + return true; + + var typeName = item.GetType().Name; + return typeName.Contains("sword", StringComparison.OrdinalIgnoreCase) || + typeName.Contains("axe", StringComparison.OrdinalIgnoreCase) || + typeName.Contains("mace", StringComparison.OrdinalIgnoreCase) || + typeName.Contains("dagger", StringComparison.OrdinalIgnoreCase) || + typeName.Contains("knife", StringComparison.OrdinalIgnoreCase) || + typeName.Contains("plate", StringComparison.OrdinalIgnoreCase) || + typeName.Contains("chain", StringComparison.OrdinalIgnoreCase) || + typeName.Contains("shield", StringComparison.OrdinalIgnoreCase) || + typeName.Contains("helmet", StringComparison.OrdinalIgnoreCase) || + typeName.Contains("gauntlet", StringComparison.OrdinalIgnoreCase) || + typeName.Contains("ring", StringComparison.OrdinalIgnoreCase) || + typeName.Contains("necklace", StringComparison.OrdinalIgnoreCase) || + typeName.Contains("earrings", StringComparison.OrdinalIgnoreCase) || + typeName.Contains("bracelet", StringComparison.OrdinalIgnoreCase) || + typeName.Contains("talisman", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsMetalResource(CraftResource resource) + { + var resName = CraftResources.GetName(resource); + return !string.IsNullOrEmpty(resName) && MetalResourceNames.Contains(resName); + } + + private bool DropItemToGround(Item item, PlayerMobile player) + { + if (item?.Movable != true || player is null || !CanDropItem(item)) + return false; + + try + { + item.MoveToWorld(player.Location, player.Map); + player.SendLocalizedMessage(502690); + return true; + } + catch + { + return false; + } + } + + private void CleanupDropState(DateTime now) + { + var keysToRemove = new List(); + + foreach (var kv in _dropState) + { + var player = kv.Key; + var lastSeen = kv.Value.LastSeen; + if (player is null || player.Deleted || (now - lastSeen).TotalSeconds > DropStateStaleSeconds) + keysToRemove.Add(player); + } + + foreach (var key in keysToRemove) + _dropState.Remove(key); + } + + private void StartDeleteTimer() + { + _deleteTimerToken.Cancel(); + Timer.StartTimer(_autoDelete, Delete, out _deleteTimerToken); + } + + public override void OnDelete() + { + _timerToken.Cancel(); + _deleteTimerToken.Cancel(); + _dropState.Clear(); + base.OnDelete(); + } +} \ No newline at end of file diff --git a/ToAdd/Traps/SealedItemsTrap.cs b/ToAdd/Traps/SealedItemsTrap.cs new file mode 100644 index 000000000..166dd9af0 --- /dev/null +++ b/ToAdd/Traps/SealedItemsTrap.cs @@ -0,0 +1,182 @@ +/********************************** +* Author: Expa * +* Optimized ModernUO .Net 10/C#14 * +**********************************/ +using System; +using ModernUO.Serialization; +using Server; +using Server.Mobiles; +using Server.Targeting; + +namespace Server.Items; + +public static class SealedTrapConfig +{ + public const int MinItemsToSeal = 1; + public const int MaxItemsToSeal = 5; + public const int TrapItemId = 0x9CDD; + public const int SealedChestItemId = 0x9AA; + public const bool DeleteAfterTrigger = true; + public const string ActivationMessage = "The trap has sealed {0} items in the chest!"; + public const string SealedChestMessage = "This chest is magically sealed! Only the Seal Removal Scroll can open it."; +} + +[SerializationGenerator(0, false)] +public partial class ItemSealingTrap : Item +{ + [Constructible] + public ItemSealingTrap() : base(SealedTrapConfig.TrapItemId) + { + Movable = false; + Visible = false; + Name = "The Sealing trap"; + } + + public override bool OnMoveOver(Mobile m) + { + if (m is not PlayerMobile player || player.AccessLevel > AccessLevel.Player) + return base.OnMoveOver(m); + + var backpack = player.Backpack; + if (backpack?.Items.Count == 0) + return base.OnMoveOver(m); + + var itemsToSeal = Math.Min(Utility.RandomMinMax(SealedTrapConfig.MinItemsToSeal, SealedTrapConfig.MaxItemsToSeal), backpack.Items.Count); + var chest = new SealedItemsChest(); + + for (var i = 0; i < itemsToSeal; i++) + { + var idx = Utility.Random(backpack.Items.Count); + var item = backpack.Items[idx]; + backpack.RemoveItem(item); + chest.DropItem(item); + } + + chest.MoveToWorld(Location, Map); + player.SendMessage(string.Format(SealedTrapConfig.ActivationMessage, itemsToSeal)); + + if (SealedTrapConfig.DeleteAfterTrigger) + Delete(); + + return base.OnMoveOver(m); + } +} + +[Furniture] +[SerializationGenerator(0, false)] +public partial class SealedItemsChest : LockableContainer +{ + private bool _sealed = true; + + [CommandProperty(AccessLevel.GameMaster)] + public bool Sealed + { + get => _sealed; + set + { + _sealed = value; + Locked = value; + InvalidateProperties(); + } + } + + [Constructible] + public SealedItemsChest() : base(SealedTrapConfig.SealedChestItemId) + { + Locked = true; + Name = "A sealed little chest"; + LockLevel = ILockpickable.CannotPick; + RequiredSkill = 0; + MaxLockLevel = 0; + } + + public override void OnDoubleClick(Mobile from) + { + if (Sealed) + from?.SendMessage(SealedTrapConfig.SealedChestMessage); + else + base.OnDoubleClick(from); + } + + public override void GetProperties(IPropertyList list) + { + base.GetProperties(list); + if (_sealed) + list.Add("Sealed"); + } + + public void Unseal() + { + Sealed = false; + LockLevel = ILockpickable.CannotPick; + Name = "A small chest"; + } + + public override void Open(Mobile from) + { + if (_sealed) + from?.SendMessage(SealedTrapConfig.SealedChestMessage); + else + base.Open(from); + } + + public override bool CheckLocked(Mobile from) + { + if (!Locked) + return false; + + if (_sealed) + { + from?.SendMessage(SealedTrapConfig.SealedChestMessage); + return true; + } + + return base.CheckLocked(from); + } +} + +[SerializationGenerator(0, false)] +public partial class ScrollOfUnsealing : Item +{ + [Constructible] + public ScrollOfUnsealing() : base(0x1F4F) + { + Name = "The Seal Removal Scroll"; + Weight = 1.0; + } + + public override void OnDoubleClick(Mobile from) + { + from?.Target = new UnsealTarget(this); + from?.SendMessage("Select a sealed chest."); + } + + private sealed class UnsealTarget : Target + { + private readonly ScrollOfUnsealing _scroll; + + public UnsealTarget(ScrollOfUnsealing scroll) : base(3, false, TargetFlags.None) => + _scroll = scroll; + + protected override void OnTarget(Mobile from, object targeted) + { + if (_scroll?.Deleted != false) + return; + + switch (targeted) + { + case SealedItemsChest { Sealed: true } chest: + chest.Unseal(); + from?.SendMessage("The seal has been lifted!"); + _scroll.Delete(); + break; + case SealedItemsChest: + from?.SendMessage("The chest has already been opened!"); + break; + default: + from?.SendMessage("It's not a sealed chest!"); + break; + } + } + } +} \ No newline at end of file