From f4ba6036fe89a40d91f72c014b561c51cfb5396d Mon Sep 17 00:00:00 2001 From: Sergi Rosell <50594106+srosellj@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:41:58 +0200 Subject: [PATCH] fix: fishing catch chances by era and the leviathan's treasure map (#2709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #2687. Magic fish and footwear roll the same in every era: magic fish get rarer with skill (5% at 0 skill, 1% at GM) and footwear has a 20% chance at 0 skill. The leviathan rolls the generic 1% for its level 5 map. A fishing table row can now end at an expansion, so each era gets its own magic fish and footwear rows. The chances at GM come from the Fishing Council of Britannia's cast counts before and after Publish 16, which came out in parts from April to December 2002, during LBR [O14]. The leviathan gets its 25% through a `BaseCreature.TreasureMapChance` override; leviathans only come from the fabled net, which already needs AOS. | Change | Era | Source | |---|---|---| | Footwear: 75% at 0 skill, 2.5% at GM | T2A to UOTD | "The beginning fisherman will find this item on his hook most of the times" [S1–S7]; 2.5% of catches [F1] | | Footwear: 75% at 0 skill, 10% at GM | LBR on | The same quote; 10.5% of catches [F2] | | Magic fish: 80 skill, 2.5% at GM | T2A to UOTD | 80 from July 1999 to October 2002 [S1–S6, S9]; 2.4% of catches [F1] | | Magic fish: no minimum, 5% at GM | LBR to SA | 0 from December 2002 [S7], so the minimum went in late 2002, during LBR; 5.6% of catches [F2] | | Magic fish: 80 skill again | HS on | Still 0 in June 2010, during SA [S13]; 80 today [O9]. HS is the first expansion after that | | Leviathan's map: 25% | AOS | Publish 27: "It also has a 25% chance to carry a level 5 treasure map." [O5] | - These are chances per roll. The FCB's counts leave out empty casts, so they line up once the "nothing" row #2687 lists is gone (a follow-up PR removes it); while it stays, footwear is 3.2% of a GM's catches before LBR and 12% from LBR. - No source counts footwear below GM, so 75% at 0 skill only follows Stratics' "most of the times". The numbers in my comment were placeholders. - 80 again from HS is the one change beyond my comment, which proposed no minimum from LBR on. 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/ - [F1] The Fishin' Statistician, 11,101 casts from August 2001 to April 2002, before Publish 16; skill not stated, most likely GM. 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; skill not stated either. https://web.archive.org/web/20030216074120/http://www.thefcb.com:80/novellettes/stats.html - [S13] Stratics' fishing guide, captured June 5, 2010. https://web.archive.org/web/20100605065528/http://uo.stratics.com:80/content/skills/fishing.shtml --- .../Tests/Engines/Harvest/FishingTests.cs | 108 ++++++++++++++++++ Projects/UOContent/Engines/Harvest/Fishing.cs | 40 ++++--- Projects/UOContent/Mobiles/BaseCreature.cs | 4 +- .../Monsters/Reptile/Magic/Leviathan.cs | 2 + 4 files changed, 140 insertions(+), 14 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs diff --git a/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs new file mode 100644 index 000000000..d77cadcae --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs @@ -0,0 +1,108 @@ +using System; +using Server; +using Server.Engines.Harvest; +using Server.Items; +using Server.Mobiles; +using Server.Random; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class FishingTests +{ + private const int Casts = 100_000; + + // On land in Britain, so a cast there never counts as deep water. + private static readonly Point3D _shallow = new(1434, 1699, 0); + + 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)]; + + [Theory] + [InlineData(Expansion.None, 50.0, 0.0, 0.0)] + [InlineData(Expansion.T2A, 0.0, 0.0, 0.75)] + [InlineData(Expansion.T2A, 50.0, 0.0, 0.3877)] + [InlineData(Expansion.T2A, 100.0, 0.025, 0.0247)] + [InlineData(Expansion.UOTD, 80.0, 0.02, 0.1669)] + [InlineData(Expansion.LBR, 0.0, 0.0, 0.75)] + [InlineData(Expansion.LBR, 50.0, 0.025, 0.4519)] + [InlineData(Expansion.AOS, 100.0, 0.05, 0.095)] + [InlineData(Expansion.SA, 50.0, 0.025, 0.4519)] + [InlineData(Expansion.HS, 50.0, 0.0, 0.4635)] + [InlineData(Expansion.HS, 120.0, 0.06, 0.047)] + [InlineData(Expansion.EJ, 120.0, 0.06, 0.047)] + public void MutateType_MagicFishAndFootwear_FollowTheEra( + Expansion expansion, double skill, double magicFishRate, double footwearRate + ) + { + 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; + + Assert.Equal(skill, fisher.Skills.Fishing.Value); + Assert.False(SpecialFishingNet.FullValidation(Map.Felucca, _shallow.X, _shallow.Y)); + + var definition = Fishing.System.Definitions[0]; + var magicFish = 0; + var footwear = 0; + + for (var i = 0; i < Casts; i++) + { + var type = Fishing.System.MutateType(typeof(Fish), fisher, null, definition, Map.Felucca, _shallow, null); + + if (Array.IndexOf(_magicFish, type) >= 0) + { + magicFish++; + } + else if (Array.IndexOf(_footwear, type) >= 0) + { + footwear++; + } + } + + AssertRate(magicFishRate, magicFish); + AssertRate(footwearRate, footwear); + } + finally + { + fisher.Delete(); + BuiltInRng.Generator = previousGenerator; + Core.Expansion = previousExpansion; + } + } + + [Fact] + public void Leviathan_CarriesItsTreasureMapAQuarterOfTheTime() + { + var leviathan = new Leviathan(); + var serpent = new SeaSerpent(); + + try + { + Assert.Equal(0.25, leviathan.TreasureMapChance); + Assert.Equal(TreasureMap.LootChance, serpent.TreasureMapChance); + } + finally + { + leviathan.Delete(); + serpent.Delete(); + } + } + + // 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); + } +} diff --git a/Projects/UOContent/Engines/Harvest/Fishing.cs b/Projects/UOContent/Engines/Harvest/Fishing.cs index b45c44b72..d3784ac3d 100644 --- a/Projects/UOContent/Engines/Harvest/Fishing.cs +++ b/Projects/UOContent/Engines/Harvest/Fishing.cs @@ -10,24 +10,29 @@ 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 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)), - new( - 0.0, - 125.0, - -2375.0, - false, - Expansion.T2A, - typeof(PrizedFish), - typeof(WondrousFish), - typeof(TrulyRareFish), - typeof(PeculiarFish) - ), - new(0.0, 105.0, -420.0, false, Expansion.T2A, typeof(Boots), typeof(Shoes), typeof(Sandals), typeof(ThighBoots)), + // 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. + new(80.0, 0.0, 4000.0, false, Expansion.T2A, Expansion.UOTD, _magicFish), + new(0.0, 0.0, 2000.0, false, Expansion.LBR, Expansion.SA, _magicFish), + new(80.0, 0.0, 2000.0, false, Expansion.HS, _magicFish), + 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 }) }; @@ -174,7 +179,7 @@ namespace Server.Engines.Harvest { var entry = _mutateTable[i]; - if (Core.Expansion < entry.m_Expansion) + if (Core.Expansion < entry.m_Expansion || Core.Expansion > entry.m_MaxExpansion) { continue; } @@ -541,6 +546,7 @@ namespace Server.Engines.Harvest { public readonly bool m_DeepWater; public readonly Expansion m_Expansion; + public readonly Expansion? m_MaxExpansion; public readonly double m_MaxSkill; public readonly double m_MinSkill; public readonly double m_ReqSkill; @@ -548,6 +554,13 @@ namespace Server.Engines.Harvest public MutateEntry( double reqSkill, double minSkill, double maxSkill, bool deepWater, Expansion expansion, params Type[] types + ) : this(reqSkill, minSkill, maxSkill, deepWater, expansion, null, types) + { + } + + public MutateEntry( + double reqSkill, double minSkill, double maxSkill, bool deepWater, Expansion expansion, + Expansion? maxExpansion, params Type[] types ) { m_ReqSkill = reqSkill; @@ -555,6 +568,7 @@ namespace Server.Engines.Harvest m_MaxSkill = maxSkill; m_DeepWater = deepWater; m_Expansion = expansion; + m_MaxExpansion = maxExpansion; m_Types = types; } } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index f0210d4f6..bd28ff590 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -1310,6 +1310,8 @@ namespace Server.Mobiles public virtual int TreasureMapLevel => -1; + public virtual double TreasureMapChance => TreasureMap.LootChance; + public virtual bool IgnoreYoungProtection => false; public bool NoKillAwards { get; set; } @@ -3246,7 +3248,7 @@ namespace Server.Mobiles { PackItem(new ParagonChest(Name, treasureLevel)); } - else if ((Map == Map.Felucca || Map == Map.Trammel) && Utility.RandomDouble() < TreasureMap.LootChance) + else if ((Map == Map.Felucca || Map == Map.Trammel) && Utility.RandomDouble() < TreasureMapChance) { PackItem(new TreasureMap(treasureLevel, Map)); } diff --git a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs index e4ffe5ba3..e4adbf05e 100644 --- a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs +++ b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs @@ -62,6 +62,8 @@ namespace Server.Mobiles public override int TreasureMapLevel => 5; + public override double TreasureMapChance => 0.25; + public override bool OnBeforeDeath() { PackItem(new MessageInABottle(Map == Map.Felucca ? Map.Felucca : Map.Trammel));