diff --git a/docs/ISSUES.md b/docs/ISSUES.md index dbe75065..65106aa7 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 diff --git a/src/AcDream.App/Combat/CombatTargetController.cs b/src/AcDream.App/Combat/CombatTargetController.cs index cdd739ac..4ce51f96 100644 --- a/src/AcDream.App/Combat/CombatTargetController.cs +++ b/src/AcDream.App/Combat/CombatTargetController.cs @@ -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; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 9c8685e9..d5e07d95 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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) diff --git a/src/AcDream.App/Rendering/Selection/IRetailSelectionGeometrySource.cs b/src/AcDream.App/Rendering/Selection/IRetailSelectionGeometrySource.cs new file mode 100644 index 00000000..7f3ef11c --- /dev/null +++ b/src/AcDream.App/Rendering/Selection/IRetailSelectionGeometrySource.cs @@ -0,0 +1,12 @@ +using AcDream.Core.Selection; + +namespace AcDream.App.Rendering.Selection; + +/// +/// Supplies the immutable DAT geometry used by retail's render-coupled picker. +/// The seam keeps frame publication and incarnation tests independent of DAT IO. +/// +internal interface IRetailSelectionGeometrySource +{ + RetailSelectionMesh? Resolve(uint gfxObjId); +} diff --git a/src/AcDream.App/Rendering/Selection/IRetailSelectionLightingSource.cs b/src/AcDream.App/Rendering/Selection/IRetailSelectionLightingSource.cs index d902ea7d..50779210 100644 --- a/src/AcDream.App/Rendering/Selection/IRetailSelectionLightingSource.cs +++ b/src/AcDream.App/Rendering/Selection/IRetailSelectionLightingSource.cs @@ -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. /// - bool TryGetLighting(uint serverGuid, out RetailSelectionLighting lighting); + bool TryGetLighting( + uint serverGuid, + uint localEntityId, + out RetailSelectionLighting lighting); } diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs index 270606cd..6e26ce73 100644 --- a/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs +++ b/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs @@ -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. /// -internal sealed class RetailSelectionGeometryCache +internal sealed class RetailSelectionGeometryCache : IRetailSelectionGeometrySource { private readonly IDatReaderWriter _dats; private readonly object _datLock; diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionLightingPulse.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionLightingPulse.cs index a1300da9..cf28e950 100644 --- a/src/AcDream.App/Rendering/Selection/RetailSelectionLightingPulse.cs +++ b/src/AcDream.App/Rendering/Selection/RetailSelectionLightingPulse.cs @@ -27,6 +27,7 @@ internal sealed class RetailSelectionLightingPulse private readonly Func _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? 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; diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs index 8510be56..6a029d55 100644 --- a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs +++ b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs @@ -11,7 +11,7 @@ namespace AcDream.App.Rendering.Selection; /// internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetailSelectionLightingSource { - private readonly RetailSelectionGeometryCache _geometry; + private readonly IRetailSelectionGeometrySource _geometry; private readonly RetailSelectionLightingPulse _lightingPulse; private List _building = new(); private List _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. /// - 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); + + /// Clears every session-owned frame and material pulse. + 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( diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index e258f252..cb230e04 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -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); diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 5ec1d1c9..39dbcd42 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -587,6 +587,24 @@ public sealed class ItemInteractionController : IDisposable StateChanged?.Invoke(); } + /// + /// 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. + /// + 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(); diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index a7adbd29..65d8c7ad 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -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; + } + + /// + /// 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. + /// + 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; diff --git a/src/AcDream.Core/Selection/RetailSelectionMesh.cs b/src/AcDream.Core/Selection/RetailSelectionMesh.cs index cccebf0c..0175f669 100644 --- a/src/AcDream.Core/Selection/RetailSelectionMesh.cs +++ b/src/AcDream.Core/Selection/RetailSelectionMesh.cs @@ -20,6 +20,7 @@ public sealed record RetailSelectionPolygon( /// One part which survived the normal world-render visibility traversal. public readonly record struct RetailSelectionPart( uint ServerGuid, + uint LocalEntityId, int PartIndex, Matrix4x4 LocalToWorld, RetailSelectionMesh Mesh); @@ -27,6 +28,7 @@ public readonly record struct RetailSelectionPart( /// Retail picker result, including which physics part supplied the hit. public readonly record struct RetailSelectionHit( uint ServerGuid, + uint LocalEntityId, int PartIndex, double Distance, bool PolygonHit); diff --git a/src/AcDream.Core/Selection/RetailWorldPicker.cs b/src/AcDream.Core/Selection/RetailWorldPicker.cs index 794efa89..fd8004f9 100644 --- a/src/AcDream.Core/Selection/RetailWorldPicker.cs +++ b/src/AcDream.Core/Selection/RetailWorldPicker.cs @@ -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; } } diff --git a/tests/AcDream.App.Tests/Combat/CombatTargetControllerTests.cs b/tests/AcDream.App.Tests/Combat/CombatTargetControllerTests.cs index 1ef586bf..7a92e3e6 100644 --- a/tests/AcDream.App.Tests/Combat/CombatTargetControllerTests.cs +++ b/tests/AcDream.App.Tests/Combat/CombatTargetControllerTests.cs @@ -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); + } } diff --git a/tests/AcDream.App.Tests/Rendering/RetailSelectionLightingPulseTests.cs b/tests/AcDream.App.Tests/Rendering/RetailSelectionLightingPulseTests.cs index 8ead7b21..9a670abb 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailSelectionLightingPulseTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailSelectionLightingPulseTests.cs @@ -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); } } diff --git a/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs b/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs new file mode 100644 index 00000000..9fb24950 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs @@ -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); + } +} diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 9622e244..58d2a4d6 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -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); + } } diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index f0da5d2b..b578950f 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -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)); diff --git a/tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs b/tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs index 9528c2cb..18d3c80a 100644 --- a/tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs +++ b/tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs @@ -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(