#W# Merge: Merged with upstream. Conflicts resolved.

This commit is contained in:
WarrentyExpired 2026-10-27 19:56:03 -04:00
commit f6f50470a9
276 changed files with 6742 additions and 8845 deletions

View file

@ -28,7 +28,7 @@ using Server.Logging;
namespace Server.Engines.CannedEvil;
[SerializationGenerator(11, false)]
[SerializationGenerator(12, false)]
public partial class ChampionSpawn : Item
{
private void MigrateFrom(V10Content content)
@ -59,6 +59,35 @@ public partial class ChampionSpawn : Item
_restartTime = content.RestartTime;
}
private void MigrateFrom(V11Content content)
{
_level = content.Level;
_activatedByProximity = content.ActivatedByProximity;
_nextProximityTime = content.NextProximityTime;
_maxLevel = content.MaxLevel;
_activatedByValor = content.ActivatedByValor;
_damageEntries = content.DamageEntries;
_confinedRoaming = content.ConfinedRoaming;
_idol = content.Idol;
_hasBeenAdvanced = content.HasBeenAdvanced;
_spawnArea = content.SpawnArea;
_randomizeType = content.RandomizeType;
_kills = content.Kills;
_active = content.Active;
_type = content.Type;
_creatures = content.Creatures;
_redSkulls = content.RedSkulls;
_whiteSkulls = content.WhiteSkulls;
_platform = content.Platform;
_altar = content.Altar;
_expireDelay = content.ExpireDelay;
_expireTime = content.ExpireTime;
_champion = content.Champion;
_restartDelay = content.RestartDelay;
_restartTime = content.RestartTime;
_minions = [];
}
private static readonly ILogger logger = LogFactory.GetLogger(typeof(ChampionSpawn));
[SerializableField(1)]
@ -143,6 +172,12 @@ public partial class ChampionSpawn : Item
[SerializedCommandProperty(AccessLevel.GameMaster)]
private DateTime _restartTime;
// Mobiles summoned by the champion or the spawn's creatures. Deleted with the creatures, never counted as kills.
[Tidy]
[SerializableField(24)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private List<Mobile> _minions;
private ChampionSpawnRegion m_Region;
//Goes back each level, below level 0 and it goes off!
@ -173,6 +208,7 @@ public partial class ChampionSpawn : Item
Visible = false;
_creatures = new List<Mobile>();
_minions = [];
_redSkulls = new List<Item>();
_whiteSkulls = new List<Item>();
@ -294,6 +330,21 @@ public partial class ChampionSpawn : Item
public bool IsChampionSpawn(Mobile m) => _creatures.Contains(m);
/// <summary>
/// The spawn <paramref name="creature" /> fights for: as its champion, one of its creatures, or a minion of either.
/// </summary>
public static ChampionSpawn FindSpawnOf(BaseCreature creature)
{
var spawn = Region.Find(creature.Location, creature.Map).GetRegion<ChampionSpawnRegion>()?.Spawn;
return spawn?.Deleted == false &&
(spawn.Champion == creature || spawn._minions.Contains(creature) || spawn._creatures.Contains(creature))
? spawn
: null;
}
public void AddMinion(Mobile minion) => AddToMinions(minion);
[CommandProperty(AccessLevel.GameMaster)]
public virtual int MaxKills
{
@ -1171,6 +1222,13 @@ public partial class ChampionSpawn : Item
ClearCreatures();
}
for (var i = 0; i < _minions.Count; ++i)
{
_minions[i].Delete();
}
ClearMinions();
}
public virtual void RegisterDamageTo(Mobile m)
@ -1321,6 +1379,7 @@ public partial class ChampionSpawn : Item
_active = reader.ReadBool();
_type = (ChampionSpawnType)reader.ReadInt();
_creatures = reader.ReadEntityList<Mobile>();
_minions = [];
_redSkulls = reader.ReadEntityList<Item>();
_whiteSkulls = reader.ReadEntityList<Item>();
_platform = reader.ReadEntity<ChampionPlatform>();

View file

@ -663,15 +663,7 @@ namespace Server.Engines.Craft
}
// ******************
for (var j = 0; types[i] == null && j < m_TypesTable.Length; ++j)
{
if (m_TypesTable[j][0] == baseType)
{
types[i] = m_TypesTable[j];
}
}
types[i] ??= [baseType];
types[i] = GetEquivalentTypes(baseType) ?? [baseType];
amounts[i] = craftRes.Amount;
// For stackable items that can be crafted more than one at a time
@ -1271,13 +1263,54 @@ namespace Server.Engines.Craft
return true;
}
private static int GetHuedAmount(Container pack, Type type, int hue)
/// <summary>
/// Returns the interchangeable resource types for <paramref name="baseType"/> (e.g. logs and boards),
/// or null when it has no equivalents.
/// </summary>
internal static Type[] GetEquivalentTypes(Type baseType)
{
for (var i = 0; i < m_TypesTable.Length; i++)
{
if (m_TypesTable[i][0] == baseType)
{
return m_TypesTable[i];
}
}
return null;
}
private static bool IsHuedMatch(Item item, Type type, Type[] equivalents, int hue)
{
if (item.Hue != hue)
{
return false;
}
if (equivalents == null)
{
return type.IsInstanceOfType(item);
}
for (var i = 0; i < equivalents.Length; i++)
{
if (equivalents[i].IsInstanceOfType(item))
{
return true;
}
}
return false;
}
internal static int GetHuedAmount(Container pack, Type type, int hue)
{
var total = 0;
var equivalents = GetEquivalentTypes(type);
foreach (var item in pack.FindItems(true))
{
if (item.Hue == hue && type.IsInstanceOfType(item))
if (IsHuedMatch(item, type, equivalents, hue))
{
total += item.Amount;
}
@ -1289,6 +1322,7 @@ namespace Server.Engines.Craft
private static bool ConsumeHuedAmount(Container pack, Type type, int hue, int amount)
{
var remaining = amount;
var equivalents = GetEquivalentTypes(type);
using var toDelete = PooledRefList<Item>.Create();
foreach (var item in pack.FindItems(true))
@ -1298,7 +1332,7 @@ namespace Server.Engines.Craft
break;
}
if (item.Hue != hue || !type.IsInstanceOfType(item))
if (!IsHuedMatch(item, type, equivalents, hue))
{
continue;
}

View file

@ -0,0 +1,211 @@
using System;
using Server.Items;
using Server.Targeting;
namespace Server.Engines.Craft.T2A;
/// <summary>An item that can be double-clicked under T2A to combine with, or cook over, a target.</summary>
public interface IT2ACombinable
{
bool TryCombine(Mobile from, object target);
}
/// <summary>
/// Shared helpers for T2A cooking, which is item-on-item and item-on-heat-source rather than a craft menu.
/// Each ingredient item decides its own results in <see cref="IT2ACombinable.TryCombine"/>.
/// </summary>
public sealed class T2ACooking
{
private const int _heatSourceRange = 1;
private const int _waterRange = 2;
private const int _burnRange = 3;
private T2ACooking()
{
}
/// <summary>Asks the player for a target if the item is in their pack.</summary>
public static void Prompt(Mobile from, Item source)
{
if (!ValidateInPack(from, source))
{
return;
}
from.SendAsciiMessage("What do you wish to combine this with, or cook it over?");
from.Target = new CookTarget(source);
}
public static bool ValidateInPack(Mobile from, Item item)
{
if (item is { Deleted: false } && item.IsChildOf(from.Backpack))
{
return true;
}
from.SendLocalizedMessage(1042001); // That must be in your pack for you to use it.
return false;
}
public static bool CannotCombine(Mobile from)
{
from.SendAsciiMessage("You cannot combine those.");
return false;
}
//TODO Support static water troughs and barrels (no static water ID list exists yet)
public static IHasQuantity GetWaterSource(object o) =>
o switch
{
BaseBeverage { Content: BeverageType.Water, Quantity: > 0 } bev => bev,
BaseWaterContainer { IsEmpty: false } container => container,
AddonComponent { Addon: IWaterSource { Quantity: > 0 } trough } => trough,
IWaterSource { Quantity: > 0 } source => source,
_ => null
};
// Ground water only, so a nearby open container (someone else's pack) cannot be drawn from.
public static bool CanReachWater(Mobile from, Item water)
{
if (water.IsChildOf(from.Backpack) || water.Parent == from ||
water.RootParent == null && water.Map == from.Map &&
from.InRange(water.GetWorldLocation(), _waterRange) && from.InLOS(water))
{
return true;
}
from.LocalOverheadMessage(MessageType.Regular, 0x3B2, 1019045); // I can't reach that.
return false;
}
/// <summary>Spends one unit of each ingredient and gives the player the result.</summary>
public static bool Combine(Mobile from, object source, object target, Item result, string message)
{
ConsumeOne(source);
ConsumeOne(target);
if (!GiveResult(from, result))
{
return false;
}
from.SendAsciiMessage(message);
return true;
}
// AddToBackpack drops a result that does not fit at the mobile's feet (or logout location),
// but with no valid map either it would be left on the internal map forever.
private static bool GiveResult(Mobile from, Item result)
{
if (from.AddToBackpack(result) || result.Map != null && result.Map != Map.Internal)
{
return true;
}
result.Delete();
return false;
}
//TODO Confirm whether an empty bowl is returned when a BowlFlour is used up
private static void ConsumeOne(object o)
{
if (o is IHasQuantity container)
{
container.Quantity--;
}
else if (o is Item item)
{
item.Consume();
}
}
/// <summary>Cooks the source over a heat source if it is close enough and the player is not already cooking.</summary>
public static bool BeginHeat(Mobile from, Item source, object target)
{
var point = target is IPoint3D p ? new Point3D(p) : from.Location;
if (from.Map == null || !from.InRange(point, _heatSourceRange))
{
from.SendLocalizedMessage(500446); // That is too far away.
return false;
}
if (!from.BeginAction<T2ACooking>())
{
from.SendLocalizedMessage(500119); // You must wait to perform another action
return false;
}
from.PlaySound(0x225);
source.Consume();
Timer.DelayCall(TimeSpan.FromSeconds(5.0), FinishHeat, from, source, from.Map, point);
return true;
}
// The source is already consumed here and may be deleted, so only its type and CookingLevel are read.
private static void FinishHeat(Mobile from, Item source, Map map, Point3D point)
{
from.EndAction<T2ACooking>();
if (from.Deleted)
{
return;
}
if (from.Map != map || !from.InRange(point, _burnRange))
{
from.SendLocalizedMessage(500686); // You burn the food to a crisp! It's ruined.
return;
}
var (min, max) = source switch
{
CookableFood food => (food.CookingLevel, 100.0),
Dough => CraftRange(typeof(BreadLoaf)),
SweetDough => CraftRange(typeof(Muffins)),
_ => (0.0, 100.0)
};
if (!from.CheckSkill(SkillName.Cooking, min, max))
{
from.SendLocalizedMessage(500686); // You burn the food to a crisp! It's ruined.
return;
}
Item result = source switch
{
CookableFood food => food.Cook(),
Dough => new BreadLoaf(),
SweetDough => new Muffins(),
_ => null
};
if (result != null && GiveResult(from, result))
{
from.PlaySound(0x57);
}
}
// Falls back to the full range when the cooking craft system has no entry or is not initialized.
private static (double Min, double Max) CraftRange(Type type)
{
var skills = DefCooking.CraftSystem?.CraftItems.SearchFor(type)?.Skills;
return skills is { Count: > 0 } ? (skills[0].MinSkill, skills[0].MaxSkill) : (0.0, 100.0);
}
private sealed class CookTarget : Target
{
private readonly Item _source;
public CookTarget(Item source) : base(_waterRange, false, TargetFlags.None) => _source = source;
protected override void OnTarget(Mobile from, object targeted)
{
if (ValidateInPack(from, _source) && _source is IT2ACombinable combinable)
{
combinable.TryCombine(from, targeted);
}
}
}
}

View file

@ -15,6 +15,12 @@ public static class T2ACraftSystem
/// </summary>
public static bool Enabled { get; set; }
public static bool HasMenu(CraftSystem system) =>
system == DefBlacksmithy.CraftSystem || system == DefAlchemy.CraftSystem ||
system == DefBowFletching.CraftSystem || system == DefCarpentry.CraftSystem ||
system == DefCartography.CraftSystem || system == DefInscription.CraftSystem ||
system == DefTailoring.CraftSystem || system == DefTinkering.CraftSystem;
public static void ShowMenu(Mobile from, CraftSystem craftSystem, BaseTool tool, Item preTarget = null)
{
if (!Enabled)
@ -190,9 +196,11 @@ public static class T2ACraftSystem
/// Checks if a player can craft a specific item, accounting for sub-resource types.
/// When <paramref name="selectedResourceType"/> is non-null, checks against that specific sub-resource.
/// When null, checks against ANY available sub-resource the player has sufficient skill and materials for.
/// A non-negative <paramref name="hue"/> counts only same-hue stock for the primary resource, exactly as
/// the hue-aware craft path consumes it.
/// </summary>
public static bool CanCraftItem(
Mobile from, CraftItem itemDef, CraftSystem system, Type selectedResourceType = null
Mobile from, CraftItem itemDef, CraftSystem system, Type selectedResourceType = null, int hue = -1
)
{
var pack = from.Backpack;
@ -214,6 +222,27 @@ public static class T2ACraftSystem
var res = itemDef.Resources[i];
var resType = res.ItemType;
if (hue >= 0 && i == 0)
{
if (resCol.Init && resType == resCol.ResType && selectedResourceType != null)
{
var subRes = resCol.SearchFor(selectedResourceType);
if (subRes != null && from.Skills[system.MainSkill].Value < subRes.RequiredSkill)
{
return false;
}
resType = selectedResourceType;
}
if (CraftItem.GetHuedAmount(pack, resType, hue) < res.Amount)
{
return false;
}
continue;
}
// If this resource is the base sub-resource type (e.g. IronIngot for blacksmithing),
// handle sub-resource substitution
if (resCol.Init && resType == resCol.ResType)
@ -250,17 +279,20 @@ public static class T2ACraftSystem
/// <summary>
/// Convenience overload that resolves a Type to a CraftItem first.
/// </summary>
public static bool CanCraftItem(Mobile from, Type itemType, CraftSystem system, Type selectedResourceType = null)
public static bool CanCraftItem(
Mobile from, Type itemType, CraftSystem system, Type selectedResourceType = null, int hue = -1
)
{
var itemDef = system.CraftItems.SearchFor(itemType);
return itemDef != null && CanCraftItem(from, itemDef, system, selectedResourceType);
return itemDef != null && CanCraftItem(from, itemDef, system, selectedResourceType, hue);
}
/// <summary>
/// Filters static template entries to only those the player can craft.
/// </summary>
public static ItemListEntry[] FilterEntries(
Mobile from, ItemListEntry[] staticEntries, Type[] types, CraftSystem system, Type selectedResourceType = null
Mobile from, ItemListEntry[] staticEntries, Type[] types, CraftSystem system,
Type selectedResourceType = null, int hue = -1
)
{
var filtered = new ItemListEntry[staticEntries.Length];
@ -271,7 +303,7 @@ public static class T2ACraftSystem
var entry = staticEntries[i];
var typeIndex = entry.CraftIndex;
if (typeIndex >= 0 && typeIndex < types.Length &&
CanCraftItem(from, types[typeIndex], system, selectedResourceType))
CanCraftItem(from, types[typeIndex], system, selectedResourceType, hue))
{
filtered[count++] = entry;
}
@ -294,12 +326,12 @@ public static class T2ACraftSystem
/// Returns true if at least one item in the type array is craftable.
/// </summary>
public static bool AnyCraftableInCategory(
Mobile from, Type[] types, CraftSystem system, Type selectedResourceType = null
Mobile from, Type[] types, CraftSystem system, Type selectedResourceType = null, int hue = -1
)
{
for (var i = 0; i < types.Length; i++)
{
if (CanCraftItem(from, types[i], system, selectedResourceType))
if (CanCraftItem(from, types[i], system, selectedResourceType, hue))
{
return true;
}
@ -308,28 +340,11 @@ public static class T2ACraftSystem
return false;
}
/// <summary>
/// Equivalent type pairs mirroring CraftItem.m_TypesTable — used so that menu filtering
/// counts boards when checking for logs, hides when checking for leather, etc.
/// </summary>
private static readonly Type[][] _equivalentTypes =
[
[typeof(Log), typeof(Board)],
[typeof(Cloth), typeof(UncutCloth)],
[typeof(Items.Leather), typeof(Hides)]
];
// Shares CraftItem's equivalence table so the menu counts exactly what the craft will consume
private static int GetResourceAmount(Container pack, Type type)
{
for (var i = 0; i < _equivalentTypes.Length; i++)
{
if (_equivalentTypes[i][0] == type)
{
return pack.GetAmount(_equivalentTypes[i]);
}
}
return pack.GetAmount(type);
var equivalents = CraftItem.GetEquivalentTypes(type);
return equivalents != null ? pack.GetAmount(equivalents) : pack.GetAmount(type);
}
private static bool HasAnySufficientSubResource(

View file

@ -36,6 +36,13 @@ public class T2ACraftToolTarget : Target
var resIndex = lastMade.UseSubRes2 ? context.LastResourceIndex2 : context.LastResourceIndex;
var type = resIndex > -1 ? res[resIndex].ItemType : null;
// T2A tinkering has no colored ingots; a stale index from the gump flow must not leak in
if (_system == DefTinkering.CraftSystem)
{
type = typeof(IronIngot);
T2ACraftSystem.SetLastResourceIndex(from, _system, type);
}
// Jewelry requires gem targeting — re-prompt instead of crafting directly
if (typeof(BaseJewel).IsAssignableFrom(lastMade.ItemType))
{

View file

@ -92,7 +92,7 @@ public class TailoringMenu : ItemListMenu
};
private TailoringMenu(Mobile from, BaseTool tool, Category category, int hue = -1)
: base(GetQuestion(category), BuildFilteredEntries(from, category))
: base(GetQuestion(category), BuildFilteredEntries(from, category, hue))
{
_tool = tool;
_category = category;
@ -198,11 +198,11 @@ public class TailoringMenu : ItemListMenu
public static ItemListEntry[] Studded() => _studdedEntries ??= BuildStaticEntries(StuddedArmorTypes, "leather");
public static ItemListEntry[] Female() => _femaleEntries ??= BuildStaticEntries(FemaleArmorTypes, "leather");
private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category)
private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category, int hue)
{
if (category is Category.Main or Category.LeatherMain)
{
return BuildFilteredMainEntries(from, category);
return BuildFilteredMainEntries(from, category, hue);
}
var types = GetTypes(category);
@ -212,10 +212,10 @@ public class TailoringMenu : ItemListMenu
return [];
}
return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefTailoring.CraftSystem);
return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefTailoring.CraftSystem, null, hue);
}
private static ItemListEntry[] BuildFilteredMainEntries(Mobile from, Category mainCategory)
private static ItemListEntry[] BuildFilteredMainEntries(Mobile from, Category mainCategory, int hue)
{
var system = DefTailoring.CraftSystem;
var mainStatic = GetStaticEntries(mainCategory);
@ -231,7 +231,7 @@ public class TailoringMenu : ItemListMenu
{
var entry = mainStatic[i];
var types = GetTypes((Category)entry.CraftIndex);
if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system))
if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system, null, hue))
{
filtered[count++] = entry;
}

View file

@ -117,7 +117,7 @@ public class TinkeringMenu : ItemListMenu
};
private TinkeringMenu(Mobile from, BaseTool tool, Category category, Type selectedResourceType)
: base(GetQuestion(category), BuildFilteredEntries(from, category))
: base(GetQuestion(category), BuildFilteredEntries(from, category, selectedResourceType))
{
_tool = tool;
_category = category;
@ -224,16 +224,16 @@ public class TinkeringMenu : ItemListMenu
public static ItemListEntry[] Rings() => _ringEntries ??= BuildStaticEntries(RingTypes, "ingots");
public static ItemListEntry[] KegItems() => _kegEntries ??= BuildStaticEntries(KegItemTypes, "kegs");
private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category)
private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category, Type selectedResourceType)
{
if (category == Category.Main)
{
return BuildFilteredMainEntries(from);
return BuildFilteredMainEntries(from, selectedResourceType);
}
if (category == Category.Jewelry)
{
return BuildFilteredJewelryEntries(from);
return BuildFilteredJewelryEntries(from, selectedResourceType);
}
var types = GetTypes(category);
@ -243,7 +243,7 @@ public class TinkeringMenu : ItemListMenu
return [];
}
return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefTinkering.CraftSystem);
return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefTinkering.CraftSystem, selectedResourceType);
}
private static readonly ItemListEntry[] JewelrySubcategoryEntries =
@ -253,7 +253,7 @@ public class TinkeringMenu : ItemListMenu
new("Rings", 0x108a, 0, (int)Category.Rings)
];
private static ItemListEntry[] BuildFilteredJewelryEntries(Mobile from)
private static ItemListEntry[] BuildFilteredJewelryEntries(Mobile from, Type selectedResourceType)
{
var system = DefTinkering.CraftSystem;
var filtered = new ItemListEntry[JewelrySubcategoryEntries.Length];
@ -263,7 +263,7 @@ public class TinkeringMenu : ItemListMenu
{
var entry = JewelrySubcategoryEntries[i];
var types = GetTypes((Category)entry.CraftIndex);
if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system))
if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system, selectedResourceType))
{
filtered[count++] = entry;
}
@ -282,7 +282,7 @@ public class TinkeringMenu : ItemListMenu
return filtered;
}
private static ItemListEntry[] BuildFilteredMainEntries(Mobile from)
private static ItemListEntry[] BuildFilteredMainEntries(Mobile from, Type selectedResourceType)
{
var system = DefTinkering.CraftSystem;
var mainStatic = Main();
@ -293,7 +293,7 @@ public class TinkeringMenu : ItemListMenu
{
var entry = mainStatic[i];
var types = GetTypes((Category)entry.CraftIndex);
if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system))
if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system, selectedResourceType))
{
filtered[count++] = entry;
}
@ -407,9 +407,17 @@ public class TinkeringMenu : ItemListMenu
return true;
}
if (targeted is BaseIngot)
if (targeted is BaseIngot and not IronIngot)
{
var menu = new TinkeringMenu(from, tool, Category.Main, targeted.GetType());
// T2A tinkering predates colored ingots; metal items are always made from iron.
from.SendAsciiMessage("Tinkering can only use iron ingots. Please select iron ingots or wood.");
from.Target = new ResourceSelectTarget(from, tool);
return true;
}
if (targeted is IronIngot)
{
var menu = new TinkeringMenu(from, tool, Category.Main, typeof(IronIngot));
if (menu.Entries.Length == 0)
{
from.SendAsciiMessage("You lack the skill and materials to craft anything.");

View file

@ -10,61 +10,77 @@ 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)),
new(0.0, 200.0, -200.0, false, Expansion.None, new Type[] { null })
};
// 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.
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)
];
// 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()
{
@ -83,9 +99,9 @@ namespace Server.Engines.Harvest
MaxRange = 4,
ConsumedPerHarvest = 1,
ConsumedPerFeluccaHarvest = 1,
EffectActions = new[] { 12 },
EffectSounds = Array.Empty<int>(),
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.
@ -96,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();
@ -160,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
@ -174,7 +191,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;
}
@ -198,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<SOS>())
{
@ -239,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<SOS>())
{
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;
}
}
@ -334,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
}
@ -380,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)
@ -401,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;
@ -467,7 +466,7 @@ namespace Server.Engines.Harvest
TimeSpan.FromSeconds(1.5),
() =>
{
if (Core.ML)
if (Core.T2A)
{
from.RevealingAction();
}
@ -486,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;
@ -541,6 +548,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 +556,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 +570,7 @@ namespace Server.Engines.Harvest
m_MaxSkill = maxSkill;
m_DeepWater = deepWater;
m_Expansion = expansion;
m_MaxExpansion = maxExpansion;
m_Types = types;
}
}

View file

@ -216,16 +216,22 @@ internal static class StepCacheFile
var path = Core.FindDataFile("tiledata.mul", false);
if (path != null)
{
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var hasher = HashUtility.CreateXxHash3();
hasher.Append(fs);
_tileDataFileFingerprint = hasher.GetCurrentHashAsUInt64();
_tileDataFileFingerprint = HashTileDataFile(path);
}
_tileDataFileFingerprintComputed = true;
return _tileDataFileFingerprint;
}
/// <summary>XxHash3 of the bytes of the tiledata file at <paramref name="path"/>.</summary>
internal static ulong HashTileDataFile(string path)
{
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var hasher = HashUtility.CreateXxHash3();
hasher.Append(fs);
return hasher.GetCurrentHashAsUInt64();
}
/// <summary>
/// Writes the map's chunks to <paramref name="path"/>: header, then one record per chunk, then
/// the index trailer. IndexOffset isn't known until the records are down, so it goes in as a