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

@ -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(