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:
Erik 2026-07-21 07:07:58 +02:00
parent c271383714
commit 047a4c83b5
19 changed files with 374 additions and 42 deletions

View file

@ -102,7 +102,7 @@ ownership mutation.
## #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
**Filed:** 2026-07-21
**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
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

View file

@ -63,6 +63,7 @@ public sealed class CombatTargetController : IDisposable
private void OnSelectionChanged(SelectionTransition transition)
{
if (transition.SelectedObjectId is not null
|| transition.Reason == SelectionChangeReason.SessionReset
|| !_autoTarget()
|| !CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode))
return;

View file

@ -1168,6 +1168,7 @@ public sealed class GameWindow : IDisposable
private readonly record struct PendingPostArrivalAction(
uint Guid,
uint LocalEntityId,
bool IsPickup,
uint DestinationContainerId = 0u,
int Placement = 0);
@ -2982,10 +2983,11 @@ public sealed class GameWindow : IDisposable
Objects.Clear();
SpellBook.Clear();
_magicRuntime?.Reset();
_itemInteractionController?.ClearBusy();
_itemInteractionController?.ResetSession();
_selection.Reset();
_outboundInteractions.Clear();
_pendingPostArrivalAction = null;
_retailSelectionScene?.Reset();
_particleVisibility.Reset();
try
{
@ -5490,14 +5492,15 @@ public sealed class GameWindow : IDisposable
() => _liveEntityPresentation?.Forget(record),
() => _entityEffects?.OnLiveEntityUnregistered(record),
() => _remoteTeleportController?.Forget(record),
() => incarnation.RunIfNoReplacement(() =>
() =>
{
if (_pendingPostArrivalAction is { Guid: var pendingGuid }
&& pendingGuid == serverGuid)
&& pendingGuid == serverGuid
&& _pendingPostArrivalAction.Value.LocalEntityId == record.LocalEntityId)
{
_pendingPostArrivalAction = null;
}
}),
},
};
if (record.WorldEntity is not { } existingEntity)
@ -13049,7 +13052,7 @@ public sealed class GameWindow : IDisposable
{
uint target = PickWorldGuidAt(x, y, includeSelf: true) ?? 0u;
if (target != 0u)
_retailSelectionScene?.BeginLightingPulse(target);
BeginSelectionLightingPulse(target);
_itemInteractionController?.PlaceIn3D(itemPayload, target);
}
}
@ -13765,16 +13768,40 @@ public sealed class GameWindow : IDisposable
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;
var camera = GetSelectionCamera();
return _retailSelectionScene.Pick(
AcDream.Core.Selection.RetailSelectionHit? hit = _retailSelectionScene.Pick(
mouseX,
mouseY,
camera.Viewport,
camera.View,
camera.Projection,
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)
@ -13791,7 +13818,7 @@ public sealed class GameWindow : IDisposable
// Retail UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound
// @ 0x004E5AD0 pulses every successfully found click before it
// branches into select/use/targeted-use behavior.
_retailSelectionScene?.BeginLightingPulse(guid);
BeginSelectionLightingPulse(guid);
if (_itemInteractionController?.OfferPrimaryClick(guid)
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
// MoveToComplete(None) (turn-first done), not a retry of an
// 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);
InstallSpeculativeTurnToTarget(guid);
@ -13897,7 +13931,10 @@ public sealed class GameWindow : IDisposable
{
// Defer the wire packet — OnAutoWalkArrivedSendDeferredAction
// 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)
Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}");
return;
@ -13979,6 +14016,14 @@ public sealed class GameWindow : IDisposable
// overlay reports arrival.
//
// 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);
InstallSpeculativeTurnToTarget(itemGuid);
@ -13986,6 +14031,7 @@ public sealed class GameWindow : IDisposable
{
_pendingPostArrivalAction = new PendingPostArrivalAction(
itemGuid,
pickupTargetLocalId,
IsPickup: true,
destinationContainerId,
placement);
@ -14031,6 +14077,13 @@ public sealed class GameWindow : IDisposable
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
return;
if (_liveEntities?.TryGetInteractionEligibleRecord(
pending.Guid,
pending.LocalEntityId,
out _) != true)
{
return;
}
var seq = _liveSession.NextGameActionSequence();
if (pending.IsPickup)

View file

@ -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);
}

View file

@ -13,5 +13,8 @@ internal interface IRetailSelectionLightingSource
/// Returns the current CMaterial luminosity/diffuse replacement for an
/// object, or false when its authored material values must be restored.
/// </summary>
bool TryGetLighting(uint serverGuid, out RetailSelectionLighting lighting);
bool TryGetLighting(
uint serverGuid,
uint localEntityId,
out RetailSelectionLighting lighting);
}

View file

@ -10,7 +10,7 @@ namespace AcDream.App.Rendering.Selection;
/// Decodes the exact CPU geometry consumed by retail mouse selection. The broad
/// sphere comes from the drawing-BSP root; polygons retain GfxObj DAT order.
/// </summary>
internal sealed class RetailSelectionGeometryCache
internal sealed class RetailSelectionGeometryCache : IRetailSelectionGeometrySource
{
private readonly IDatReaderWriter _dats;
private readonly object _datLock;

View file

@ -27,6 +27,7 @@ internal sealed class RetailSelectionLightingPulse
private readonly Func<double> _now;
private uint _serverGuid;
private uint _localEntityId;
private int _flipCount;
private double _nextFlip;
private RetailSelectionLighting _lighting;
@ -34,15 +35,16 @@ internal sealed class RetailSelectionLightingPulse
public RetailSelectionLightingPulse(Func<double>? now = null)
=> _now = now ?? MonotonicSeconds;
public void Start(uint serverGuid)
public void Start(uint serverGuid, uint localEntityId)
{
if (serverGuid == 0u)
if (serverGuid == 0u || localEntityId == 0u)
{
Clear();
return;
}
_serverGuid = serverGuid;
_localEntityId = localEntityId;
_flipCount = 1;
_lighting = RetailSelectionLighting.High;
_nextFlip = _now() + FlipIntervalSeconds;
@ -76,9 +78,16 @@ internal sealed class RetailSelectionLightingPulse
_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;
return true;
@ -91,6 +100,7 @@ internal sealed class RetailSelectionLightingPulse
public void Clear()
{
_serverGuid = 0u;
_localEntityId = 0u;
_flipCount = 0;
_nextFlip = 0d;
_lighting = default;

View file

@ -11,7 +11,7 @@ namespace AcDream.App.Rendering.Selection;
/// </summary>
internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetailSelectionLightingSource
{
private readonly RetailSelectionGeometryCache _geometry;
private readonly IRetailSelectionGeometrySource _geometry;
private readonly RetailSelectionLightingPulse _lightingPulse;
private List<RetailSelectionPart> _building = 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);
public RetailSelectionScene(
RetailSelectionGeometryCache geometry,
IRetailSelectionGeometrySource geometry,
RetailSelectionLightingPulse? lightingPulse = null)
{
_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
/// selection. Examine, use, and targeted-use clicks receive the same pulse.
/// </summary>
public void BeginLightingPulse(uint serverGuid)
=> _lightingPulse.Start(serverGuid);
public void BeginLightingPulse(uint serverGuid, uint localEntityId)
=> _lightingPulse.Start(serverGuid, localEntityId);
public void TickLighting()
=> _lightingPulse.Tick();
public bool TryGetLighting(uint serverGuid, out RetailSelectionLighting lighting)
=> _lightingPulse.TryGet(serverGuid, out lighting);
public bool TryGetLighting(
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()
{
@ -76,6 +89,7 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
_building.Add(new RetailSelectionPart(
entity.ServerGuid,
entity.Id,
partIndex,
partWorld,
mesh));
@ -86,7 +100,7 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
(_published, _building) = (_building, _published);
}
public uint? Pick(
public RetailSelectionHit? Pick(
float mouseX,
float mouseY,
Vector2 viewport,
@ -99,7 +113,7 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
var ray = WorldPicker.BuildRay(
mouseX, mouseY, viewport.X, viewport.Y, view, projection);
return RetailWorldPicker.Pick(
ray.Origin, ray.Direction, _published, skipServerGuid)?.ServerGuid;
ray.Origin, ray.Direction, _published, skipServerGuid);
}
internal static bool DrawingSphereIntersectsFrustum(

View file

@ -1527,7 +1527,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// bounding sphere — camera-INDEPENDENT (minimize_object_lighting).
ComputeEntityLightSet(entity);
_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(0f, 1f);

View file

@ -587,6 +587,24 @@ public sealed class ItemInteractionController : IDisposable
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()
{
long now = _nowMs();

View file

@ -964,6 +964,46 @@ public sealed class LiveEntityRuntime
out WorldEntity 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) =>
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& record.WorldEntity is not null;

View file

@ -20,6 +20,7 @@ public sealed record RetailSelectionPolygon(
/// <summary>One part which survived the normal world-render visibility traversal.</summary>
public readonly record struct RetailSelectionPart(
uint ServerGuid,
uint LocalEntityId,
int PartIndex,
Matrix4x4 LocalToWorld,
RetailSelectionMesh Mesh);
@ -27,6 +28,7 @@ public readonly record struct RetailSelectionPart(
/// <summary>Retail picker result, including which physics part supplied the hit.</summary>
public readonly record struct RetailSelectionHit(
uint ServerGuid,
uint LocalEntityId,
int PartIndex,
double Distance,
bool PolygonHit);

View file

@ -53,7 +53,7 @@ public static class RetailWorldPicker
if (closestSphere is null || sphereT < closestSphere.Value.Distance)
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.
foreach (var polygon in part.Mesh.Polygons)
@ -63,7 +63,7 @@ public static class RetailWorldPicker
if (closestPolygon is null || polygonT < closestPolygon.Value.Distance)
closestPolygon = new RetailSelectionHit(
part.ServerGuid, part.PartIndex, polygonT, PolygonHit: true);
part.ServerGuid, part.LocalEntityId, part.PartIndex, polygonT, PolygonHit: true);
break;
}
}

View file

@ -79,4 +79,21 @@ public sealed class CombatTargetControllerTests
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);
}
}

View file

@ -10,28 +10,28 @@ public sealed class RetailSelectionLightingPulseTests
double now = 10d;
var pulse = new RetailSelectionLightingPulse(() => now);
pulse.Start(0x5000_1234u);
AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.High);
pulse.Start(0x5000_1234u, 1234u);
AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.High);
now = 10.199;
pulse.Tick();
AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.High);
AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.High);
now = 10.2;
pulse.Tick();
AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.Low);
AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.Low);
now = 10.4;
pulse.Tick();
AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.High);
AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.High);
now = 10.6;
pulse.Tick();
AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.Low);
AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.Low);
now = 10.8;
pulse.Tick();
Assert.False(pulse.TryGet(0x5000_1234u, out _));
Assert.False(pulse.TryGet(0x5000_1234u, 1234u, out _));
}
[Fact]
@ -39,12 +39,12 @@ public sealed class RetailSelectionLightingPulseTests
{
double now = 1d;
var pulse = new RetailSelectionLightingPulse(() => now);
pulse.Start(0x5000_1234u);
pulse.Start(0x5000_1234u, 1234u);
now = 20d;
pulse.Tick();
AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.Low);
AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.Low);
}
[Fact]
@ -52,24 +52,35 @@ public sealed class RetailSelectionLightingPulseTests
{
double now = 4d;
var pulse = new RetailSelectionLightingPulse(() => now);
pulse.Start(0x5000_0001u);
pulse.Start(0x5000_0001u, 1u);
now = 4.2;
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 _));
AssertLighting(pulse, 0x5000_0002u, RetailSelectionLighting.High);
Assert.False(pulse.TryGet(0x5000_0001u, 1u, out _));
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(
RetailSelectionLightingPulse pulse,
uint serverGuid,
uint localEntityId,
RetailSelectionLighting expected)
{
Assert.True(pulse.TryGet(serverGuid, out var actual));
Assert.True(pulse.TryGet(serverGuid, localEntityId, out var actual));
Assert.Equal(expected, actual);
}
}

View 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);
}
}

View file

@ -1418,4 +1418,20 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Controller.ActivateItem(0x50000A0Au));
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);
}
}

View file

@ -808,6 +808,9 @@ public sealed class LiveEntityRuntimeTests
guid, 0x01010001u, id => Entity(id, guid))!;
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(1, resources.UnregisterCount);
}
@ -1428,6 +1431,7 @@ public sealed class LiveEntityRuntimeTests
Assert.False(entity.IsDrawVisible);
Assert.Empty(runtime.WorldEntities);
Assert.False(runtime.TryGetInteractionEligibleEntity(guid, out _));
Assert.False(runtime.TryGetInteractionEligibleRecord(guid, entity.Id, out _));
Assert.True(runtime.TryApplyState(new SetState.Parsed(
guid,
@ -1437,6 +1441,9 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(RetailHiddenTransition.BecameVisible, visible.HiddenTransition);
Assert.True(entity.IsDrawVisible);
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, Assert.Single(runtime.WorldEntities).Value);
Assert.Same(entity, Assert.Single(spatial.Entities));

View file

@ -30,6 +30,7 @@ public sealed class RetailWorldPickerTests
Assert.NotNull(hit);
Assert.Equal(2u, hit.Value.ServerGuid);
Assert.Equal(1002u, hit.Value.LocalEntityId);
Assert.True(hit.Value.PolygonHit);
}
@ -72,6 +73,7 @@ public sealed class RetailWorldPickerTests
{
var front = new RetailSelectionPart(
1u,
1001u,
0,
Matrix4x4.Identity,
new RetailSelectionMesh(
@ -92,6 +94,7 @@ public sealed class RetailWorldPickerTests
* Matrix4x4.CreateTranslation(0f, 0f, -10f);
var part = new RetailSelectionPart(
3u,
1003u,
0,
transform,
new RetailSelectionMesh(Vector3.Zero, 1f, [SquareAtLocalZ(0f)]));
@ -152,6 +155,7 @@ public sealed class RetailWorldPickerTests
float sphereRadius = 2f)
=> new(
guid,
guid + 1000u,
0,
Matrix4x4.CreateTranslation(0f, 0f, z),
new RetailSelectionMesh(