fix(interaction): bind selection to live incarnations
Carry local WorldEntity identity through render hits, lighting pulses, and deferred movement actions so GUID reuse cannot target a replacement. Reset all session-owned selection and ItemHolder state and prevent combat auto-target during teardown.
This commit is contained in:
parent
c271383714
commit
047a4c83b5
19 changed files with 374 additions and 42 deletions
|
|
@ -102,7 +102,7 @@ ownership mutation.
|
||||||
|
|
||||||
## #230 — Selection hits and deferred actions can cross live GUID incarnations
|
## #230 — Selection hits and deferred actions can cross live GUID incarnations
|
||||||
|
|
||||||
**Status:** IN-PROGRESS — isolated as Slice 1 prerequisite hardening
|
**Status:** FIXED (2026-07-21) — incarnation identity is carried and revalidated
|
||||||
**Severity:** HIGH
|
**Severity:** HIGH
|
||||||
**Filed:** 2026-07-21
|
**Filed:** 2026-07-21
|
||||||
**Component:** selection / live entity lifetime / interaction
|
**Component:** selection / live entity lifetime / interaction
|
||||||
|
|
@ -134,6 +134,13 @@ old click lighting never colors a replacement; natural MoveTo completion sends
|
||||||
only for the captured current visible incarnation; session reset clears the
|
only for the captured current visible incarnation; session reset clears the
|
||||||
complete interaction lifetime and never auto-targets.
|
complete interaction lifetime and never auto-targets.
|
||||||
|
|
||||||
|
**Resolution:** Render parts/hits and SmartBox lighting now carry the local
|
||||||
|
`WorldEntity.Id`; `LiveEntityRuntime` revalidates that identity against the
|
||||||
|
current interaction-visible record. Deferred actions capture the same identity
|
||||||
|
and teardown clears the captured action even after GUID replacement. Session
|
||||||
|
reset now clears published selection geometry, lighting, ItemHolder target and
|
||||||
|
throttle state, and cannot trigger combat auto-target acquisition.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## #228 — Clean Release build emits 17 test-project warnings
|
## #228 — Clean Release build emits 17 test-project warnings
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ public sealed class CombatTargetController : IDisposable
|
||||||
private void OnSelectionChanged(SelectionTransition transition)
|
private void OnSelectionChanged(SelectionTransition transition)
|
||||||
{
|
{
|
||||||
if (transition.SelectedObjectId is not null
|
if (transition.SelectedObjectId is not null
|
||||||
|
|| transition.Reason == SelectionChangeReason.SessionReset
|
||||||
|| !_autoTarget()
|
|| !_autoTarget()
|
||||||
|| !CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode))
|
|| !CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode))
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1168,6 +1168,7 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
private readonly record struct PendingPostArrivalAction(
|
private readonly record struct PendingPostArrivalAction(
|
||||||
uint Guid,
|
uint Guid,
|
||||||
|
uint LocalEntityId,
|
||||||
bool IsPickup,
|
bool IsPickup,
|
||||||
uint DestinationContainerId = 0u,
|
uint DestinationContainerId = 0u,
|
||||||
int Placement = 0);
|
int Placement = 0);
|
||||||
|
|
@ -2982,10 +2983,11 @@ public sealed class GameWindow : IDisposable
|
||||||
Objects.Clear();
|
Objects.Clear();
|
||||||
SpellBook.Clear();
|
SpellBook.Clear();
|
||||||
_magicRuntime?.Reset();
|
_magicRuntime?.Reset();
|
||||||
_itemInteractionController?.ClearBusy();
|
_itemInteractionController?.ResetSession();
|
||||||
_selection.Reset();
|
_selection.Reset();
|
||||||
_outboundInteractions.Clear();
|
_outboundInteractions.Clear();
|
||||||
_pendingPostArrivalAction = null;
|
_pendingPostArrivalAction = null;
|
||||||
|
_retailSelectionScene?.Reset();
|
||||||
_particleVisibility.Reset();
|
_particleVisibility.Reset();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -5490,14 +5492,15 @@ public sealed class GameWindow : IDisposable
|
||||||
() => _liveEntityPresentation?.Forget(record),
|
() => _liveEntityPresentation?.Forget(record),
|
||||||
() => _entityEffects?.OnLiveEntityUnregistered(record),
|
() => _entityEffects?.OnLiveEntityUnregistered(record),
|
||||||
() => _remoteTeleportController?.Forget(record),
|
() => _remoteTeleportController?.Forget(record),
|
||||||
() => incarnation.RunIfNoReplacement(() =>
|
() =>
|
||||||
{
|
{
|
||||||
if (_pendingPostArrivalAction is { Guid: var pendingGuid }
|
if (_pendingPostArrivalAction is { Guid: var pendingGuid }
|
||||||
&& pendingGuid == serverGuid)
|
&& pendingGuid == serverGuid
|
||||||
|
&& _pendingPostArrivalAction.Value.LocalEntityId == record.LocalEntityId)
|
||||||
{
|
{
|
||||||
_pendingPostArrivalAction = null;
|
_pendingPostArrivalAction = null;
|
||||||
}
|
}
|
||||||
}),
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (record.WorldEntity is not { } existingEntity)
|
if (record.WorldEntity is not { } existingEntity)
|
||||||
|
|
@ -13049,7 +13052,7 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
uint target = PickWorldGuidAt(x, y, includeSelf: true) ?? 0u;
|
uint target = PickWorldGuidAt(x, y, includeSelf: true) ?? 0u;
|
||||||
if (target != 0u)
|
if (target != 0u)
|
||||||
_retailSelectionScene?.BeginLightingPulse(target);
|
BeginSelectionLightingPulse(target);
|
||||||
_itemInteractionController?.PlaceIn3D(itemPayload, target);
|
_itemInteractionController?.PlaceIn3D(itemPayload, target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -13765,16 +13768,40 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf)
|
private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf)
|
||||||
{
|
{
|
||||||
if (_retailSelectionScene is null || _window is null)
|
if (_retailSelectionScene is null
|
||||||
|
|| _liveEntities is null
|
||||||
|
|| _window is null)
|
||||||
return null;
|
return null;
|
||||||
var camera = GetSelectionCamera();
|
var camera = GetSelectionCamera();
|
||||||
return _retailSelectionScene.Pick(
|
AcDream.Core.Selection.RetailSelectionHit? hit = _retailSelectionScene.Pick(
|
||||||
mouseX,
|
mouseX,
|
||||||
mouseY,
|
mouseY,
|
||||||
camera.Viewport,
|
camera.Viewport,
|
||||||
camera.View,
|
camera.View,
|
||||||
camera.Projection,
|
camera.Projection,
|
||||||
includeSelf ? 0u : _playerServerGuid);
|
includeSelf ? 0u : _playerServerGuid);
|
||||||
|
if (hit is not { } found
|
||||||
|
|| !_liveEntities.TryGetInteractionEligibleRecord(
|
||||||
|
found.ServerGuid,
|
||||||
|
found.LocalEntityId,
|
||||||
|
out _))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return found.ServerGuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BeginSelectionLightingPulse(uint serverGuid)
|
||||||
|
{
|
||||||
|
if (_retailSelectionScene is null
|
||||||
|
|| _liveEntities?.TryGetRecord(serverGuid, out LiveEntityRecord record) != true
|
||||||
|
|| record.LocalEntityId is not { } localEntityId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_retailSelectionScene.BeginLightingPulse(serverGuid, localEntityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PickAndStoreSelection(bool useImmediately)
|
private void PickAndStoreSelection(bool useImmediately)
|
||||||
|
|
@ -13791,7 +13818,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// Retail UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound
|
// Retail UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound
|
||||||
// @ 0x004E5AD0 pulses every successfully found click before it
|
// @ 0x004E5AD0 pulses every successfully found click before it
|
||||||
// branches into select/use/targeted-use behavior.
|
// branches into select/use/targeted-use behavior.
|
||||||
_retailSelectionScene?.BeginLightingPulse(guid);
|
BeginSelectionLightingPulse(guid);
|
||||||
|
|
||||||
if (_itemInteractionController?.OfferPrimaryClick(guid)
|
if (_itemInteractionController?.OfferPrimaryClick(guid)
|
||||||
is not null and not AcDream.App.UI.ItemPrimaryClickResult.NotActive)
|
is not null and not AcDream.App.UI.ItemPrimaryClickResult.NotActive)
|
||||||
|
|
@ -13890,6 +13917,13 @@ public sealed class GameWindow : IDisposable
|
||||||
// Close-range deferral fires the wire packet ONCE on
|
// Close-range deferral fires the wire packet ONCE on
|
||||||
// MoveToComplete(None) (turn-first done), not a retry of an
|
// MoveToComplete(None) (turn-first done), not a retry of an
|
||||||
// earlier failed send. No re-send path.
|
// earlier failed send. No re-send path.
|
||||||
|
if (_liveEntities?.TryGetInteractionEligibleRecord(guid, out LiveEntityRecord useTarget)
|
||||||
|
!= true
|
||||||
|
|| useTarget.LocalEntityId is not { } useTargetLocalId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
bool closeRange = IsCloseRangeTarget(guid);
|
bool closeRange = IsCloseRangeTarget(guid);
|
||||||
InstallSpeculativeTurnToTarget(guid);
|
InstallSpeculativeTurnToTarget(guid);
|
||||||
|
|
||||||
|
|
@ -13897,7 +13931,10 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
// Defer the wire packet — OnAutoWalkArrivedSendDeferredAction
|
// Defer the wire packet — OnAutoWalkArrivedSendDeferredAction
|
||||||
// will fire it after rotation completes.
|
// will fire it after rotation completes.
|
||||||
_pendingPostArrivalAction = new PendingPostArrivalAction(guid, IsPickup: false);
|
_pendingPostArrivalAction = new PendingPostArrivalAction(
|
||||||
|
guid,
|
||||||
|
useTargetLocalId,
|
||||||
|
IsPickup: false);
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||||
Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}");
|
Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}");
|
||||||
return;
|
return;
|
||||||
|
|
@ -13979,6 +14016,14 @@ public sealed class GameWindow : IDisposable
|
||||||
// overlay reports arrival.
|
// overlay reports arrival.
|
||||||
//
|
//
|
||||||
// 2026-05-16: simplified — FIRST send on arrival, not a retry.
|
// 2026-05-16: simplified — FIRST send on arrival, not a retry.
|
||||||
|
if (_liveEntities?.TryGetInteractionEligibleRecord(
|
||||||
|
itemGuid,
|
||||||
|
out LiveEntityRecord pickupTarget) != true
|
||||||
|
|| pickupTarget.LocalEntityId is not { } pickupTargetLocalId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
bool closeRange = IsCloseRangeTarget(itemGuid);
|
bool closeRange = IsCloseRangeTarget(itemGuid);
|
||||||
InstallSpeculativeTurnToTarget(itemGuid);
|
InstallSpeculativeTurnToTarget(itemGuid);
|
||||||
|
|
||||||
|
|
@ -13986,6 +14031,7 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
_pendingPostArrivalAction = new PendingPostArrivalAction(
|
_pendingPostArrivalAction = new PendingPostArrivalAction(
|
||||||
itemGuid,
|
itemGuid,
|
||||||
|
pickupTargetLocalId,
|
||||||
IsPickup: true,
|
IsPickup: true,
|
||||||
destinationContainerId,
|
destinationContainerId,
|
||||||
placement);
|
placement);
|
||||||
|
|
@ -14031,6 +14077,13 @@ public sealed class GameWindow : IDisposable
|
||||||
if (_liveSession is null
|
if (_liveSession is null
|
||||||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||||||
return;
|
return;
|
||||||
|
if (_liveEntities?.TryGetInteractionEligibleRecord(
|
||||||
|
pending.Guid,
|
||||||
|
pending.LocalEntityId,
|
||||||
|
out _) != true)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var seq = _liveSession.NextGameActionSequence();
|
var seq = _liveSession.NextGameActionSequence();
|
||||||
if (pending.IsPickup)
|
if (pending.IsPickup)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
using AcDream.Core.Selection;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering.Selection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Supplies the immutable DAT geometry used by retail's render-coupled picker.
|
||||||
|
/// The seam keeps frame publication and incarnation tests independent of DAT IO.
|
||||||
|
/// </summary>
|
||||||
|
internal interface IRetailSelectionGeometrySource
|
||||||
|
{
|
||||||
|
RetailSelectionMesh? Resolve(uint gfxObjId);
|
||||||
|
}
|
||||||
|
|
@ -13,5 +13,8 @@ internal interface IRetailSelectionLightingSource
|
||||||
/// Returns the current CMaterial luminosity/diffuse replacement for an
|
/// Returns the current CMaterial luminosity/diffuse replacement for an
|
||||||
/// object, or false when its authored material values must be restored.
|
/// object, or false when its authored material values must be restored.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
bool TryGetLighting(uint serverGuid, out RetailSelectionLighting lighting);
|
bool TryGetLighting(
|
||||||
|
uint serverGuid,
|
||||||
|
uint localEntityId,
|
||||||
|
out RetailSelectionLighting lighting);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ namespace AcDream.App.Rendering.Selection;
|
||||||
/// Decodes the exact CPU geometry consumed by retail mouse selection. The broad
|
/// Decodes the exact CPU geometry consumed by retail mouse selection. The broad
|
||||||
/// sphere comes from the drawing-BSP root; polygons retain GfxObj DAT order.
|
/// sphere comes from the drawing-BSP root; polygons retain GfxObj DAT order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class RetailSelectionGeometryCache
|
internal sealed class RetailSelectionGeometryCache : IRetailSelectionGeometrySource
|
||||||
{
|
{
|
||||||
private readonly IDatReaderWriter _dats;
|
private readonly IDatReaderWriter _dats;
|
||||||
private readonly object _datLock;
|
private readonly object _datLock;
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ internal sealed class RetailSelectionLightingPulse
|
||||||
|
|
||||||
private readonly Func<double> _now;
|
private readonly Func<double> _now;
|
||||||
private uint _serverGuid;
|
private uint _serverGuid;
|
||||||
|
private uint _localEntityId;
|
||||||
private int _flipCount;
|
private int _flipCount;
|
||||||
private double _nextFlip;
|
private double _nextFlip;
|
||||||
private RetailSelectionLighting _lighting;
|
private RetailSelectionLighting _lighting;
|
||||||
|
|
@ -34,15 +35,16 @@ internal sealed class RetailSelectionLightingPulse
|
||||||
public RetailSelectionLightingPulse(Func<double>? now = null)
|
public RetailSelectionLightingPulse(Func<double>? now = null)
|
||||||
=> _now = now ?? MonotonicSeconds;
|
=> _now = now ?? MonotonicSeconds;
|
||||||
|
|
||||||
public void Start(uint serverGuid)
|
public void Start(uint serverGuid, uint localEntityId)
|
||||||
{
|
{
|
||||||
if (serverGuid == 0u)
|
if (serverGuid == 0u || localEntityId == 0u)
|
||||||
{
|
{
|
||||||
Clear();
|
Clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_serverGuid = serverGuid;
|
_serverGuid = serverGuid;
|
||||||
|
_localEntityId = localEntityId;
|
||||||
_flipCount = 1;
|
_flipCount = 1;
|
||||||
_lighting = RetailSelectionLighting.High;
|
_lighting = RetailSelectionLighting.High;
|
||||||
_nextFlip = _now() + FlipIntervalSeconds;
|
_nextFlip = _now() + FlipIntervalSeconds;
|
||||||
|
|
@ -76,9 +78,16 @@ internal sealed class RetailSelectionLightingPulse
|
||||||
_nextFlip = now + FlipIntervalSeconds;
|
_nextFlip = now + FlipIntervalSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TryGet(uint serverGuid, out RetailSelectionLighting lighting)
|
public bool TryGet(
|
||||||
|
uint serverGuid,
|
||||||
|
uint localEntityId,
|
||||||
|
out RetailSelectionLighting lighting)
|
||||||
{
|
{
|
||||||
if (_flipCount != 0 && serverGuid != 0u && serverGuid == _serverGuid)
|
if (_flipCount != 0
|
||||||
|
&& serverGuid != 0u
|
||||||
|
&& localEntityId != 0u
|
||||||
|
&& serverGuid == _serverGuid
|
||||||
|
&& localEntityId == _localEntityId)
|
||||||
{
|
{
|
||||||
lighting = _lighting;
|
lighting = _lighting;
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -91,6 +100,7 @@ internal sealed class RetailSelectionLightingPulse
|
||||||
public void Clear()
|
public void Clear()
|
||||||
{
|
{
|
||||||
_serverGuid = 0u;
|
_serverGuid = 0u;
|
||||||
|
_localEntityId = 0u;
|
||||||
_flipCount = 0;
|
_flipCount = 0;
|
||||||
_nextFlip = 0d;
|
_nextFlip = 0d;
|
||||||
_lighting = default;
|
_lighting = default;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ namespace AcDream.App.Rendering.Selection;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetailSelectionLightingSource
|
internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetailSelectionLightingSource
|
||||||
{
|
{
|
||||||
private readonly RetailSelectionGeometryCache _geometry;
|
private readonly IRetailSelectionGeometrySource _geometry;
|
||||||
private readonly RetailSelectionLightingPulse _lightingPulse;
|
private readonly RetailSelectionLightingPulse _lightingPulse;
|
||||||
private List<RetailSelectionPart> _building = new();
|
private List<RetailSelectionPart> _building = new();
|
||||||
private List<RetailSelectionPart> _published = new();
|
private List<RetailSelectionPart> _published = new();
|
||||||
|
|
@ -21,7 +21,7 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
|
||||||
private readonly record struct PartKey(uint LocalEntityId, int PartIndex, uint GfxObjId);
|
private readonly record struct PartKey(uint LocalEntityId, int PartIndex, uint GfxObjId);
|
||||||
|
|
||||||
public RetailSelectionScene(
|
public RetailSelectionScene(
|
||||||
RetailSelectionGeometryCache geometry,
|
IRetailSelectionGeometrySource geometry,
|
||||||
RetailSelectionLightingPulse? lightingPulse = null)
|
RetailSelectionLightingPulse? lightingPulse = null)
|
||||||
{
|
{
|
||||||
_geometry = geometry ?? throw new ArgumentNullException(nameof(geometry));
|
_geometry = geometry ?? throw new ArgumentNullException(nameof(geometry));
|
||||||
|
|
@ -32,14 +32,27 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
|
||||||
/// Starts retail's SmartBox click pulse independently of persistent target
|
/// Starts retail's SmartBox click pulse independently of persistent target
|
||||||
/// selection. Examine, use, and targeted-use clicks receive the same pulse.
|
/// selection. Examine, use, and targeted-use clicks receive the same pulse.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void BeginLightingPulse(uint serverGuid)
|
public void BeginLightingPulse(uint serverGuid, uint localEntityId)
|
||||||
=> _lightingPulse.Start(serverGuid);
|
=> _lightingPulse.Start(serverGuid, localEntityId);
|
||||||
|
|
||||||
public void TickLighting()
|
public void TickLighting()
|
||||||
=> _lightingPulse.Tick();
|
=> _lightingPulse.Tick();
|
||||||
|
|
||||||
public bool TryGetLighting(uint serverGuid, out RetailSelectionLighting lighting)
|
public bool TryGetLighting(
|
||||||
=> _lightingPulse.TryGet(serverGuid, out lighting);
|
uint serverGuid,
|
||||||
|
uint localEntityId,
|
||||||
|
out RetailSelectionLighting lighting)
|
||||||
|
=> _lightingPulse.TryGet(serverGuid, localEntityId, out lighting);
|
||||||
|
|
||||||
|
/// <summary>Clears every session-owned frame and material pulse.</summary>
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
_building.Clear();
|
||||||
|
_published.Clear();
|
||||||
|
_buildingKeys.Clear();
|
||||||
|
_viewFrustum = null;
|
||||||
|
_lightingPulse.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
public void BeginFrame()
|
public void BeginFrame()
|
||||||
{
|
{
|
||||||
|
|
@ -76,6 +89,7 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
|
||||||
|
|
||||||
_building.Add(new RetailSelectionPart(
|
_building.Add(new RetailSelectionPart(
|
||||||
entity.ServerGuid,
|
entity.ServerGuid,
|
||||||
|
entity.Id,
|
||||||
partIndex,
|
partIndex,
|
||||||
partWorld,
|
partWorld,
|
||||||
mesh));
|
mesh));
|
||||||
|
|
@ -86,7 +100,7 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
|
||||||
(_published, _building) = (_building, _published);
|
(_published, _building) = (_building, _published);
|
||||||
}
|
}
|
||||||
|
|
||||||
public uint? Pick(
|
public RetailSelectionHit? Pick(
|
||||||
float mouseX,
|
float mouseX,
|
||||||
float mouseY,
|
float mouseY,
|
||||||
Vector2 viewport,
|
Vector2 viewport,
|
||||||
|
|
@ -99,7 +113,7 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
|
||||||
var ray = WorldPicker.BuildRay(
|
var ray = WorldPicker.BuildRay(
|
||||||
mouseX, mouseY, viewport.X, viewport.Y, view, projection);
|
mouseX, mouseY, viewport.X, viewport.Y, view, projection);
|
||||||
return RetailWorldPicker.Pick(
|
return RetailWorldPicker.Pick(
|
||||||
ray.Origin, ray.Direction, _published, skipServerGuid)?.ServerGuid;
|
ray.Origin, ray.Direction, _published, skipServerGuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static bool DrawingSphereIntersectsFrustum(
|
internal static bool DrawingSphereIntersectsFrustum(
|
||||||
|
|
|
||||||
|
|
@ -1527,7 +1527,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
// bounding sphere — camera-INDEPENDENT (minimize_object_lighting).
|
// bounding sphere — camera-INDEPENDENT (minimize_object_lighting).
|
||||||
ComputeEntityLightSet(entity);
|
ComputeEntityLightSet(entity);
|
||||||
_currentEntitySelectionLighting =
|
_currentEntitySelectionLighting =
|
||||||
_selectionLighting?.TryGetLighting(entity.ServerGuid, out var lighting) == true
|
_selectionLighting?.TryGetLighting(
|
||||||
|
entity.ServerGuid,
|
||||||
|
entity.Id,
|
||||||
|
out var lighting) == true
|
||||||
? new Vector2(lighting.Luminosity, lighting.Diffuse)
|
? new Vector2(lighting.Luminosity, lighting.Diffuse)
|
||||||
: new Vector2(0f, 1f);
|
: new Vector2(0f, 1f);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -587,6 +587,24 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
StateChanged?.Invoke();
|
StateChanged?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ends all session-scoped ItemHolder state. Target modes, click
|
||||||
|
/// suppression, and use throttles must not survive a reconnect where the
|
||||||
|
/// same numeric GUID can identify a different object.
|
||||||
|
/// </summary>
|
||||||
|
public void ResetSession()
|
||||||
|
{
|
||||||
|
bool busyChanged = _busyCount != 0;
|
||||||
|
bool modeChanged = IsAnyTargetModeActive;
|
||||||
|
_busyCount = 0;
|
||||||
|
_lastUseMs = long.MinValue / 2;
|
||||||
|
_consumedPrimaryClickTarget = 0u;
|
||||||
|
_consumedPrimaryClickMs = long.MinValue / 2;
|
||||||
|
_interactionState.Clear();
|
||||||
|
if (busyChanged && !modeChanged)
|
||||||
|
StateChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
private bool ConsumeUseThrottle()
|
private bool ConsumeUseThrottle()
|
||||||
{
|
{
|
||||||
long now = _nowMs();
|
long now = _nowMs();
|
||||||
|
|
|
||||||
|
|
@ -964,6 +964,46 @@ public sealed class LiveEntityRuntime
|
||||||
out WorldEntity entity) =>
|
out WorldEntity entity) =>
|
||||||
_visibleWorldEntitiesByGuid.TryGetValue(serverGuid, out entity!);
|
_visibleWorldEntitiesByGuid.TryGetValue(serverGuid, out entity!);
|
||||||
|
|
||||||
|
public bool TryGetInteractionEligibleRecord(
|
||||||
|
uint serverGuid,
|
||||||
|
out LiveEntityRecord record)
|
||||||
|
{
|
||||||
|
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? found)
|
||||||
|
&& found.WorldEntity is { } entity
|
||||||
|
&& _visibleWorldEntitiesByGuid.TryGetValue(serverGuid, out WorldEntity? visible)
|
||||||
|
&& ReferenceEquals(entity, visible))
|
||||||
|
{
|
||||||
|
record = found;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
record = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a render-published interaction candidate only while the same
|
||||||
|
/// logical WorldEntity incarnation still owns the server GUID. A stale
|
||||||
|
/// frame must never retarget a replacement which reused that GUID.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryGetInteractionEligibleRecord(
|
||||||
|
uint serverGuid,
|
||||||
|
uint localEntityId,
|
||||||
|
out LiveEntityRecord record)
|
||||||
|
{
|
||||||
|
if (serverGuid != 0u
|
||||||
|
&& localEntityId != 0u
|
||||||
|
&& TryGetInteractionEligibleRecord(serverGuid, out LiveEntityRecord found)
|
||||||
|
&& found.WorldEntity!.Id == localEntityId)
|
||||||
|
{
|
||||||
|
record = found;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
record = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public bool ContainsWorldEntity(uint serverGuid) =>
|
public bool ContainsWorldEntity(uint serverGuid) =>
|
||||||
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||||
&& record.WorldEntity is not null;
|
&& record.WorldEntity is not null;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ public sealed record RetailSelectionPolygon(
|
||||||
/// <summary>One part which survived the normal world-render visibility traversal.</summary>
|
/// <summary>One part which survived the normal world-render visibility traversal.</summary>
|
||||||
public readonly record struct RetailSelectionPart(
|
public readonly record struct RetailSelectionPart(
|
||||||
uint ServerGuid,
|
uint ServerGuid,
|
||||||
|
uint LocalEntityId,
|
||||||
int PartIndex,
|
int PartIndex,
|
||||||
Matrix4x4 LocalToWorld,
|
Matrix4x4 LocalToWorld,
|
||||||
RetailSelectionMesh Mesh);
|
RetailSelectionMesh Mesh);
|
||||||
|
|
@ -27,6 +28,7 @@ public readonly record struct RetailSelectionPart(
|
||||||
/// <summary>Retail picker result, including which physics part supplied the hit.</summary>
|
/// <summary>Retail picker result, including which physics part supplied the hit.</summary>
|
||||||
public readonly record struct RetailSelectionHit(
|
public readonly record struct RetailSelectionHit(
|
||||||
uint ServerGuid,
|
uint ServerGuid,
|
||||||
|
uint LocalEntityId,
|
||||||
int PartIndex,
|
int PartIndex,
|
||||||
double Distance,
|
double Distance,
|
||||||
bool PolygonHit);
|
bool PolygonHit);
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ public static class RetailWorldPicker
|
||||||
|
|
||||||
if (closestSphere is null || sphereT < closestSphere.Value.Distance)
|
if (closestSphere is null || sphereT < closestSphere.Value.Distance)
|
||||||
closestSphere = new RetailSelectionHit(
|
closestSphere = new RetailSelectionHit(
|
||||||
part.ServerGuid, part.PartIndex, sphereT, PolygonHit: false);
|
part.ServerGuid, part.LocalEntityId, part.PartIndex, sphereT, PolygonHit: false);
|
||||||
|
|
||||||
// Retail stops at the FIRST hit polygon in this part's stored flat order.
|
// Retail stops at the FIRST hit polygon in this part's stored flat order.
|
||||||
foreach (var polygon in part.Mesh.Polygons)
|
foreach (var polygon in part.Mesh.Polygons)
|
||||||
|
|
@ -63,7 +63,7 @@ public static class RetailWorldPicker
|
||||||
|
|
||||||
if (closestPolygon is null || polygonT < closestPolygon.Value.Distance)
|
if (closestPolygon is null || polygonT < closestPolygon.Value.Distance)
|
||||||
closestPolygon = new RetailSelectionHit(
|
closestPolygon = new RetailSelectionHit(
|
||||||
part.ServerGuid, part.PartIndex, polygonT, PolygonHit: true);
|
part.ServerGuid, part.LocalEntityId, part.PartIndex, polygonT, PolygonHit: true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,4 +79,21 @@ public sealed class CombatTargetControllerTests
|
||||||
|
|
||||||
Assert.Equal(0x50000002u, selection.SelectedObjectId);
|
Assert.Equal(0x50000002u, selection.SelectedObjectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SessionReset_DoesNotAcquireTargetFromDepartingWorld()
|
||||||
|
{
|
||||||
|
var combat = new CombatState();
|
||||||
|
combat.SetCombatMode(CombatMode.Missile);
|
||||||
|
var selection = new SelectionState();
|
||||||
|
selection.Select(0x50000002u, SelectionChangeSource.World);
|
||||||
|
int calls = 0;
|
||||||
|
using var controller = new CombatTargetController(
|
||||||
|
combat, selection, () => true, () => { calls++; return null; });
|
||||||
|
|
||||||
|
selection.Reset();
|
||||||
|
|
||||||
|
Assert.Equal(0, calls);
|
||||||
|
Assert.Null(selection.SelectedObjectId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,28 +10,28 @@ public sealed class RetailSelectionLightingPulseTests
|
||||||
double now = 10d;
|
double now = 10d;
|
||||||
var pulse = new RetailSelectionLightingPulse(() => now);
|
var pulse = new RetailSelectionLightingPulse(() => now);
|
||||||
|
|
||||||
pulse.Start(0x5000_1234u);
|
pulse.Start(0x5000_1234u, 1234u);
|
||||||
AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.High);
|
AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.High);
|
||||||
|
|
||||||
now = 10.199;
|
now = 10.199;
|
||||||
pulse.Tick();
|
pulse.Tick();
|
||||||
AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.High);
|
AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.High);
|
||||||
|
|
||||||
now = 10.2;
|
now = 10.2;
|
||||||
pulse.Tick();
|
pulse.Tick();
|
||||||
AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.Low);
|
AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.Low);
|
||||||
|
|
||||||
now = 10.4;
|
now = 10.4;
|
||||||
pulse.Tick();
|
pulse.Tick();
|
||||||
AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.High);
|
AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.High);
|
||||||
|
|
||||||
now = 10.6;
|
now = 10.6;
|
||||||
pulse.Tick();
|
pulse.Tick();
|
||||||
AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.Low);
|
AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.Low);
|
||||||
|
|
||||||
now = 10.8;
|
now = 10.8;
|
||||||
pulse.Tick();
|
pulse.Tick();
|
||||||
Assert.False(pulse.TryGet(0x5000_1234u, out _));
|
Assert.False(pulse.TryGet(0x5000_1234u, 1234u, out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -39,12 +39,12 @@ public sealed class RetailSelectionLightingPulseTests
|
||||||
{
|
{
|
||||||
double now = 1d;
|
double now = 1d;
|
||||||
var pulse = new RetailSelectionLightingPulse(() => now);
|
var pulse = new RetailSelectionLightingPulse(() => now);
|
||||||
pulse.Start(0x5000_1234u);
|
pulse.Start(0x5000_1234u, 1234u);
|
||||||
|
|
||||||
now = 20d;
|
now = 20d;
|
||||||
pulse.Tick();
|
pulse.Tick();
|
||||||
|
|
||||||
AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.Low);
|
AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.Low);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -52,24 +52,35 @@ public sealed class RetailSelectionLightingPulseTests
|
||||||
{
|
{
|
||||||
double now = 4d;
|
double now = 4d;
|
||||||
var pulse = new RetailSelectionLightingPulse(() => now);
|
var pulse = new RetailSelectionLightingPulse(() => now);
|
||||||
pulse.Start(0x5000_0001u);
|
pulse.Start(0x5000_0001u, 1u);
|
||||||
|
|
||||||
now = 4.2;
|
now = 4.2;
|
||||||
pulse.Tick();
|
pulse.Tick();
|
||||||
AssertLighting(pulse, 0x5000_0001u, RetailSelectionLighting.Low);
|
AssertLighting(pulse, 0x5000_0001u, 1u, RetailSelectionLighting.Low);
|
||||||
|
|
||||||
pulse.Start(0x5000_0002u);
|
pulse.Start(0x5000_0002u, 2u);
|
||||||
|
|
||||||
Assert.False(pulse.TryGet(0x5000_0001u, out _));
|
Assert.False(pulse.TryGet(0x5000_0001u, 1u, out _));
|
||||||
AssertLighting(pulse, 0x5000_0002u, RetailSelectionLighting.High);
|
AssertLighting(pulse, 0x5000_0002u, 2u, RetailSelectionLighting.High);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GuidReuseDoesNotTransferPulseToReplacementIncarnation()
|
||||||
|
{
|
||||||
|
var pulse = new RetailSelectionLightingPulse(() => 1d);
|
||||||
|
pulse.Start(0x5000_0001u, 10u);
|
||||||
|
|
||||||
|
Assert.False(pulse.TryGet(0x5000_0001u, 11u, out _));
|
||||||
|
AssertLighting(pulse, 0x5000_0001u, 10u, RetailSelectionLighting.High);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AssertLighting(
|
private static void AssertLighting(
|
||||||
RetailSelectionLightingPulse pulse,
|
RetailSelectionLightingPulse pulse,
|
||||||
uint serverGuid,
|
uint serverGuid,
|
||||||
|
uint localEntityId,
|
||||||
RetailSelectionLighting expected)
|
RetailSelectionLighting expected)
|
||||||
{
|
{
|
||||||
Assert.True(pulse.TryGet(serverGuid, out var actual));
|
Assert.True(pulse.TryGet(serverGuid, localEntityId, out var actual));
|
||||||
Assert.Equal(expected, actual);
|
Assert.Equal(expected, actual);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
114
tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs
Normal file
114
tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Rendering.Selection;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
|
public sealed class RetailSelectionSceneTests
|
||||||
|
{
|
||||||
|
private sealed class GeometrySource(RetailSelectionMesh mesh)
|
||||||
|
: IRetailSelectionGeometrySource
|
||||||
|
{
|
||||||
|
public RetailSelectionMesh? Resolve(uint gfxObjId)
|
||||||
|
=> gfxObjId == 0x0100_0001u ? mesh : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PublishedHitCarriesLogicalEntityIdentity()
|
||||||
|
{
|
||||||
|
var scene = CreateScene();
|
||||||
|
var entity = Entity(localId: 44u, serverGuid: 0x5000_0044u);
|
||||||
|
Publish(scene, entity);
|
||||||
|
|
||||||
|
RetailSelectionHit? hit = PickCenter(scene);
|
||||||
|
|
||||||
|
Assert.NotNull(hit);
|
||||||
|
Assert.Equal(entity.ServerGuid, hit.Value.ServerGuid);
|
||||||
|
Assert.Equal(entity.Id, hit.Value.LocalEntityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResetClearsPublishedFrameAndLightingPulse()
|
||||||
|
{
|
||||||
|
double now = 1d;
|
||||||
|
var pulse = new RetailSelectionLightingPulse(() => now);
|
||||||
|
var scene = CreateScene(pulse);
|
||||||
|
var entity = Entity(localId: 45u, serverGuid: 0x5000_0045u);
|
||||||
|
Publish(scene, entity);
|
||||||
|
scene.BeginLightingPulse(entity.ServerGuid, entity.Id);
|
||||||
|
|
||||||
|
scene.Reset();
|
||||||
|
|
||||||
|
Assert.Null(PickCenter(scene));
|
||||||
|
Assert.False(scene.TryGetLighting(entity.ServerGuid, entity.Id, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RetailSelectionScene CreateScene(
|
||||||
|
RetailSelectionLightingPulse? pulse = null)
|
||||||
|
{
|
||||||
|
var mesh = new RetailSelectionMesh(
|
||||||
|
Vector3.Zero,
|
||||||
|
2f,
|
||||||
|
[new RetailSelectionPolygon(
|
||||||
|
[
|
||||||
|
new(-1f, -1f, 0f),
|
||||||
|
new( 1f, -1f, 0f),
|
||||||
|
new( 1f, 1f, 0f),
|
||||||
|
new(-1f, 1f, 0f),
|
||||||
|
],
|
||||||
|
SingleSided: false)]);
|
||||||
|
return new RetailSelectionScene(new GeometrySource(mesh), pulse);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorldEntity Entity(uint localId, uint serverGuid)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = localId,
|
||||||
|
ServerGuid = serverGuid,
|
||||||
|
SourceGfxObjOrSetupId = 0x0200_0001u,
|
||||||
|
Position = Vector3.Zero,
|
||||||
|
Rotation = Quaternion.Identity,
|
||||||
|
MeshRefs = [],
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void Publish(RetailSelectionScene scene, WorldEntity entity)
|
||||||
|
{
|
||||||
|
scene.BeginFrame();
|
||||||
|
(Matrix4x4 view, Matrix4x4 projection) = Camera();
|
||||||
|
scene.SetViewFrustum(FrustumPlanes.FromViewProjection(view * projection));
|
||||||
|
scene.AddVisiblePart(
|
||||||
|
entity,
|
||||||
|
partIndex: 0,
|
||||||
|
gfxObjId: 0x0100_0001u,
|
||||||
|
Matrix4x4.CreateTranslation(0f, 0f, -5f));
|
||||||
|
scene.CompleteFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RetailSelectionHit? PickCenter(RetailSelectionScene scene)
|
||||||
|
{
|
||||||
|
(Matrix4x4 view, Matrix4x4 projection) = Camera();
|
||||||
|
return scene.Pick(
|
||||||
|
400f,
|
||||||
|
300f,
|
||||||
|
new Vector2(800f, 600f),
|
||||||
|
view,
|
||||||
|
projection,
|
||||||
|
skipServerGuid: 0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Matrix4x4 View, Matrix4x4 Projection) Camera()
|
||||||
|
{
|
||||||
|
Matrix4x4 view = Matrix4x4.CreateLookAt(
|
||||||
|
Vector3.Zero,
|
||||||
|
-Vector3.UnitZ,
|
||||||
|
Vector3.UnitY);
|
||||||
|
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||||
|
MathF.PI / 3f,
|
||||||
|
4f / 3f,
|
||||||
|
0.1f,
|
||||||
|
100f);
|
||||||
|
return (view, projection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1418,4 +1418,20 @@ public sealed class ItemInteractionControllerTests
|
||||||
Assert.True(h.Controller.ActivateItem(0x50000A0Au));
|
Assert.True(h.Controller.ActivateItem(0x50000A0Au));
|
||||||
Assert.Equal(2, h.Uses.Count);
|
Assert.Equal(2, h.Uses.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResetSession_ClearsTargetBusyThrottleAndConsumedClickState()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
const uint kit = 0x50000A0Cu;
|
||||||
|
h.AddContained(kit, item => item.Useability = HealthKitUseability);
|
||||||
|
Assert.True(h.Controller.ActivateItem(kit));
|
||||||
|
h.Controller.IncrementBusyCount();
|
||||||
|
|
||||||
|
h.Controller.ResetSession();
|
||||||
|
|
||||||
|
Assert.False(h.Controller.IsAnyTargetModeActive);
|
||||||
|
Assert.Equal(0, h.Controller.BusyCount);
|
||||||
|
Assert.Equal(InteractionModeKind.None, h.Controller.InteractionState.Current.Kind);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -808,6 +808,9 @@ public sealed class LiveEntityRuntimeTests
|
||||||
guid, 0x01010001u, id => Entity(id, guid))!;
|
guid, 0x01010001u, id => Entity(id, guid))!;
|
||||||
|
|
||||||
Assert.NotEqual(first.Id, second.Id);
|
Assert.NotEqual(first.Id, second.Id);
|
||||||
|
Assert.False(runtime.TryGetInteractionEligibleRecord(guid, first.Id, out _));
|
||||||
|
Assert.True(runtime.TryGetInteractionEligibleRecord(guid, second.Id, out var current));
|
||||||
|
Assert.Same(second, current.WorldEntity);
|
||||||
Assert.Equal(2, resources.RegisterCount);
|
Assert.Equal(2, resources.RegisterCount);
|
||||||
Assert.Equal(1, resources.UnregisterCount);
|
Assert.Equal(1, resources.UnregisterCount);
|
||||||
}
|
}
|
||||||
|
|
@ -1428,6 +1431,7 @@ public sealed class LiveEntityRuntimeTests
|
||||||
Assert.False(entity.IsDrawVisible);
|
Assert.False(entity.IsDrawVisible);
|
||||||
Assert.Empty(runtime.WorldEntities);
|
Assert.Empty(runtime.WorldEntities);
|
||||||
Assert.False(runtime.TryGetInteractionEligibleEntity(guid, out _));
|
Assert.False(runtime.TryGetInteractionEligibleEntity(guid, out _));
|
||||||
|
Assert.False(runtime.TryGetInteractionEligibleRecord(guid, entity.Id, out _));
|
||||||
|
|
||||||
Assert.True(runtime.TryApplyState(new SetState.Parsed(
|
Assert.True(runtime.TryApplyState(new SetState.Parsed(
|
||||||
guid,
|
guid,
|
||||||
|
|
@ -1437,6 +1441,9 @@ public sealed class LiveEntityRuntimeTests
|
||||||
Assert.Equal(RetailHiddenTransition.BecameVisible, visible.HiddenTransition);
|
Assert.Equal(RetailHiddenTransition.BecameVisible, visible.HiddenTransition);
|
||||||
Assert.True(entity.IsDrawVisible);
|
Assert.True(entity.IsDrawVisible);
|
||||||
Assert.True(runtime.TryGetInteractionEligibleEntity(guid, out var eligible));
|
Assert.True(runtime.TryGetInteractionEligibleEntity(guid, out var eligible));
|
||||||
|
Assert.True(runtime.TryGetInteractionEligibleRecord(guid, entity.Id, out var eligibleRecord));
|
||||||
|
Assert.Same(entity, eligibleRecord.WorldEntity);
|
||||||
|
Assert.False(runtime.TryGetInteractionEligibleRecord(guid, entity.Id + 1u, out _));
|
||||||
Assert.Same(entity, eligible);
|
Assert.Same(entity, eligible);
|
||||||
Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value);
|
Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value);
|
||||||
Assert.Same(entity, Assert.Single(spatial.Entities));
|
Assert.Same(entity, Assert.Single(spatial.Entities));
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ public sealed class RetailWorldPickerTests
|
||||||
|
|
||||||
Assert.NotNull(hit);
|
Assert.NotNull(hit);
|
||||||
Assert.Equal(2u, hit.Value.ServerGuid);
|
Assert.Equal(2u, hit.Value.ServerGuid);
|
||||||
|
Assert.Equal(1002u, hit.Value.LocalEntityId);
|
||||||
Assert.True(hit.Value.PolygonHit);
|
Assert.True(hit.Value.PolygonHit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,6 +73,7 @@ public sealed class RetailWorldPickerTests
|
||||||
{
|
{
|
||||||
var front = new RetailSelectionPart(
|
var front = new RetailSelectionPart(
|
||||||
1u,
|
1u,
|
||||||
|
1001u,
|
||||||
0,
|
0,
|
||||||
Matrix4x4.Identity,
|
Matrix4x4.Identity,
|
||||||
new RetailSelectionMesh(
|
new RetailSelectionMesh(
|
||||||
|
|
@ -92,6 +94,7 @@ public sealed class RetailWorldPickerTests
|
||||||
* Matrix4x4.CreateTranslation(0f, 0f, -10f);
|
* Matrix4x4.CreateTranslation(0f, 0f, -10f);
|
||||||
var part = new RetailSelectionPart(
|
var part = new RetailSelectionPart(
|
||||||
3u,
|
3u,
|
||||||
|
1003u,
|
||||||
0,
|
0,
|
||||||
transform,
|
transform,
|
||||||
new RetailSelectionMesh(Vector3.Zero, 1f, [SquareAtLocalZ(0f)]));
|
new RetailSelectionMesh(Vector3.Zero, 1f, [SquareAtLocalZ(0f)]));
|
||||||
|
|
@ -152,6 +155,7 @@ public sealed class RetailWorldPickerTests
|
||||||
float sphereRadius = 2f)
|
float sphereRadius = 2f)
|
||||||
=> new(
|
=> new(
|
||||||
guid,
|
guid,
|
||||||
|
guid + 1000u,
|
||||||
0,
|
0,
|
||||||
Matrix4x4.CreateTranslation(0f, 0f, z),
|
Matrix4x4.CreateTranslation(0f, 0f, z),
|
||||||
new RetailSelectionMesh(
|
new RetailSelectionMesh(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue