diff --git a/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs index d77cadcae..65ea66386 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs @@ -1,9 +1,12 @@ using System; +using System.Collections.Generic; using Server; using Server.Engines.Harvest; using Server.Items; using Server.Mobiles; using Server.Random; +using Server.Targeting; +using Server.Tests; using Xunit; namespace UOContent.Tests; @@ -16,6 +19,9 @@ public class FishingTests // On land in Britain, so a cast there never counts as deep water. private static readonly Point3D _shallow = new(1434, 1699, 0); + // Open sea with no land within 30 tiles: deep water once the client's map is loaded. + private static readonly Point3D _deep = new(1840, 1760, -5); + private static readonly Type[] _magicFish = [typeof(PrizedFish), typeof(WondrousFish), typeof(TrulyRareFish), typeof(PeculiarFish)]; @@ -99,10 +105,702 @@ public class FishingTests } } + [Theory] + [InlineData(Expansion.None, 0.0)] + [InlineData(Expansion.T2A, 100.0)] + [InlineData(Expansion.UOR, 50.0)] + [InlineData(Expansion.LBR, 0.0)] + [InlineData(Expansion.EJ, 120.0)] + public void MutateType_NeverComesBackEmpty(Expansion expansion, double skill) + { + var previousExpansion = Core.Expansion; + var previousGenerator = BuiltInRng.Generator; + var fisher = new PlayerMobile(World.NewMobile); + + try + { + Core.Expansion = expansion; + BuiltInRng.Generator = new System.Random(1); + fisher.DefaultMobileInit(); + fisher.Skills.Fishing.Base = skill; + + var definition = Fishing.System.Definitions[0]; + + for (var i = 0; i < Casts; i++) + { + var type = Fishing.System.MutateType(typeof(Fish), fisher, null, definition, Map.Felucca, _shallow, null); + Assert.NotNull(type); + } + } + finally + { + fisher.Delete(); + BuiltInRng.Generator = previousGenerator; + Core.Expansion = previousExpansion; + } + } + + [SkippableTheory] + [InlineData(Expansion.T2A, 100.0, 0.005, 0.004975)] + [InlineData(Expansion.UOTD, 100.0, 0.00495, 0.004925)] + [InlineData(Expansion.LBR, 95.0, 0.0062, 0.0)] + [InlineData(Expansion.LBR, 100.0, 0.00825, 0.01309)] + [InlineData(Expansion.HS, 120.0, 0.016335, 0.0257)] + [InlineData(Expansion.EJ, 120.0, 0.016335, 0.0257)] + public void MutateType_DeepWaterMapsAndBottles_FollowTheEra( + Expansion expansion, double skill, double mapRate, double bottleRate + ) + { + TileDataRequirement.SkipIfMissing(); + Assert.True(SpecialFishingNet.FullValidation(Map.Felucca, _deep.X, _deep.Y)); + + var previousExpansion = Core.Expansion; + var previousGenerator = BuiltInRng.Generator; + var fisher = new PlayerMobile(World.NewMobile); + + try + { + Core.Expansion = expansion; + BuiltInRng.Generator = new System.Random(1); + fisher.DefaultMobileInit(); + fisher.Skills.Fishing.Base = skill; + + var definition = Fishing.System.Definitions[0]; + var maps = 0; + var bottles = 0; + + for (var i = 0; i < Casts; i++) + { + var type = Fishing.System.MutateType(typeof(Fish), fisher, null, definition, Map.Felucca, _deep, null); + + if (type == typeof(TreasureMap)) + { + maps++; + } + else if (type == typeof(MessageInABottle)) + { + bottles++; + } + } + + AssertRate(mapRate, maps); + AssertRate(bottleRate, bottles); + } + finally + { + fisher.Delete(); + BuiltInRng.Generator = previousGenerator; + Core.Expansion = previousExpansion; + } + } + + [SkippableTheory] + [InlineData(Expansion.T2A, true, 0.0, 100.0)] + [InlineData(Expansion.UOR, false, 0.0, 100.0)] + [InlineData(Expansion.UOR, true, 65.0, 115.0)] + [InlineData(Expansion.EJ, true, 65.0, 115.0)] + public void MutateResource_DeepWaterIsHarderFromUOR(Expansion expansion, bool deep, double minSkill, double maxSkill) + { + if (deep) + { + TileDataRequirement.SkipIfMissing(); + Assert.True(SpecialFishingNet.FullValidation(Map.Felucca, _deep.X, _deep.Y)); + } + + var previousExpansion = Core.Expansion; + var fisher = new PlayerMobile(World.NewMobile); + + try + { + Core.Expansion = expansion; + fisher.DefaultMobileInit(); + + var definition = Fishing.System.Definitions[0]; + var vein = definition.Veins[0]; + var resource = Fishing.System.MutateResource( + fisher, null, definition, Map.Felucca, deep ? _deep : _shallow, vein, vein.PrimaryResource, null + ); + + Assert.Equal(minSkill, resource.MinSkill); + Assert.Equal(maxSkill, resource.MaxSkill); + } + finally + { + fisher.Delete(); + Core.Expansion = previousExpansion; + } + } + + [Theory] + [InlineData(Expansion.T2A, 0.0)] + [InlineData(Expansion.UOTD, 0.0)] + [InlineData(Expansion.LBR, 0.1)] + [InlineData(Expansion.EJ, 0.1)] + public void SpecialFishingNet_IsColoredOneTimeInTenFromLBR(Expansion expansion, double coloredRate) + { + var previousExpansion = Core.Expansion; + var previousGenerator = BuiltInRng.Generator; + + try + { + Core.Expansion = expansion; + BuiltInRng.Generator = new System.Random(1); + + var colored = 0; + + for (var i = 0; i < Casts; i++) + { + var net = new SpecialFishingNet(); + + if (net.Hue != 0x8A0) + { + colored++; + } + + net.Delete(); + } + + AssertRate(coloredRate, colored); + } + finally + { + BuiltInRng.Generator = previousGenerator; + Core.Expansion = previousExpansion; + } + } + + [SkippableTheory] + [InlineData(1840, 1760, true)] // land 115 tiles away + [InlineData(1660, 1760, true)] // land 14 tiles away + [InlineData(1659, 1760, false)] // land 13 tiles away + [InlineData(2188, 1000, true)] // land 23 tiles away, on a diagonal + [InlineData(1718, 1913, false)] // land 8 tiles away, between the diagonals + [InlineData(13, 200, true)] // the edge of the map 14 tiles away + [InlineData(12, 200, false)] // the edge of the map 13 tiles away + public void FullValidation_NeedsLandAndTheMapEdgeAtLeast14TilesAway(int x, int y, bool deep) + { + TileDataRequirement.SkipIfMissing(); + + Assert.Equal(deep, SpecialFishingNet.FullValidation(Map.Felucca, x, y)); + } + + [SkippableFact] + public void SOS_FindLocation_PicksDeepWater() + { + TileDataRequirement.SkipIfMissing(); + + var previousGenerator = BuiltInRng.Generator; + + try + { + BuiltInRng.Generator = new System.Random(1); + + for (var i = 0; i < 100; i++) + { + var location = SOS.FindLocation(Map.Felucca); + + Assert.NotEqual(Point3D.Zero, location); + Assert.True(SpecialFishingNet.FullValidation(Map.Felucca, location.X, location.Y)); + } + } + finally + { + BuiltInRng.Generator = previousGenerator; + } + } + + [Fact] + public void Give_SurfacesASerpentThatAttacksAndIsADeepSeaSerpentOneTimeInTen() + { + const int serpents = 2_000; + + var previousGenerator = BuiltInRng.Generator; + var fisher = new PlayerMobile(); + + try + { + BuiltInRng.Generator = new System.Random(1); + fisher.MoveToWorld(_deep, Map.Felucca); + + var deepSeaSerpents = 0; + + for (var i = 0; i < serpents; i++) + { + var bottle = new MessageInABottle(Map.Felucca); + + Assert.True(Fishing.System.Give(fisher, bottle, false)); + + var serpent = Assert.IsAssignableFrom(bottle.RootParent); + Assert.Same(fisher, serpent.Combatant); + + if (serpent is DeepSeaSerpent) + { + deepSeaSerpents++; + } + + serpent.Delete(); + } + + AssertRate(0.1, deepSeaSerpents, serpents); + } + finally + { + fisher.Delete(); + BuiltInRng.Generator = previousGenerator; + } + } + + [Theory] + [InlineData(null, false)] + [InlineData(2.5, false)] + [InlineData(4.5, true)] + public void Give_SerpentGoesBackAfterFiveMinutesUnlessHitInTheLastTwo(double? hitAtMinutes, bool stays) + { + Core._tickCount = 0; + Timer.Init(0); + + var previousNow = Core._now; + var previousHitsRate = Mobile.DefaultHitsRate; + var fisher = new PlayerMobile(); + BaseCreature serpent = null; + + try + { + // The rate RegenRates sets before AOS, so the hit is not healed before the serpent's five minutes are up. + Mobile.DefaultHitsRate = TimeSpan.FromSeconds(11.0); + fisher.MoveToWorld(_deep, Map.Felucca); + + var bottle = new MessageInABottle(Map.Felucca); + Fishing.System.Give(fisher, bottle, false); + + serpent = Assert.IsAssignableFrom(bottle.RootParent); + serpent.AIObject?.AITimer.Stop(); + serpent.Combatant = null; + + var elapsed = TimeSpan.Zero; + + if (hitAtMinutes is { } minutes) + { + elapsed = TimeSpan.FromMinutes(minutes); + Advance(elapsed); + serpent.Damage(20, fisher); + Assert.Same(fisher, serpent.FindMostRecentDamager(false)); + } + + Advance(TimeSpan.FromMinutes(4.9) - elapsed); + Assert.False(serpent.Deleted); + + Advance(TimeSpan.FromMinutes(0.2)); + Assert.Equal(!stays, serpent.Deleted); + Assert.Equal(!stays, bottle.Deleted); + } + finally + { + serpent?.Delete(); + fisher.Delete(); + Mobile.DefaultHitsRate = previousHitsRate; + Core._now = previousNow; + } + } + + [SkippableTheory] + [InlineData(Expansion.None, 100.0, true, 0.0)] + [InlineData(Expansion.T2A, 79.9, true, 0.0)] + [InlineData(Expansion.T2A, 80.0, true, 0.01)] + [InlineData(Expansion.T2A, 100.0, false, 0.0)] + [InlineData(Expansion.EJ, 120.0, true, 0.01)] + public void OnHarvestFinished_SurfacesAnEmptySerpentOneCastInAHundred( + Expansion expansion, double skill, bool deep, double serpentRate + ) + { + TileDataRequirement.SkipIfMissing(); + Assert.True(SpecialFishingNet.FullValidation(Map.Felucca, _deep.X, _deep.Y)); + + var previousExpansion = Core.Expansion; + var previousGenerator = BuiltInRng.Generator; + var fisher = new PlayerMobile(); + + try + { + Core.Expansion = expansion; + BuiltInRng.Generator = new System.Random(1); + fisher.Skills.Fishing.Base = skill; + fisher.MoveToWorld(_deep, Map.Felucca); + + var definition = Fishing.System.Definitions[0]; + var target = new LandTarget(deep ? _deep : _shallow, Map.Felucca); + var surfaced = new List(); + var serpents = 0; + + for (var i = 0; i < Casts; i++) + { + Fishing.System.OnHarvestFinished(fisher, null, definition, null, null, null, target); + + foreach (var serpent in Map.Felucca.GetMobilesInRange(fisher.Location, 10)) + { + surfaced.Add(serpent); + } + + serpents += surfaced.Count; + + foreach (var serpent in surfaced) + { + serpent.Delete(); + } + + surfaced.Clear(); + } + + AssertRate(serpentRate, serpents); + } + finally + { + fisher.Delete(); + BuiltInRng.Generator = previousGenerator; + Core.Expansion = previousExpansion; + } + } + + [Theory] + [InlineData(Expansion.T2A, 0.0)] + [InlineData(Expansion.UOR, 65.0)] + [InlineData(Expansion.EJ, 120.0)] + public void Construct_BringsUpTheSOSChestOneCatchInFour(Expansion expansion, double skill) + { + const int casts = 2_000; + + var previousExpansion = Core.Expansion; + var previousGenerator = BuiltInRng.Generator; + var fisher = new PlayerMobile(); + + try + { + Core.Expansion = expansion; + BuiltInRng.Generator = new System.Random(1); + fisher.Skills.Fishing.Base = skill; + fisher.AddItem(new Backpack()); + fisher.MoveToWorld(_deep, Map.Felucca); + + var sos = AddSOS(fisher); + var chests = 0; + + for (var i = 0; i < casts; i++) + { + var item = Fishing.System.Construct(typeof(Fish), fisher); + + if (item is WoodenChest or MetalGoldenChest) + { + chests++; + Assert.True(sos.Deleted); + sos = AddSOS(fisher); + } + else + { + Assert.True(Assert.IsAssignableFrom(item).IsShipwreckedItem); + } + + item.Delete(); + } + + AssertRate(0.25, chests, casts); + } + finally + { + fisher.Delete(); + BuiltInRng.Generator = previousGenerator; + Core.Expansion = previousExpansion; + } + } + + [Theory] + [InlineData(Expansion.T2A, 0.0, true)] + [InlineData(Expansion.UOR, 64.9, false)] + [InlineData(Expansion.UOR, 65.0, true)] + [InlineData(Expansion.EJ, 64.9, false)] + public void SOS_Needs65SkillFromUOR(Expansion expansion, double skill, bool recovers) + { + var previousExpansion = Core.Expansion; + var definition = Fishing.System.Definitions[0]; + var fisher = new PlayerMobile(); + Item item = null; + + try + { + Core.Expansion = expansion; + fisher.Skills.Fishing.Base = skill; + fisher.AddItem(new Backpack()); + fisher.MoveToWorld(_deep, Map.Felucca); + AddSOS(fisher); + + // With no fish left, only an SOS that works lets the cast go on. + var bank = definition.GetBank(Map.Felucca, _deep.X, _deep.Y); + bank.Consume(bank.Current, fisher); + Assert.Equal(recovers, Fishing.System.CheckResources(fisher, null, definition, Map.Felucca, _deep, false)); + + item = Fishing.System.Construct(typeof(Fish), fisher); + Assert.Equal(recovers, item is not Fish); + } + finally + { + item?.Delete(); + fisher.Delete(); + definition.Banks.GetValueOrDefault(Map.Felucca) + ?.Remove(new Point2D(_deep.X / definition.BankWidth, _deep.Y / definition.BankHeight)); + Core.Expansion = previousExpansion; + } + } + + [Fact] + public void Construct_KeepsTheWhitePearlAtAnSOSSpot() + { + var previousExpansion = Core.Expansion; + var fisher = new PlayerMobile(); + + try + { + Core.Expansion = Expansion.ML; + fisher.Skills.Fishing.Base = 100.0; + fisher.AddItem(new Backpack()); + fisher.MoveToWorld(_deep, Map.Felucca); + + var sos = AddSOS(fisher); + + for (var i = 0; i < 100; i++) + { + var item = Fishing.System.Construct(typeof(WhitePearl), fisher); + Assert.IsType(item); + item.Delete(); + } + + Assert.False(sos.Deleted); + } + finally + { + fisher.Delete(); + Core.Expansion = previousExpansion; + } + } + + [Theory] + [InlineData(Expansion.None, false)] + [InlineData(Expansion.T2A, true)] + [InlineData(Expansion.AOS, true)] + [InlineData(Expansion.ML, true)] + public void OnHarvestStartedAndFinished_RevealAHiddenFishermanFromT2A(Expansion expansion, bool reveals) + { + Core._tickCount = 0; + Timer.Init(0); + + var previousExpansion = Core.Expansion; + var previousNow = Core._now; + var fisher = new PlayerMobile(); + + try + { + Core.Expansion = expansion; + fisher.MoveToWorld(_deep, Map.Felucca); + + var definition = Fishing.System.Definitions[0]; + var target = new LandTarget(_deep, Map.Felucca); + + fisher.Hidden = true; + Fishing.System.OnHarvestStarted(fisher, null, definition, target); + Advance(TimeSpan.FromSeconds(2.0)); + Assert.Equal(!reveals, fisher.Hidden); + + fisher.Hidden = true; + Fishing.System.OnHarvestFinished(fisher, null, definition, null, null, null, target); + Assert.Equal(!reveals, fisher.Hidden); + } + finally + { + fisher.Delete(); + Core._now = previousNow; + Core.Expansion = previousExpansion; + } + } + + [Theory] + [InlineData(Expansion.UOR, true)] + [InlineData(Expansion.AOS, true)] + [InlineData(Expansion.SE, false)] + public void BigFish_WeighsAHundredStonesBeforeSE(Expansion expansion, bool weighsAHundred) + { + var previousExpansion = Core.Expansion; + var previousGenerator = BuiltInRng.Generator; + var weights = new HashSet(); + + try + { + Core.Expansion = expansion; + BuiltInRng.Generator = new System.Random(1); + + for (var i = 0; i < 100; i++) + { + var fish = new BigFish(); + weights.Add((int)fish.Weight); + fish.Delete(); + } + } + finally + { + BuiltInRng.Generator = previousGenerator; + Core.Expansion = previousExpansion; + } + + Assert.All(weights, weight => Assert.InRange(weight, 3, 200)); + Assert.Equal(weighsAHundred, weights.SetEquals([100])); + } + + [Theory] + [InlineData(Expansion.AOS, 100, 25)] + [InlineData(Expansion.AOS, 12, 4)] + [InlineData(Expansion.SE, 100, 20)] + [InlineData(Expansion.SE, 200, 40)] + [InlineData(Expansion.SE, 12, 4)] + public void BigFish_GivesASteakPerFiveStonesFromSE(Expansion expansion, int weight, int steaks) + { + var previousExpansion = Core.Expansion; + var fisher = new PlayerMobile(); + + try + { + Core.Expansion = expansion; + fisher.AddItem(new Backpack()); + + var fish = new BigFish { Weight = weight }; + fisher.Backpack.DropItem(fish); + fish.Carve(fisher, null); + + Assert.True(fish.Deleted); + Assert.Equal(steaks, fisher.Backpack.FindItemByType()?.Amount); + } + finally + { + fisher.Delete(); + Core.Expansion = previousExpansion; + } + } + + [Theory] + [InlineData(Expansion.AOS, 100.0, 0.0)] + [InlineData(Expansion.SE, 99.9, 0.0)] + [InlineData(Expansion.SE, 100.0, 0.25)] + public void Leviathan_GivesAGMNetThrowerAnArtifactOneTimeInFourFromSE(Expansion expansion, double skill, double rate) + { + const int leviathans = 400; + + var previousExpansion = Core.Expansion; + var previousGenerator = BuiltInRng.Generator; + var fisher = new PlayerMobile(); + + try + { + Core.Expansion = expansion; + BuiltInRng.Generator = new System.Random(1); + fisher.Skills.Fishing.Base = skill; + fisher.AddItem(new Backpack()); + fisher.MoveToWorld(_deep, Map.Felucca); + + var pack = fisher.Backpack; + var artifacts = 0; + + // Nobody damages it, so only the net thrower's own roll can give an artifact. + for (var i = 0; i < leviathans; i++) + { + var leviathan = new Leviathan(fisher); + leviathan.MoveToWorld(_deep, Map.Felucca); + leviathan.Kill(); + + artifacts += pack.Items.Count; + + for (var j = pack.Items.Count - 1; j >= 0; j--) + { + pack.Items[j].Delete(); + } + + leviathan.Corpse?.Delete(); + leviathan.Delete(); + } + + AssertRate(rate, artifacts, leviathans); + } + finally + { + fisher.Delete(); + BuiltInRng.Generator = previousGenerator; + Core.Expansion = previousExpansion; + } + } + + [Theory] + [InlineData(Expansion.AOS, 5, 60)] + [InlineData(Expansion.SE, 1, 12)] + [InlineData(Expansion.EJ, 1, 12)] + public void MagicFish_GiveAPointFor12SecondsFromSE(Expansion expansion, int bonus, int seconds) + { + Core._tickCount = 0; + Timer.Init(0); + + var previousExpansion = Core.Expansion; + var previousNow = Core._now; + var fisher = new PlayerMobile(); + BaseMagicFish[] fish = [new PrizedFish(), new WondrousFish(), new TrulyRareFish()]; + + try + { + Core.Expansion = expansion; + + foreach (var magicFish in fish) + { + Assert.True(magicFish.Apply(fisher)); + Assert.Equal(bonus, fisher.GetStatOffset(magicFish.Type)); + } + + Advance(TimeSpan.FromSeconds(seconds - 1)); + Assert.All(fish, magicFish => Assert.Equal(bonus, fisher.GetStatOffset(magicFish.Type))); + + Advance(TimeSpan.FromSeconds(1)); + Assert.All(fish, magicFish => Assert.Equal(0, fisher.GetStatOffset(magicFish.Type))); + } + finally + { + foreach (var magicFish in fish) + { + magicFish.Delete(); + } + + fisher.Delete(); + Core._now = previousNow; + Core.Expansion = previousExpansion; + } + } + // Within five standard deviations of the expected rate; a rate of zero must never happen. private static void AssertRate(double expected, int count) { var tolerance = 5 * Math.Sqrt(expected * (1 - expected) / Casts); Assert.InRange((double)count / Casts, expected - tolerance, expected + tolerance); } + + private static void AssertRate(double expected, int count, int trials) + { + var tolerance = 5 * Math.Sqrt(expected * (1 - expected) / trials); + Assert.InRange((double)count / trials, expected - tolerance, expected + tolerance); + } + + private static void Advance(TimeSpan time) + { + Core._now += time; + Core._tickCount += (long)time.TotalMilliseconds; + Timer.Slice(Core.TickCount); + } + + // Map.Internal skips FindLocation's search; the wreck is then where the fisher stands. + private static SOS AddSOS(Mobile fisher) + { + var sos = new SOS(Map.Internal, 1) { TargetMap = fisher.Map, TargetLocation = fisher.Location }; + fisher.Backpack.DropItem(sos); + return sos; + } } diff --git a/Projects/UOContent/Engines/Harvest/Fishing.cs b/Projects/UOContent/Engines/Harvest/Fishing.cs index d3784ac3d..2eb04702d 100644 --- a/Projects/UOContent/Engines/Harvest/Fishing.cs +++ b/Projects/UOContent/Engines/Harvest/Fishing.cs @@ -11,18 +11,24 @@ namespace Server.Engines.Harvest private static Fishing _system; private static readonly Type[] _magicFish = - { + [ typeof(PrizedFish), typeof(WondrousFish), typeof(TrulyRareFish), typeof(PeculiarFish) - }; + ]; - private static readonly Type[] _footwear = { typeof(Boots), typeof(Shoes), typeof(Sandals), typeof(ThighBoots) }; + private static readonly Type[] _footwear = [typeof(Boots), typeof(Shoes), typeof(Sandals), typeof(ThighBoots)]; private static readonly MutateEntry[] _mutateTable = - { + [ new(80.0, 80.0, 4080.0, true, Expansion.UOR, typeof(SpecialFishingNet)), new(80.0, 80.0, 4080.0, true, Expansion.UOR, typeof(BigFish)), - new(90.0, 80.0, 4080.0, true, Expansion.T2A, typeof(TreasureMap)), - new(100.0, 80.0, 4080.0, true, Expansion.T2A, typeof(MessageInABottle)), + // T2A-UOTD: needs 90; 0.25% at 90, 0.5% at 100. + // LBR onward: needs 90; 0.42% at 90, 0.83% at 100. + new(90.0, 80.0, 4080.0, true, Expansion.T2A, Expansion.UOTD, typeof(TreasureMap)), + new(90.0, 80.0, 2480.0, true, Expansion.LBR, typeof(TreasureMap)), + // T2A-UOTD: needs 100; 0.5% at 100. + // LBR onward: needs 100; 1.33% at 100. + new(100.0, 80.0, 4080.0, true, Expansion.T2A, Expansion.UOTD, typeof(MessageInABottle)), + new(100.0, 80.0, 1580.0, true, Expansion.LBR, typeof(MessageInABottle)), // T2A-UOTD: needs 80; 2.0% at 80, 2.5% at 100. // LBR-SA: no minimum; 0% at 0, 5% at 100. // HS onward: needs 80; 4.0% at 80, 5.0% at 100. @@ -32,44 +38,49 @@ namespace Server.Engines.Harvest new(0.0, 103.5, -34.5, false, Expansion.T2A, Expansion.UOTD, _footwear), // From LBR the two footwear rows make one curve: 75% at 0 skill, 10% at 100 and 5% at 120. new(0.0, 100.0, -62.5, false, Expansion.LBR, _footwear), - new(0.0, 140.0, -260.0, false, Expansion.LBR, _footwear), - new(0.0, 200.0, -200.0, false, Expansion.None, new Type[] { null }) - }; + new(0.0, 140.0, -260.0, false, Expansion.LBR, _footwear) + ]; + + // From UO:R deep water needs 65 skill to catch anything, and a GM succeeds 70% of the time. + private static readonly HarvestResource _deepWaterFish = new(0.0, 65.0, 115.0, 1043297, typeof(Fish)); private static readonly int[] _waterLandTiles = - { + [ 0x00A8, 0x00AB, 0x0136, 0x0137 - }; + ]; private static readonly int[] waterStaticTiles = - { + [ 0x1797, 0x179C, 0x346E, 0x3485, 0x3490, 0x34AB, 0x34B5, 0x35D5 - }; + ]; - private static readonly int[] bodyParts = { + private static readonly int[] bodyParts = + [ 0x1CDD, 0x1CE5, // arm 0x1CE0, 0x1CE8, // torso 0x1CE1, 0x1CE9, // head 0x1CE2, 0x1CEC // leg - }; + ]; - private static readonly int[] boneParts = { + private static readonly int[] boneParts = + [ 0x1AE0, 0x1AE1, 0x1AE2, 0x1AE3, 0x1AE4, // skulls 0x1B09, 0x1B0A, 0x1B0B, 0x1B0C, 0x1B0D, 0x1B0E, 0x1B0F, 0x1B10, // bone piles 0x1B15, 0x1B16 // pelvis bones - }; + ]; - private static readonly int[] miscItems = { + private static readonly int[] miscItems = + [ 0x1EB5, // unfinished barrel 0xA2A, // stool 0xC1F, // broken clock 0x1047, 0x1048, // globe 0x1EB1, 0x1EB2, 0x1EB3, 0x1EB4 // barrel staves - }; + ]; private Fishing() { @@ -88,9 +99,9 @@ namespace Server.Engines.Harvest MaxRange = 4, ConsumedPerHarvest = 1, ConsumedPerFeluccaHarvest = 1, - EffectActions = new[] { 12 }, - EffectSounds = Array.Empty(), - EffectCounts = new[] { 1 }, + EffectActions = [12], + EffectSounds = [], + EffectCounts = [1], EffectDelay = TimeSpan.Zero, EffectSoundDelay = TimeSpan.FromSeconds(8.0), NoResourcesMessage = 503172, // The fish don't seem to be biting here. @@ -101,29 +112,23 @@ namespace Server.Engines.Harvest ToolBrokeMessage = 503174 // You broke your fishing pole. }; - HarvestResource[] res = - { - new(00.0, 00.0, 100.0, 1043297, typeof(Fish)) - }; + HarvestResource[] res = [new(00.0, 00.0, 100.0, 1043297, typeof(Fish))]; - HarvestVein[] veins = - { - new(1000, 0.0, res[0], null) - }; + HarvestVein[] veins = [new(1000, 0.0, res[0], null)]; fish.Resources = res; fish.Veins = veins; if (Core.ML) { - fish.BonusResources = new[] - { + fish.BonusResources = + [ new BonusHarvestResource(0, 99.4, null, null), // set to same chance as mining ml gems new BonusHarvestResource(80.0, .6, 1072597, typeof(WhitePearl)) - }; + ]; } - Definitions = new[] { fish }; + Definitions = [fish]; } public static Fishing System => _system ??= new Fishing(); @@ -165,6 +170,13 @@ namespace Server.Engines.Harvest return false; } + public override HarvestResource MutateResource( + Mobile from, Item tool, HarvestDefinition def, Map map, Point3D loc, + HarvestVein vein, HarvestResource primary, HarvestResource fallback + ) => Core.UOR && SpecialFishingNet.FullValidation(map, loc.X, loc.Y) + ? _deepWaterFish + : base.MutateResource(from, tool, def, map, loc, vein, primary, fallback); + public override Type MutateType( Type type, Mobile from, Item tool, HarvestDefinition def, Map map, Point3D loc, HarvestResource resource @@ -203,11 +215,14 @@ namespace Server.Engines.Harvest return type; } + // From UO:R the chest needs 65 skill, and below that an SOS changes nothing. + private static bool CanFishUpShipwreck(Mobile from) => !Core.UOR || from.Skills.Fishing.Base >= 65.0; + public override bool CheckResources(Mobile from, Item tool, HarvestDefinition def, Map map, Point3D loc, bool timed) { var pack = from.Backpack; - if (pack != null) + if (pack != null && CanFishUpShipwreck(from)) { foreach (var sos in pack.FindItemsByType()) { @@ -244,91 +259,67 @@ namespace Server.Engines.Harvest return new MessageInABottle(from.Map == Map.Felucca ? Map.Felucca : Map.Trammel); } + // The pearl is a bonus on top of the catch, so an SOS never takes its place. + if (type == typeof(WhitePearl)) + { + return base.Construct(type, from); + } + var pack = from.Backpack; - if (pack != null) + if (pack != null && CanFishUpShipwreck(from)) { // We don't have to queue since we are returning on the first SOS. foreach (var sos in pack.FindItemsByType()) { - if (from.Map == sos.TargetMap && from.InRange(sos.TargetLocation, 60)) + if (from.Map != sos.TargetMap || !from.InRange(sos.TargetLocation, 60)) { - Item preLoot = null; - - switch (Utility.Random(8)) - { - case 0: // Body parts - { - preLoot = new ShipwreckedItem(bodyParts.RandomElement()); - break; - } - case 1: // Bone parts - { - preLoot = new ShipwreckedItem(boneParts.RandomElement()); - break; - } - case 2: // Paintings and portraits - { - preLoot = new ShipwreckedItem(Utility.Random(0xE9F, 10)); - break; - } - case 3: // Pillows - { - preLoot = new ShipwreckedItem(Utility.Random(0x13A4, 11)); - break; - } - case 4: // Shells - { - preLoot = new ShipwreckedItem(Utility.Random(0xFC4, 9)); - break; - } - case 5: // Hats - { - preLoot = Utility.RandomBool() ? new SkullCap() : new TricorneHat(); - break; - } - case 6: // Misc - { - var rand = Utility.Random(miscItems.Length + 1); - - preLoot = rand == miscItems.Length - ? new Candelabra() - : new ShipwreckedItem(miscItems[rand]); - - break; - } - } - - if (preLoot != null) - { - ((IShipwreckedItem)preLoot).IsShipwreckedItem = true; - return preLoot; - } - - LockableContainer chest = Utility.RandomBool() ? new MetalGoldenChest() : new WoodenChest(); - - if (sos.IsAncient && Core.SE) - { - chest.Hue = 0x481; - } - - TreasureMapChest.Fill(chest, Math.Max(1, Math.Min(4, sos.Level))); - - if (Core.UOR) - { - chest.DropItem(sos.IsAncient ? new FabledFishingNet() : new SpecialFishingNet()); - } - - chest.Movable = true; - chest.Locked = false; - chest.TrapType = TrapType.None; - chest.TrapPower = 0; - chest.TrapLevel = 0; - - sos.Delete(); - - return chest; + continue; } + + if (Utility.Random(4) != 0) + { + return Utility.Random(7) switch + { + 0 => new ShipwreckedItem(bodyParts.RandomElement()), // Body Parts + 1 => new ShipwreckedItem(boneParts.RandomElement()), // Bone Parts + 2 => new ShipwreckedItem(Utility.Random(0xE9F, 10)), // Paintings and portraits + 3 => new ShipwreckedItem(Utility.Random(0x13A4, 11)), // Pillows + 4 => new ShipwreckedItem(Utility.Random(0xFC4, 9)), // Shells + 5 => Utility.RandomBool() // Hats + ? new SkullCap { IsShipwreckedItem = true } + : new TricorneHat { IsShipwreckedItem = true }, + 6 => Utility.Random(miscItems.Length + 1) switch // Misc + { + var r when r < miscItems.Length => new ShipwreckedItem(miscItems[r]), + _ => new Candelabra { IsShipwreckedItem = true }, + } + }; + } + + LockableContainer chest = Utility.RandomBool() ? new MetalGoldenChest() : new WoodenChest(); + + if (sos.IsAncient && Core.SE) + { + chest.Hue = 0x481; + } + + TreasureMapChest.Fill(chest, Math.Max(1, Math.Min(4, sos.Level))); + + if (Core.UOR) + { + chest.DropItem(sos.IsAncient ? new FabledFishingNet() : new SpecialFishingNet()); + } + + chest.Movable = true; + chest.Locked = false; + chest.TrapType = TrapType.None; + chest.TrapPower = 0; + chest.TrapLevel = 0; + + sos.Delete(); + + return chest; } } @@ -339,45 +330,7 @@ namespace Server.Engines.Harvest { if (item is TreasureMap or MessageInABottle or SpecialFishingNet) { - BaseCreature serp; - - if (Utility.RandomDouble() < 0.25) - { - serp = new DeepSeaSerpent(); - } - else - { - serp = new SeaSerpent(); - } - - int x = m.X, y = m.Y; - - var map = m.Map; - - for (var i = 0; map != null && i < 20; ++i) - { - var tx = m.X - 10 + Utility.Random(21); - var ty = m.Y - 10 + Utility.Random(21); - - var t = map.Tiles.GetLandTile(tx, ty); - - if (t.Z == -5 && (t.ID >= 0xA8 && t.ID <= 0xAB || t.ID >= 0x136 && t.ID <= 0x137) && - !SpellHelper.CheckMulti(new Point3D(tx, ty, -5), map)) - { - x = tx; - y = ty; - break; - } - } - - serp.MoveToWorld(new Point3D(x, y, -5), map); - - serp.Home = serp.Location; - serp.RangeHome = 10; - - serp.PackItem(item); - - m.SendLocalizedMessage(503170); // Uh oh! That doesn't look like a fish! + SurfaceSerpent(m).PackItem(item); return true; // we don't want to give the item to the player, it's on the serpent } @@ -385,6 +338,52 @@ namespace Server.Engines.Harvest return base.Give(m, item, placeAtFeet || item is BigFish or WoodenChest or MetalGoldenChest); } + private static BaseCreature SurfaceSerpent(Mobile m) + { + BaseCreature serp = Utility.RandomDouble() < 0.1 ? new DeepSeaSerpent() : new SeaSerpent(); + + int x = m.X, y = m.Y; + + var map = m.Map; + + for (var i = 0; map != null && i < 20; ++i) + { + var tx = m.X - 10 + Utility.Random(21); + var ty = m.Y - 10 + Utility.Random(21); + + var t = map.Tiles.GetLandTile(tx, ty); + + if (t.Z == -5 && (t.ID >= 0xA8 && t.ID <= 0xAB || t.ID >= 0x136 && t.ID <= 0x137) && + !SpellHelper.CheckMulti(new Point3D(tx, ty, -5), map)) + { + x = tx; + y = ty; + break; + } + } + + serp.MoveToWorld(new Point3D(x, y, -5), map); + + serp.Home = serp.Location; + serp.RangeHome = 10; + serp.Combatant = m; + + Timer.StartTimer(TimeSpan.FromMinutes(5.0), () => ReturnToDeepWater(serp)); + + m.SendLocalizedMessage(503170); // Uh oh! That doesn't look like a fish! + + return serp; + } + + // Damage entries last 2 minutes and clear at full health, so a serpent nobody is fighting goes back to deep water. + private static void ReturnToDeepWater(BaseCreature serpent) + { + if (!serpent.Deleted && serpent.FindMostRecentDamager(false) == null) + { + serpent.Delete(); + } + } + public override void SendSuccessTo(Mobile from, Item item, HarvestResource resource) { if (item is BigFish fish) @@ -406,12 +405,7 @@ namespace Server.Engines.Harvest number = 1008124; name = "a mess of small fish"; } - else if (item is Fish) - { - number = 1008124; - name = item.ItemData.Name; - } - else if (item is BaseShoes) + else if (item is Fish or BaseShoes) { number = 1008124; name = item.ItemData.Name; @@ -472,7 +466,7 @@ namespace Server.Engines.Harvest TimeSpan.FromSeconds(1.5), () => { - if (Core.ML) + if (Core.T2A) { from.RevealingAction(); } @@ -491,10 +485,18 @@ namespace Server.Engines.Harvest { base.OnHarvestFinished(from, tool, def, vein, bank, resource, harvested); - if (Core.ML) + if (Core.T2A) { from.RevealingAction(); } + + // From T2A, 1% of casts in deep water at 80 skill or more bring up an empty sea serpent, whatever the catch. + if (Core.T2A && from.Skills.Fishing.Base >= 80.0 && Utility.RandomDouble() < 0.01 && + GetHarvestDetails(from, tool, harvested, out _, out var map, out var loc, out _) && + SpecialFishingNet.FullValidation(map, loc.X, loc.Y)) + { + SurfaceSerpent(from); + } } public override object GetLock(Mobile from, Item tool, HarvestDefinition def, object toHarvest) => this; diff --git a/Projects/UOContent/Items/Resources/Fishing/BigFish.cs b/Projects/UOContent/Items/Resources/Fishing/BigFish.cs index b19e17290..3e58f67f8 100644 --- a/Projects/UOContent/Items/Resources/Fishing/BigFish.cs +++ b/Projects/UOContent/Items/Resources/Fishing/BigFish.cs @@ -15,7 +15,7 @@ public partial class BigFish : Item, ICarvable public BigFish() : base(0x09CC) { // TODO: Find correct formula. max on OSI currently 200, OSI dev says it's not 200 as max, and ~ 1/1,000,000 chance to get highest - Weight = Utility.RandomMinMax(3, 200); + Weight = Core.SE ? Utility.RandomMinMax(3, 200) : 100; Hue = Utility.RandomBool() ? 0x847 : 0x58C; } @@ -23,7 +23,8 @@ public partial class BigFish : Item, ICarvable public void Carve(Mobile from, Item item) { - ScissorHelper(from, new RawFishSteak(), Math.Max(16, (int)Weight) / 4, false); + var steaks = Core.SE ? Math.Max(4, (int)Weight / 5) : Math.Max(16, (int)Weight) / 4; + ScissorHelper(from, new RawFishSteak(), steaks, false); } public override void GetProperties(IPropertyList list) diff --git a/Projects/UOContent/Items/Resources/Fishing/MagicFish.cs b/Projects/UOContent/Items/Resources/Fishing/MagicFish.cs index e4dea06dc..184473e50 100644 --- a/Projects/UOContent/Items/Resources/Fishing/MagicFish.cs +++ b/Projects/UOContent/Items/Resources/Fishing/MagicFish.cs @@ -16,7 +16,8 @@ public abstract partial class BaseMagicFish : Item public virtual bool Apply(Mobile from) { - var applied = SpellHelper.AddStatOffset(from, Type, Bonus, TimeSpan.FromMinutes(1.0)); + var duration = Core.SE ? TimeSpan.FromSeconds(12.0) : TimeSpan.FromMinutes(1.0); + var applied = SpellHelper.AddStatOffset(from, Type, Bonus, duration); if (!applied) { @@ -50,7 +51,7 @@ public partial class PrizedFish : BaseMagicFish { } - public override int Bonus => 5; + public override int Bonus => Core.SE ? 1 : 5; public override StatType Type => StatType.Int; public override int LabelNumber => 1041073; // prized fish @@ -64,7 +65,7 @@ public partial class WondrousFish : BaseMagicFish { } - public override int Bonus => 5; + public override int Bonus => Core.SE ? 1 : 5; public override StatType Type => StatType.Dex; public override int LabelNumber => 1041074; // wondrous fish @@ -78,7 +79,7 @@ public partial class TrulyRareFish : BaseMagicFish { } - public override int Bonus => 5; + public override int Bonus => Core.SE ? 1 : 5; public override StatType Type => StatType.Str; public override int LabelNumber => 1041075; // truly rare fish diff --git a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs index 5374f8716..374fb21cd 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs @@ -12,12 +12,6 @@ public partial class SOS : Item // By default let's use the old gump, because we can. private static bool UseNewMessages = false; - private static int[] WaterTiles = - { - 0x00A8, 0x00AB, - 0x0136, 0x0137 - }; - private static Rectangle2D[] BritRegions = { new(0, 0, 5120, 4096) }; private static Rectangle2D[] IlshRegions = @@ -179,34 +173,7 @@ public partial class SOS : Item var x = Utility.Random(reg.X, reg.Width); var y = Utility.Random(reg.Y, reg.Height); - if (!ValidateDeepWater(map, x, y)) - { - continue; - } - - var valid = true; - - for (int j = 1, offset = 5; valid && j <= 5; ++j, offset += 5) - { - if (!ValidateDeepWater(map, x + offset, y + offset)) - { - valid = false; - } - else if (!ValidateDeepWater(map, x + offset, y - offset)) - { - valid = false; - } - else if (!ValidateDeepWater(map, x - offset, y + offset)) - { - valid = false; - } - else if (!ValidateDeepWater(map, x - offset, y - offset)) - { - valid = false; - } - } - - if (valid) + if (SpecialFishingNet.FullValidation(map, x, y)) { return new Point3D(x, y, 0); } @@ -215,19 +182,6 @@ public partial class SOS : Item return Point3D.Zero; } - private static bool ValidateDeepWater(Map map, int x, int y) - { - var tileID = map.Tiles.GetLandTile(x, y).ID; - var water = false; - - for (var i = 0; !water && i < WaterTiles.Length; i += 2) - { - water = tileID >= WaterTiles[i] && tileID <= WaterTiles[i + 1]; - } - - return water; - } - private class MessageGump : StaticGump { private readonly string _fmt; diff --git a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SpecialFishingNet.cs b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SpecialFishingNet.cs index 7017af81f..dc7eef960 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SpecialFishingNet.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SpecialFishingNet.cs @@ -10,8 +10,8 @@ namespace Server.Items; [SerializationGenerator(0, false)] public partial class SpecialFishingNet : Item { - private static int[] _hues = - { + private static readonly int[] _hues = + [ 0x09B, 0x0CD, 0x0D3, @@ -26,25 +26,19 @@ public partial class SpecialFishingNet : Item 0x492, 0x4B5, 0x8AA - }; + ]; - private static int[] _waterTiles = - { - 0x00A8, 0x00AB, - 0x0136, 0x0137 - }; + private static readonly int[] _waterTiles = [0x00A8, 0x00AB, 0x0136, 0x0137 ]; - private static int[] _shallowWaterTiles = - { - 0x1797, 0x179C - }; + private static readonly int[] _shallowWaterTiles = [0x1797, 0x179C]; [SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _inUse; [Constructible] - public SpecialFishingNet() : base(0x0DCA) => Hue = Utility.RandomDouble() < 0.01 ? _hues.RandomElement() : 0x8A0; + public SpecialFishingNet() : base(0x0DCA) => + Hue = Core.LBR && Utility.RandomDouble() < 0.1 ? _hues.RandomElement() : 0x8A0; public override double DefaultWeight => 1.0; @@ -322,31 +316,15 @@ public partial class SpecialFishingNet : Item public static bool FullValidation(Map map, int x, int y) { - if (!ValidateDeepWater(map, x, y)) + // Every tile closer than 14 must be water, so land and the edge of the map are at least 14 tiles away. + for (var ty = y - 13; ty <= y + 13; ++ty) { - return false; - } - - for (int j = 1, offset = 5; j <= 5; ++j, offset += 5) - { - if (!ValidateDeepWater(map, x + offset, y + offset)) + for (var tx = x - 13; tx <= x + 13; ++tx) { - return false; - } - - if (!ValidateDeepWater(map, x + offset, y - offset)) - { - return false; - } - - if (!ValidateDeepWater(map, x - offset, y + offset)) - { - return false; - } - - if (!ValidateDeepWater(map, x - offset, y - offset)) - { - return false; + if (!ValidateDeepWater(map, tx, ty)) + { + return false; + } } } diff --git a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs index e4adbf05e..b0cfaf697 100644 --- a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs +++ b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs @@ -149,7 +149,7 @@ namespace Server.Mobiles { base.OnDeath(c); - if (Fisher != null && Utility.Random(100) < 25) + if (Core.SE && Fisher?.Skills.Fishing.Base >= 100.0 && Utility.Random(100) < 25) { GiveArtifactTo(Fisher); }