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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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.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))!;
|
||||
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue