From b2c9b18ca1197bd6f3cbe87de6d8ed9640c6c1b5 Mon Sep 17 00:00:00 2001 From: Sergi Rosell <50594106+srosellj@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:34:10 +0200 Subject: [PATCH] fix: fishing by era: deep water, serpents, SOS, big fish and magic fish (#2718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #2687, after #2709. One commit per section, in the same order. ### Failures, maps, bottles and colored nets A GM's cast fails about a quarter of the time in any water, from the fishing table's last row, and deep water is no harder than a river. Maps and bottles are 0.5% per roll from T2A on, and 1 net in 100 is colored in every era. This removes the last row, so a cast only fails its skill check. From UO:R a cast in deep water checks skill from 65 to 115, through a `Fishing.MutateResource` override. Publish 5, which "was known as the Renaissance Publish", made deep water harder: "because the difficulty level is higher, the rate of success for pulling up SoS bottles will be lower." [O3] From LBR maps and bottles come up more often and 1 net in 10 is colored, both from Publish 16, which came out in parts during LBR [O14]. | Change | Era | Source | |---|---|---| | No "nothing" row | All | #2687 found no source for it; "A prepatch, you never failed catching something at GM" [S15]; a GM succeeds every time in lakes and rivers [C1] | | Deep water: nothing below 65 skill | UO:R on | "The minimum required skill level to catch *anything* in deep water is 65." [S5–S7] | | Deep water: 10% at 70, 70% at GM | UO:R on | "Even at 70, it is only about a 10% chance of success in deep water." [S14]; 29% of casts fail before Publish 16 and 30% after [F1, F2]; 70% success at 100 offshore [C1] | | Maps (skill − 80) / 2400, bottles (skill − 80) / 1500 | LBR on | A bottle "about once per hour" until October 2002 [S1–S6], then "a special item (a net, a message in a bottle or a treasure map) about five times per hour" [S7]; bottles went from 0.4% of catches before Publish 16 [F1] to 1.7% after [F2] | | Colored nets: none, then 10% | LBR on | "With Publish 16, we got a nice surprise--colored special fish nets!" [F4]; "Any net created has a 10% chance of being colored." [F3] | - From LBR a GM's deep-water catches are 0.8% maps and 1.3% bottles. Chrome counted 0.9% and 1.3% [C1], the FCB 0.7% and 1.7% after Publish 16 [F2]. Nets and big fish stay at 0.5%, close to both counts. - At Stratics' 280 casts an hour [S1–S6], that is about one bottle an hour from UO:R to UOTD and five nets, maps or bottles an hour from LBR, the two rates Stratics gives. - The gain chance rises as the success chance falls, so above 65 a cast in deep water gains faster than one near land, Publish 5's "best method for gaining at high levels" [O3]. Below 65 it neither catches nor gains. - Chrome's table also has three kinds of water near land, with difficulties of 15, 35 and 55. No other source has them, so near land stays at 0–100. ### Deep water is at least 14 tiles from land `SpecialFishingNet.FullValidation` decides what counts as deep water: where a special fishing net works, where maps, bottles, nets and big fish can be caught, and from UO:R where a cast is harder. It checks the target and 20 points on its four diagonals, 5 to 25 tiles out. Nothing off those points is read, so a spot 8 tiles from shore can count as deep, while one with land 15 to 25 tiles away on a diagonal does not. The sources give one rule in every era: land at least 14 tiles away. This checks every land tile within 13 of the target. ModernUO has no server lines inside a map, so the only boundary is the map's edge, which counts as land as before. Only land tiles are read, so statics such as rocks are ignored. `SOS.FindLocation` had its own copy of the old check and now calls `FullValidation`, so wrecks follow the same rule. - 1999 to 2000: "which means you must be at least 14 tiles away from all land" [S1–S4] - March 2001: "14 tiles away from any land mass" [F5] - 2001 to 2002: "which means you must be at least 14 tiles away from all land and server lines" [S5–S7] - Today: "You need to be a minimum of 14 tiles from land or a server boundary to be in ‘deep water’." [O9] One guide from January 2000 says "16 tiles away from land" [S9]; every other source says 14. - In the area an SOS picks from on Felucca, 59% of spots pass the old check and 63% pass the new one; in Ilshenar's two sea regions, 16% and 30%. - The check reads up to 729 land tiles instead of 21: about 6 µs per check in a Release build, against under 0.1 µs before; a cast makes at most two. ### Sea serpents In ModernUO a sea serpent comes up from fishing only with a treasure map, a bottle or a net, which it carries. A quarter are deep sea serpents. It has no timer, and nothing makes it attack the fisherman first. This follows the notes of March 28, 1999 and Publish 4. A fished serpent attacks the fisherman at once. Five minutes after it surfaced it goes back to deep water, taking what it carries, unless someone is still fighting it: it took damage in the last two minutes and has not healed to full since, which is what ModernUO's damage entries record. From T2A, at 80 skill or more in deep water, 1% of casts also bring up a serpent that carries nothing, whether the cast catches something or not. 1 serpent in 10 is a deep sea serpent. | Change | Era | Source | |---|---|---| | Attacks the fisherman at once | All | "Sea Serpents will now immediately attack the fisherman that brought them from the deep." [O1] | | Goes back after 5 minutes unless fought | All | "Sea Serpents will return to deep water after 5 minutes if not attacked." [O1]; "will return to the depths after a brief period if they are not engaged" [S10] | | 1% of casts bring up an empty serpent | T2A on | "Fishermen at over 80 skill will have a chance to pull up a sea serpent regardless of whether they succeed or fail at fishing when in deep sea." [O2]; 1% at 80 or more, before the skill check [B1] | | 1 in 10 is a deep sea serpent | All | "A small percentage of Sea Serpents will be Deep-Sea Serpents." [O1]; about 10% [B1]; 9.8% and 10.8% of the serpents counted [F1, F2] | - Publish 4 gives no rate. The 1% comes from a post by Hance, a UO developer, in December 2000 [B1]; T2A uses it too, since it is the rule Publish 4 brought in (our inference). The FCB's counts agree: 1.76% of casts brought up a serpent and 55% of serpents carried nothing, so about 1% of casts brought up an empty one [F1] (our calculation). - Before Publish 4 serpents came up "on failure" [O1], and Stratics kept "if you are fishing with 80+ skill and you fail" from July 1999 to 2002 [S1–S7]. Publish 4 is the last rule in T2A, so it is the one used from T2A. - Hance also relays a side comment that the empty serpent cancels the rest of the cast [B1]. If it did, no cast could bring up two serpents, but the FCB saw two from one cast on 0.96% of serpents [F1], and Chrome caught empty serpents together with fish and footwear [C1]. So the cast goes on. - Hance's post also needs room in the backpack for a fish and fish left in the spot [B1]. A spot with no fish left already ends the cast before the roll, unless an SOS keeps the cast going (next section). A full backpack doesn't: ModernUO doesn't check for room before a cast, so neither does the serpent. - Serpents from a special fishing net are unchanged; Hance's post says nets don't follow its formula [B1]. ### The SOS chest In ModernUO, a fisherman carrying an SOS within 60 tiles of its spot brings up the chest on 1 catch in 8 and a shipwrecked item on the other 7. The SOS also lets a cast go on where the fish have run out. This makes the chest 1 catch in 4, in every era. From UO:R an SOS needs 65 skill, and below that the cast is an ordinary one. | Change | Era | Source | |---|---|---| | The chest on 1 catch in 4 | All | "You have a 25% chance of getting the chest and a 75% chance of getting something else with each cast." [F3]; "a 25% chance to fish up the actual treasure chest" [S7] | | An SOS needs 65 skill | UO:R on | 65 in the chest's row of Stratics' table: "The skills mentioned above are minimum skills needed to fish up the corresponding item." [S5–S7] | - No source gives a number before October 2002, or any for ModernUO's 1 in 8, so the 25% applies in every era. The other catches keep ModernUO's seven kinds of shipwrecked items, equally likely. Hanse's die splits them in three: "One roll is body parts; another roll is shells, barrel staves, globes, etc.; another roll is paintings, broken clocks, etc.; and another is the chest itself." [F3] - Stratics' guide of November 2000 has neither the chest's row nor the 65 for deep water [S4]. The May 2001 version, during Third Dawn, has both: "The minimum required skill level to catch *anything* in deep water is 65." [S5] The first section applies that from UO:R, after Publish 5 made deep water harder [O3], so the chest follows it (our inference). An SOS's spot is picked in deep water, but a cast within 60 tiles of it can be nearer land, so the 65 is checked on its own. - Below 65 there are no shipwrecked items either, because Hanse's die is rolled once you "can even attempt to get a treasure chest" [F3] (our reading). Before UO:R an SOS still works at any skill; the only period line on it is "You do not have to be GM Fisherman to actually fish up the treasure from a bottle, only to fish up A bottle." [S9] ### Fishing reveals from T2A ModernUO reveals a hidden fisherman from ML. The notes of March 28, 1999 already say "If you are hidden, fishing will now unhide you." [O1], so this reveals from T2A, at the same two points: the splash and the end of the cast. ### Big fish and the leviathan's artifact In ModernUO every big fish weighs 3 to 200 stones and gives max(16, weight) / 4 steaks, and the net thrower has a 1 in 4 chance at an artifact at any skill. | Change | Era | Source | |---|---|---| | Big fish weigh 100 stones | Before SE | "They weigh 100 stones each" [S5–S7]; Publish 28 "Added variable weight and fisherman's mark on big fish" [O6] | | A steak per 5 stones, at least 4 | SE on | "now its weight / 5 will determine number of fish steaks (minimum of 4)" [O7] | | The net thrower's own chance at an artifact: 1 in 4, only at GM | SE on | "Give the fabled net thrower (if a GM Fisherman) a 1 in 4 chance of getting a minor artifact (not modified by luck or damage)." [O6] | - Before SE a big fish keeps ModernUO's weight / 4 steaks, 25 for 100 stones: no source gives a number before Publish 29. - Publish 27 names only the attackers' chance: "Minor Artifacts will have a chance of spawning in each attacker's backpack after a paragon creature or a leviathan is slain." [O5] Publish 28 adds the thrower's roll [O6], so before SE there is none (our inference). The attackers' chance is unchanged. ### Magic fish From SE the prized, wondrous and truly rare fish give +1 for 12 seconds instead of +5 for a minute [C1]. A Stratics FAQ of January 5, 2005 already says "The stat boost fish used to provide the person who ate it a +5 boost, but now it's only one or two points, and lasts only a short while." [S16] - Stratics still gives +5 in December 2002 [S7] and no note dates the change. [S16], during SE, is the earliest source with the new values, so the eras before SE keep +5 and ModernUO's minute (our inference). - Today's guide gives "Adds 8 – 12% to your Dexterity for 150 seconds when eaten" [O9]. No note dates that change either, so it is left out. ### The white pearl at an SOS's spot From ML the white pearl is a bonus on top of the catch, but it went through `Fishing.Construct`'s SOS branch: at an SOS's spot the fisherman read "You have found a white pearl!" and got a shipwrecked item or the chest, a second SOS roll in the same cast. The pearl now skips that branch. The keys are #2687's, plus: - [O14] uo.com, the 2002 publishes: Publish 16 in parts on April 23, July 23, September 20 and December 2, 2002. https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2002-2/ - [S14] Stratics fishing board, FAQ 1.2 "How do I train fishing?", January 5, 2005. https://web.archive.org/web/20050410171430/http://boards.stratics.com/php-bin/uo/showthreaded.php?Cat=&Board=uofisherman&Number=5622892 - [S15] Stratics fishing board, a post on Publish 5's SOS change, April 25, 2000. https://web.archive.org/web/20000511000424/http://boards.stratics.com:80/cgi-bin/forums/showflat.pl?Cat=21&Board=uofisherman&Number=144300&page=0&view=collapsed&sb=5 - [S16] The FAQ of [S14], 1.4 "What's with the horned leather and scales? (Resources)", January 5, 2005, in the FAQ's flat view. https://web.archive.org/web/20050410171356/http://boards.stratics.com/php-bin/uo/showflat.php?Cat=&Number=5622877&page=0&view=collapsed&sb=5&o=&fpart=1 - [F1] The Fishin' Statistician, 11,101 casts from August 2001 to April 2002, before Publish 16. Skill and water not stated; most likely GM and mostly deep water, since the counts include big fish and serpents. https://web.archive.org/web/20020609120859/http://www.thefcb.com:80/novellettes/stats.html - [F2] The same page, 7,371 casts from July 2002 to February 2003, after Publish 16, with nets, maps and bottles counted too. https://web.archive.org/web/20030216074120/http://www.thefcb.com:80/novellettes/stats.html - [F3] The FCB's interview with Hanse, an OSI designer, October 21, 2002. https://web.archive.org/web/20021104194424/http://www.thefcb.com:80/Hanse.htm - [F4] The FCB's encyclopaedia, colored nets, last modified July 13, 2002. https://web.archive.org/web/20020715111230/http://www.thefcb.com:80/encyclopaedia/colornets.html - [F5] The FCB's "Illustrated Treatise on Deep Water Defined", by Captain Pickles and Mason, last modified March 20, 2001. https://web.archive.org/web/20010419135823/http://www.thefcb.com:80/novellettes/deep-water.html - [B1] Brikan's skill guide, which quotes a post on Origin's boards by Hance, a UO developer, on December 20, 2000; most likely Hanse, the OSI designer the FCB interviewed in 2002. Page last modified February 3, 2007. https://web.archive.org/web/20070715201811/http://www.jgnn.com:80/uo/bk/2skill/skill.htm - [C1] Chrome's fishing page on OSI's Yamato shard, captured November 25, 2007: 5,000 offshore catches at GM, a table of success by water and what each magic fish does. https://web.archive.org/web/20071125144104/http://www.geocities.co.jp:80/Playtown-Yoyo/4812/fishing02.html --- .../Tests/Engines/Harvest/FishingTests.cs | 698 ++++++++++++++++++ Projects/UOContent/Engines/Harvest/Fishing.cs | 320 ++++---- .../Items/Resources/Fishing/BigFish.cs | 5 +- .../Items/Resources/Fishing/MagicFish.cs | 9 +- .../Items/Skill Items/Fishing/Misc/SOS.cs | 48 +- .../Fishing/Misc/SpecialFishingNet.cs | 50 +- .../Monsters/Reptile/Magic/Leviathan.cs | 2 +- 7 files changed, 883 insertions(+), 249 deletions(-) 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); }