ModernUO/Projects/UOContent/Engines/Harvest/Fishing.cs
Sergi Rosell f4ba6036fe
fix: fishing catch chances by era and the leviathan's treasure map (#2709)
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
2026-10-17 14:41:58 -07:00

576 lines
20 KiB
C#

using System;
using Server.Engines.Quests.Collector;
using Server.Items;
using Server.Mobiles;
using Server.Spells;
namespace Server.Engines.Harvest
{
public class Fishing : HarvestSystem
{
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)),
// 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 })
};
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 = {
0x1CDD, 0x1CE5, // arm
0x1CE0, 0x1CE8, // torso
0x1CE1, 0x1CE9, // head
0x1CE2, 0x1CEC // leg
};
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 = {
0x1EB5, // unfinished barrel
0xA2A, // stool
0xC1F, // broken clock
0x1047, 0x1048, // globe
0x1EB1, 0x1EB2, 0x1EB3, 0x1EB4 // barrel staves
};
private Fishing()
{
var fish = new HarvestDefinition
{
BankWidth = 8,
BankHeight = 8,
MinTotal = 5,
MaxTotal = 15,
MinRespawn = TimeSpan.FromMinutes(10.0),
MaxRespawn = TimeSpan.FromMinutes(20.0),
Skill = SkillName.Fishing,
LandTiles = _waterLandTiles,
StaticTiles = waterStaticTiles,
RangedTiles = true,
MaxRange = 4,
ConsumedPerHarvest = 1,
ConsumedPerFeluccaHarvest = 1,
EffectActions = new[] { 12 },
EffectSounds = Array.Empty<int>(),
EffectCounts = new[] { 1 },
EffectDelay = TimeSpan.Zero,
EffectSoundDelay = TimeSpan.FromSeconds(8.0),
NoResourcesMessage = 503172, // The fish don't seem to be biting here.
FailMessage = 503171, // You fish a while, but fail to catch anything.
TimedOutOfRangeMessage = 500976, // You need to be closer to the water to fish!
OutOfRangeMessage = 500976, // You need to be closer to the water to fish!
PackFullMessage = 503176, // You do not have room in your backpack for a fish.
ToolBrokeMessage = 503174 // You broke your fishing pole.
};
HarvestResource[] res =
{
new(00.0, 00.0, 100.0, 1043297, typeof(Fish))
};
HarvestVein[] veins =
{
new(1000, 0.0, res[0], null)
};
fish.Resources = res;
fish.Veins = veins;
if (Core.ML)
{
fish.BonusResources = new[]
{
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 };
}
public static Fishing System => _system ??= new Fishing();
public override void OnConcurrentHarvest(Mobile from, Item tool, HarvestDefinition def, object toHarvest)
{
from.SendLocalizedMessage(500972); // You are already fishing.
}
public override bool SpecialHarvest(Mobile from, Item tool, HarvestDefinition def, Map map, Point3D loc)
{
if (from is PlayerMobile player)
{
var qs = player.Quest;
if (qs is CollectorQuest)
{
var obj = qs.FindObjective<FishPearlsObjective>();
if (obj?.Completed == false)
{
if (Utility.RandomBool())
{
// You pull a shellfish out of the water, and find a rainbow pearl inside of it.
player.SendLocalizedMessage(1055086, "", 0x59);
obj.CurProgress++;
}
else
{
// You pull a shellfish out of the water, but it doesn't have a rainbow pearl.
player.SendLocalizedMessage(1055087, "", 0x2C);
}
return true;
}
}
}
return false;
}
public override Type MutateType(
Type type, Mobile from, Item tool, HarvestDefinition def, Map map, Point3D loc,
HarvestResource resource
)
{
var deepWater = SpecialFishingNet.FullValidation(map, loc.X, loc.Y);
var skillBase = from.Skills.Fishing.Base;
var skillValue = from.Skills.Fishing.Value;
for (var i = 0; i < _mutateTable.Length; ++i)
{
var entry = _mutateTable[i];
if (Core.Expansion < entry.m_Expansion || Core.Expansion > entry.m_MaxExpansion)
{
continue;
}
if (!deepWater && entry.m_DeepWater)
{
continue;
}
if (skillBase >= entry.m_ReqSkill)
{
var chance = (skillValue - entry.m_MinSkill) / (entry.m_MaxSkill - entry.m_MinSkill);
if (chance > Utility.RandomDouble())
{
return entry.m_Types.RandomElement();
}
}
}
return type;
}
public override bool CheckResources(Mobile from, Item tool, HarvestDefinition def, Map map, Point3D loc, bool timed)
{
var pack = from.Backpack;
if (pack != null)
{
foreach (var sos in pack.FindItemsByType<SOS>())
{
if (from.Map == sos.TargetMap && from.InRange(sos.TargetLocation, 60))
{
return true;
}
}
}
return base.CheckResources(from, tool, def, map, loc, timed);
}
public override Item Construct(Type type, Mobile from)
{
if (type == typeof(TreasureMap))
{
int level;
if (from is PlayerMobile mobile && mobile.Young && mobile.Map == Map.Trammel &&
TreasureMap.IsInHavenIsland(from))
{
level = 0;
}
else
{
level = 1;
}
return new TreasureMap(level, from.Map == Map.Felucca ? Map.Felucca : Map.Trammel);
}
if (type == typeof(MessageInABottle))
{
return new MessageInABottle(from.Map == Map.Felucca ? Map.Felucca : Map.Trammel);
}
var pack = from.Backpack;
if (pack != null)
{
// We don't have to queue since we are returning on the first SOS.
foreach (var sos in pack.FindItemsByType<SOS>())
{
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;
}
}
}
return base.Construct(type, from);
}
public override bool Give(Mobile m, Item item, bool placeAtFeet)
{
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!
return true; // we don't want to give the item to the player, it's on the serpent
}
return base.Give(m, item, placeAtFeet || item is BigFish or WoodenChest or MetalGoldenChest);
}
public override void SendSuccessTo(Mobile from, Item item, HarvestResource resource)
{
if (item is BigFish fish)
{
from.SendLocalizedMessage(1042635); // Your fishing pole bends as you pull a big fish from the depths!
fish.Fisher = from;
}
else if (item is WoodenChest or MetalGoldenChest)
{
from.SendLocalizedMessage(503175); // You pull up a heavy chest from the depths of the ocean!
}
else
{
int number;
string name;
if (item is BaseMagicFish)
{
number = 1008124;
name = "a mess of small fish";
}
else if (item is Fish)
{
number = 1008124;
name = item.ItemData.Name;
}
else if (item is BaseShoes)
{
number = 1008124;
name = item.ItemData.Name;
}
else if (item is TreasureMap)
{
number = 1008125;
name = "a sodden piece of parchment";
}
else if (item is MessageInABottle)
{
number = 1008125;
name = "a bottle, with a message in it";
}
else if (item is SpecialFishingNet)
{
number = 1008125;
name = "a special fishing net"; // TODO: this is just a guess--what should it really be named?
}
else
{
number = 1043297;
if ((item.ItemData.Flags & TileFlag.ArticleA) != 0)
{
name = $"a {item.ItemData.Name}";
}
else if ((item.ItemData.Flags & TileFlag.ArticleAn) != 0)
{
name = $"an {item.ItemData.Name}";
}
else
{
name = item.ItemData.Name;
}
}
var ns = from.NetState;
if (number == 1043297 || ns?.HighSeas == true)
{
from.SendLocalizedMessage(number, name);
}
else
{
from.SendLocalizedMessage(number, true, name);
}
}
}
public override void OnHarvestStarted(Mobile from, Item tool, HarvestDefinition def, object toHarvest)
{
base.OnHarvestStarted(from, tool, def, toHarvest);
if (GetHarvestDetails(from, tool, toHarvest, out _, out var map, out var loc, out _))
{
Timer.StartTimer(
TimeSpan.FromSeconds(1.5),
() =>
{
if (Core.ML)
{
from.RevealingAction();
}
Effects.SendLocationEffect(loc, map, 0x352D, 16, 4);
Effects.PlaySound(loc, map, 0x364);
}
);
}
}
public override void OnHarvestFinished(
Mobile from, Item tool, HarvestDefinition def, HarvestVein vein,
HarvestBank bank, HarvestResource resource, object harvested
)
{
base.OnHarvestFinished(from, tool, def, vein, bank, resource, harvested);
if (Core.ML)
{
from.RevealingAction();
}
}
public override object GetLock(Mobile from, Item tool, HarvestDefinition def, object toHarvest) => this;
public override bool BeginHarvesting(Mobile from, Item tool)
{
if (!base.BeginHarvesting(from, tool))
{
return false;
}
from.SendLocalizedMessage(500974); // What water do you want to fish in?
return true;
}
public override bool CheckHarvest(Mobile from, Item tool)
{
if (!base.CheckHarvest(from, tool))
{
return false;
}
if (from.Mounted)
{
from.SendLocalizedMessage(500971); // You can't fish while riding!
return false;
}
return true;
}
public override bool CheckHarvest(Mobile from, Item tool, HarvestDefinition def, object toHarvest)
{
if (!base.CheckHarvest(from, tool, def, toHarvest))
{
return false;
}
if (from.Mounted)
{
from.SendLocalizedMessage(500971); // You can't fish while riding!
return false;
}
return true;
}
private class MutateEntry
{
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;
public readonly Type[] m_Types;
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;
m_MinSkill = minSkill;
m_MaxSkill = maxSkill;
m_DeepWater = deepWater;
m_Expansion = expansion;
m_MaxExpansion = maxExpansion;
m_Types = types;
}
}
}
}