Fixes #2682. Thanks @DatMoshu for the detailed report and repro. While fixing it I audited the other map, item and multi enumerators for the same class of defect. None repeats #2682 itself, but four others turned up. All are fixed here. ## Fixes - **`StaticTileEnumerator` (#2682)**: `SetMulti` returned `SetTile()` for the first multi whose bounds contained the point. If that multi had no tile at the cell, the enumeration ended and every later multi in the sector was skipped. Movement and line of sight lost a floor that stood inside another multi's bounding box. It now moves on to the next multi. - **`MultiBoundsEnumerator`**: deduplication used one static `HashSet` that every new query cleared. A query run inside another made the outer query skip multis or return them twice. Each multi is now yielded only from the sector that holds its `Location`; the bounds filter guarantees that sector is visited. A per-enumerator set covers a multi that isn't registered in that sector (components changed after placement). Normal queries allocate nothing. - **`Item`/`Mobile`/`ClientDistanceEnumerator`**: ring sectors were bounded only by `sx >= _sectorStartX`. Ring cells past the map edge resolved to the shared invalid sector, once per cell. Entities at off-map coordinates were returned more than once, and sectors outside the query were created lazily. The check now covers all four sides. Results match the bounds enumerators, which never returned off-map entities. - **`BaseBoat.MovingEntitiesEnumerator`**: `GetMovingEntities` handles a null map, but the enumerator constructor still called `boat.Map.GetMobilesInBounds` and threw. A boat with no map now gets empty enumerators. - **`BaseDoor.ChainEnumerator`**: the walk stopped only on returning to the start door. Links are set in pairs, so `[ChainLink A B C` followed by `[Link B C` leaves A→B→C→B, and enumerating that chain never ended. The walk now stops on any door it has already yielded. Only reachable when `UseChainedFunctionality` is true, which is off in base and in all shipped content. ## Testing - A regression test for each fix except the boat null guard. Each failed before its fix and passes after. - Server.Tests 910/910. UOContent.Tests 1185 passed, 2 skipped. - Test note: the `MultiComponentList(List<MultiTileEntry>)` constructor drops entries after the first whose `Flags` are 0. The new test multis use non-zero flags.
528 lines
14 KiB
C#
528 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Runtime.CompilerServices;
|
|
using ModernUO.Serialization;
|
|
using Server.Targeting;
|
|
using CalcMoves = Server.Movement.Movement;
|
|
|
|
namespace Server.Items;
|
|
|
|
[SerializationGenerator(0, false)]
|
|
public abstract partial class BaseDoor : Item, ILockable, ITelekinesisable
|
|
{
|
|
private static readonly Point3D[] m_Offsets =
|
|
{
|
|
new(-1, 1, 0),
|
|
new(1, 1, 0),
|
|
new(-1, 0, 0),
|
|
new(1, -1, 0),
|
|
new(1, 1, 0),
|
|
new(1, -1, 0),
|
|
new(0, 0, 0),
|
|
new(0, -1, 0),
|
|
|
|
new(0, 0, 0),
|
|
new(0, 0, 0),
|
|
new(0, 0, 0),
|
|
new(0, 0, 0)
|
|
};
|
|
|
|
private Timer _timer;
|
|
|
|
[SerializableField(0)]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private uint _keyValue;
|
|
|
|
[SerializableField(2)]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private bool _locked;
|
|
|
|
[SerializableField(3)]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private int _openedId;
|
|
|
|
[SerializableField(4)]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private int _closedId;
|
|
|
|
[SerializableField(5)]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private int _openedSound;
|
|
|
|
[SerializableField(6)]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private int _closedSound;
|
|
|
|
[SerializableField(7)]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private Point3D _offset;
|
|
|
|
[SerializableField(8)]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private BaseDoor _link;
|
|
|
|
public BaseDoor(int closedID, int openedID, int openedSound, int closedSound, Point3D offset) : base(closedID)
|
|
{
|
|
_openedId = openedID;
|
|
_closedId = closedID;
|
|
_openedSound = openedSound;
|
|
_closedSound = closedSound;
|
|
_offset = offset;
|
|
|
|
_timer = new InternalTimer(this);
|
|
|
|
Movable = false;
|
|
}
|
|
|
|
[SerializableField(1, fieldChanged: nameof(OnOpenChanged))]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private bool _open;
|
|
|
|
private void OnOpenChanged(bool oldValue, bool newValue)
|
|
{
|
|
ItemID = _open ? _openedId : _closedId;
|
|
if (_open)
|
|
{
|
|
Location = new Point3D(X + _offset.X, Y + _offset.Y, Z + _offset.Z);
|
|
}
|
|
else
|
|
{
|
|
Location = new Point3D(X - _offset.X, Y - _offset.Y, Z - _offset.Z);
|
|
}
|
|
Effects.PlaySound(this, _open ? OpenedSound : ClosedSound);
|
|
if (_open)
|
|
{
|
|
_timer ??= new InternalTimer(this);
|
|
_timer.Start();
|
|
}
|
|
else
|
|
{
|
|
_timer.Stop();
|
|
_timer = null;
|
|
}
|
|
}
|
|
|
|
public virtual bool UseChainedFunctionality => false;
|
|
|
|
public void OnTelekinesis(Mobile from)
|
|
{
|
|
Effects.SendLocationParticles(EffectItem.Create(Location, Map, EffectItem.DefaultDuration), 0x376A, 9, 32, 5022);
|
|
Effects.PlaySound(Location, Map, 0x1F5);
|
|
|
|
Use(from);
|
|
}
|
|
|
|
public static void Configure()
|
|
{
|
|
CommandSystem.Register("Link", AccessLevel.GameMaster, Link_OnCommand);
|
|
CommandSystem.Register("ChainLink", AccessLevel.GameMaster, ChainLink_OnCommand);
|
|
}
|
|
|
|
[Usage("Link"), Description("Links two targeted doors together.")]
|
|
private static void Link_OnCommand(CommandEventArgs e)
|
|
{
|
|
e.Mobile.BeginTarget(-1, false, TargetFlags.None, Link_OnFirstTarget);
|
|
e.Mobile.SendMessage("Target the first door to link.");
|
|
}
|
|
|
|
private static void Link_OnFirstTarget(Mobile from, object targeted)
|
|
{
|
|
if (targeted is not BaseDoor door)
|
|
{
|
|
from.BeginTarget(-1, false, TargetFlags.None, Link_OnFirstTarget);
|
|
from.SendMessage("That is not a door. Try again.");
|
|
}
|
|
else
|
|
{
|
|
from.BeginTarget(-1, false, TargetFlags.None, Link_OnSecondTarget, door);
|
|
from.SendMessage("Target the second door to link.");
|
|
}
|
|
}
|
|
|
|
private static void Link_OnSecondTarget(Mobile from, object targeted, BaseDoor first)
|
|
{
|
|
if (targeted is not BaseDoor second)
|
|
{
|
|
from.BeginTarget(-1, false, TargetFlags.None, Link_OnSecondTarget, first);
|
|
from.SendMessage("That is not a door. Try again.");
|
|
}
|
|
else
|
|
{
|
|
first.Link = second;
|
|
second.Link = first;
|
|
from.SendMessage("The doors have been linked.");
|
|
}
|
|
}
|
|
|
|
[Usage("ChainLink"), Description("Chain-links two or more targeted doors together.")]
|
|
private static void ChainLink_OnCommand(CommandEventArgs e)
|
|
{
|
|
e.Mobile.BeginTarget(-1, false, TargetFlags.None, ChainLink_OnTarget, new List<BaseDoor>());
|
|
e.Mobile.SendMessage("Target the first of a sequence of doors to link.");
|
|
}
|
|
|
|
private static void ChainLink_OnTarget(Mobile from, object targeted, List<BaseDoor> list)
|
|
{
|
|
if (targeted is not BaseDoor door)
|
|
{
|
|
from.BeginTarget(-1, false, TargetFlags.None, ChainLink_OnTarget, list);
|
|
from.SendMessage("That is not a door. Try again.");
|
|
}
|
|
else
|
|
{
|
|
if (list.Count > 0 && list[0] == door)
|
|
{
|
|
if (list.Count >= 2)
|
|
{
|
|
for (var i = 0; i < list.Count; ++i)
|
|
{
|
|
list[i].Link = list[(i + 1) % list.Count];
|
|
}
|
|
|
|
from.SendMessage("The chain of doors have been linked.");
|
|
}
|
|
else
|
|
{
|
|
from.BeginTarget(-1, false, TargetFlags.None, ChainLink_OnTarget, list);
|
|
from.SendMessage("You have not yet targeted two unique doors. Target the second door to link.");
|
|
}
|
|
}
|
|
else if (list.Contains(door))
|
|
{
|
|
from.BeginTarget(-1, false, TargetFlags.None, ChainLink_OnTarget, list);
|
|
from.SendMessage(
|
|
"You have already targeted that door. Target another door, or retarget the first door to complete the chain."
|
|
);
|
|
}
|
|
else
|
|
{
|
|
list.Add(door);
|
|
|
|
from.BeginTarget(-1, false, TargetFlags.None, ChainLink_OnTarget, list);
|
|
|
|
if (list.Count == 1)
|
|
{
|
|
from.SendMessage("Target the second door to link.");
|
|
}
|
|
else
|
|
{
|
|
from.SendMessage("Target another door to link. To complete the chain, retarget the first door.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public static void OpenDoorMacroUsed(Mobile m)
|
|
{
|
|
if (m.Map == null || !m.CheckAlive())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var x = m.X;
|
|
var y = m.Y;
|
|
|
|
CalcMoves.Offset(m.Direction, ref x, ref y);
|
|
|
|
foreach (var item in m.Map.GetItemsAt(x, y))
|
|
{
|
|
if (item.Z + item.ItemData.Height > m.Z &&
|
|
m.Z + 16 > item.Z && item is BaseDoor && m.CanSee(item) && m.InLOS(item))
|
|
{
|
|
m.SendLocalizedMessage(500024); // Opening door...
|
|
item.OnDoubleClick(m);
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
public static Point3D GetOffset(DoorFacing facing) => m_Offsets[(int)facing];
|
|
|
|
public bool CanClose()
|
|
{
|
|
if (!_open)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var map = Map;
|
|
|
|
if (map == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var p = new Point3D(X - Offset.X, Y - Offset.Y, Z - Offset.Z);
|
|
|
|
return CheckFit(map, p, 16);
|
|
}
|
|
|
|
private static bool CheckFit(Map map, Point3D p, int height)
|
|
{
|
|
if (map == Map.Internal)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var x = p.X;
|
|
var y = p.Y;
|
|
var z = p.Z;
|
|
|
|
foreach (var item in map.GetItemsAt(x, y))
|
|
{
|
|
if (item.ItemID <= TileData.MaxItemValue && item is not (BaseMulti or BaseDoor))
|
|
{
|
|
var id = item.ItemData;
|
|
var surface = id.Surface;
|
|
var impassable = id.Impassable;
|
|
|
|
if ((surface || impassable) && item.Z + id.CalcHeight > z && z + height > item.Z)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var m in map.GetMobilesAt(x, y))
|
|
{
|
|
// At the same location, not hidden, or is a player, alive, and within z-bounds - then cannot fit
|
|
if (m.Location.X == x && m.Location.Y == y &&
|
|
(!m.Hidden || m.AccessLevel == AccessLevel.Player) && m.Alive && m.Z + 16 > z && z + height > m.Z)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public ChainEnumerable GetChain() => new(this);
|
|
|
|
public bool IsFreeToClose()
|
|
{
|
|
if (!UseChainedFunctionality)
|
|
{
|
|
return CanClose();
|
|
}
|
|
|
|
foreach (var link in GetChain())
|
|
{
|
|
if (!link.CanClose())
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public virtual bool IsInside(Mobile from) => false;
|
|
|
|
public virtual bool UseLocks() => true;
|
|
|
|
public virtual void Use(Mobile from)
|
|
{
|
|
if (Locked && !_open && UseLocks())
|
|
{
|
|
if (from.AccessLevel >= AccessLevel.GameMaster)
|
|
{
|
|
// That is locked, but you open it with your godly powers.
|
|
from.LocalOverheadMessage(MessageType.Regular, 0x3B2, 502502);
|
|
}
|
|
else if (Key.ContainsKey(from.Backpack, KeyValue))
|
|
{
|
|
// You quickly unlock, open, and relock the door
|
|
from.LocalOverheadMessage(MessageType.Regular, 0x3B2, 501282);
|
|
}
|
|
else if (IsInside(from))
|
|
{
|
|
// That is locked, but is usable from the inside.
|
|
from.LocalOverheadMessage(MessageType.Regular, 0x3B2, 501280);
|
|
}
|
|
else
|
|
{
|
|
if (Hue == 0x44E && Map == Map.Malas) // doom door into healer room in doom
|
|
{
|
|
SendLocalizedMessageTo(from, 1060014); // Only the dead may pass.
|
|
}
|
|
else
|
|
{
|
|
from.LocalOverheadMessage(MessageType.Regular, 0x3B2, 502503); // That is locked.
|
|
}
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (_open && !IsFreeToClose())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_open)
|
|
{
|
|
OnClosed(from);
|
|
}
|
|
else
|
|
{
|
|
OnOpened(from);
|
|
}
|
|
|
|
if (UseChainedFunctionality)
|
|
{
|
|
var open = !_open;
|
|
|
|
foreach (var link in GetChain())
|
|
{
|
|
link.Open = open;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Open = !_open;
|
|
|
|
var link = Link;
|
|
|
|
if (_open && link is { Deleted: false, Open: false })
|
|
{
|
|
link.Open = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
public virtual void OnOpened(Mobile from)
|
|
{
|
|
}
|
|
|
|
public virtual void OnClosed(Mobile from)
|
|
{
|
|
}
|
|
|
|
public override void OnDoubleClick(Mobile from)
|
|
{
|
|
if (from.AccessLevel == AccessLevel.Player && !from.InRange(GetWorldLocation(), 2))
|
|
{
|
|
from.LocalOverheadMessage(MessageType.Regular, 0x3B2, 1019045); // I can't reach that.
|
|
}
|
|
else
|
|
{
|
|
Use(from);
|
|
}
|
|
}
|
|
|
|
public override void OnDelete()
|
|
{
|
|
_timer?.Stop();
|
|
_timer = null;
|
|
Link = null;
|
|
}
|
|
|
|
[AfterDeserialization]
|
|
private void AfterDeserialization()
|
|
{
|
|
if (_open)
|
|
{
|
|
_timer = new InternalTimer(this);
|
|
_timer.Start();
|
|
}
|
|
}
|
|
|
|
private class InternalTimer : Timer
|
|
{
|
|
private readonly BaseDoor _door;
|
|
|
|
public InternalTimer(BaseDoor door) : base(TimeSpan.FromSeconds(20.0), TimeSpan.FromSeconds(10.0)) =>
|
|
_door = door;
|
|
|
|
protected override void OnTick()
|
|
{
|
|
if (_door.Deleted)
|
|
{
|
|
Stop();
|
|
return;
|
|
}
|
|
|
|
if (_door.Open && _door.IsFreeToClose())
|
|
{
|
|
_door.Open = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
public ref struct ChainEnumerable
|
|
{
|
|
private readonly BaseDoor _door;
|
|
|
|
public ChainEnumerable(BaseDoor door) => _door = door;
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public ChainEnumerator GetEnumerator() => new(_door);
|
|
}
|
|
|
|
public ref struct ChainEnumerator
|
|
{
|
|
private BaseDoor _door;
|
|
private BaseDoor _current;
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
internal ChainEnumerator(BaseDoor door)
|
|
{
|
|
_door = door;
|
|
_current = null;
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public bool MoveNext()
|
|
{
|
|
bool valid;
|
|
|
|
if (_current == null)
|
|
{
|
|
_current = _door;
|
|
valid = _current != null;
|
|
}
|
|
else
|
|
{
|
|
var next = _current.Link;
|
|
valid = next?.Deleted == false && !IsVisited(next);
|
|
_current = next;
|
|
}
|
|
|
|
if (!valid)
|
|
{
|
|
_door = null;
|
|
_current = null;
|
|
}
|
|
|
|
return valid;
|
|
}
|
|
|
|
// Links are set pairwise by GMs, so a chain can loop back to a door other than the start
|
|
private readonly bool IsVisited(BaseDoor door)
|
|
{
|
|
var visited = _door;
|
|
|
|
while (true)
|
|
{
|
|
if (visited == door)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (visited == _current || visited == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
visited = visited.Link;
|
|
}
|
|
}
|
|
|
|
public BaseDoor Current
|
|
{
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
get => _current;
|
|
}
|
|
}
|
|
}
|