refactor(interaction): extract world selection query

Move render-hit validation, live target classification, closest-hostile search, selection spheres, use/pickup gates, range checks, and approach parameters behind a read-only App-layer query owner. GameWindow now supplies runtime seams and delegates instead of owning those algorithms.
This commit is contained in:
Erik 2026-07-21 07:16:22 +02:00
parent 52dbb5749e
commit e74f2ca99a
5 changed files with 749 additions and 371 deletions

View file

@ -0,0 +1,320 @@
using System.Numerics;
using AcDream.App.Interaction;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Selection;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Interaction;
public sealed class WorldSelectionQueryTests
{
private const uint Player = 0x5000_0001u;
private const uint Target = 0x7000_0001u;
private sealed class Resources : ILiveEntityResourceLifecycle
{
public void Register(WorldEntity entity) { }
public void Unregister(WorldEntity entity) { }
}
private sealed class GeometrySource(RetailSelectionMesh mesh)
: IRetailSelectionGeometrySource
{
public RetailSelectionMesh? Resolve(uint gfxObjId) => mesh;
}
private sealed class Harness
{
public readonly ClientObjectTable Objects = new();
public readonly LiveEntityRuntime Runtime;
public readonly RetailSelectionScene Scene;
public readonly WorldSelectionQuery Query;
public PlayerInteractionPose? PlayerPose = new(0x0101_0001u, Vector3.Zero);
public Harness()
{
var spatial = new GpuWorldState();
spatial.AddLandblock(new LoadedLandblock(
0x0101_FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
Runtime = new LiveEntityRuntime(spatial, new Resources());
Scene = new RetailSelectionScene(new GeometrySource(Mesh()));
Query = new WorldSelectionQuery(
Runtime,
Objects,
Scene,
() => Player,
Camera,
() => new Vector2(400f, 300f),
() => PlayerPose,
(_, _) => (0.5f, 2f),
_ => (new Vector3(1f, 0f, 0f), 2f));
Add(Player, Vector3.Zero, ItemType.Creature, SelectedObjectHealthPolicy.BfPlayer);
}
public WorldEntity Add(
uint guid,
Vector3 position,
ItemType type,
uint publicFlags = 0u,
uint? useability = null,
uint objectDescriptionFlags = 0u,
ushort instance = 1,
float scale = 1f,
Quaternion? rotation = null)
{
WorldSession.EntitySpawn spawn = Spawn(guid, instance) with
{
ItemType = (uint)type,
Useability = useability,
ObjectDescriptionFlags = objectDescriptionFlags,
};
Runtime.RegisterLiveEntity(spawn);
WorldEntity entity = Runtime.MaterializeLiveEntity(
guid,
0x0101_0001u,
id => Entity(id, guid, position, scale, rotation ?? Quaternion.Identity))!;
Objects.AddOrUpdate(new ClientObject
{
ObjectId = guid,
Name = $"Object {guid:X8}",
Type = type,
PublicWeenieBitfield = publicFlags,
});
return entity;
}
public void Publish(WorldEntity entity)
{
SelectionCameraSnapshot camera = Camera();
Scene.BeginFrame();
Scene.SetViewFrustum(FrustumPlanes.FromViewProjection(
camera.View * camera.Projection));
Scene.AddVisiblePart(
entity,
0,
0x0100_0001u,
Matrix4x4.CreateTranslation(entity.Position));
Scene.CompleteFrame();
}
}
[Fact]
public void PickRejectsPublishedPartAfterGuidWasReused()
{
var h = new Harness();
WorldEntity oldTarget = h.Add(Target, new Vector3(0f, 0f, -5f), ItemType.Misc);
h.Publish(oldTarget);
Assert.Equal(Target, h.Query.PickAtCursor(includeSelf: false));
Assert.True(h.Runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(Target, 1),
isLocalPlayer: false));
WorldEntity replacement = h.Add(
Target,
new Vector3(0f, 0f, -5f),
ItemType.Misc,
instance: 1);
Assert.NotEqual(oldTarget.Id, replacement.Id);
Assert.Null(h.Query.PickAtCursor(includeSelf: false));
}
[Fact]
public void ClosestTargetIncludesOnlyVisibleLivingHostileMonsters()
{
var h = new Harness();
h.Add(
0x7000_0010u,
new Vector3(8f, 0f, 0f),
ItemType.Creature,
SelectedObjectHealthPolicy.BfAttackable);
h.Add(
0x7000_0011u,
new Vector3(2f, 0f, 0f),
ItemType.Creature,
publicFlags: 0u);
h.Add(
0x7000_0012u,
new Vector3(1f, 0f, 0f),
ItemType.Misc,
SelectedObjectHealthPolicy.BfAttackable);
ClosestCombatTarget? closest = h.Query.FindClosestHostileMonster();
Assert.Equal(0x7000_0010u, closest?.ServerGuid);
Assert.Equal(64f, closest?.DistanceSquared);
}
[Theory]
[InlineData(0.59f, true)]
[InlineData(0.61f, false)]
[InlineData(7.49f, false)]
[InlineData(7.50f, true)]
public void ApproachUsesRetailGroundItemRadiusAndAceChargeBoundary(
float distance,
bool expectedBoundary)
{
var h = new Harness();
h.Add(Target, new Vector3(distance, 0f, 0f), ItemType.Misc);
Assert.True(h.Query.TryGetApproach(Target, out InteractionApproach approach));
if (distance < 1f)
Assert.Equal(expectedBoundary, approach.IsCloseRange);
else
Assert.Equal(expectedBoundary, approach.CanCharge);
Assert.Equal(0.6f, approach.UseRadius);
Assert.Equal(0.5f, approach.TargetRadius);
Assert.Equal(2f, approach.TargetHeight);
}
[Fact]
public void PickupAndUseabilityPreserveIndependentRetailGates()
{
var h = new Harness();
const uint stuckComponent = 0x7000_0020u;
const uint looseComponent = 0x7000_0021u;
h.Add(
stuckComponent,
Vector3.UnitX,
ItemType.SpellComponents,
useability: 1u,
objectDescriptionFlags: 0x0004u);
h.Add(
looseComponent,
Vector3.UnitX * 2f,
ItemType.SpellComponents,
useability: 1u);
Assert.False(h.Query.IsUseable(stuckComponent));
Assert.False(h.Query.IsPickupable(stuckComponent));
Assert.False(h.Query.IsUseable(looseComponent));
Assert.True(h.Query.IsPickupable(looseComponent));
}
[Fact]
public void SelectionSphereAppliesSetupOffsetScaleAndRotation()
{
var h = new Harness();
h.Add(
Target,
new Vector3(10f, 20f, 3f),
ItemType.Misc,
scale: 2f,
rotation: Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f));
Assert.True(h.Query.TryGetSelectionSphere(Target, out Vector3 center, out float radius));
Assert.True(Vector3.Distance(new Vector3(10f, 22f, 3f), center) < 0.0001f);
Assert.Equal(4f, radius);
}
private static SelectionCameraSnapshot Camera()
{
Matrix4x4 view = Matrix4x4.CreateLookAt(
Vector3.Zero,
-Vector3.UnitZ,
Vector3.UnitY);
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI / 3f,
4f / 3f,
0.1f,
100f);
return new SelectionCameraSnapshot(view, projection, new Vector2(800f, 600f));
}
private static RetailSelectionMesh Mesh()
=> new(
Vector3.Zero,
2f,
[new RetailSelectionPolygon(
[
new(-1f, -1f, 0f),
new( 1f, -1f, 0f),
new( 1f, 1f, 0f),
new(-1f, 1f, 0f),
],
SingleSided: false)]);
private static WorldEntity Entity(
uint id,
uint guid,
Vector3 position,
float scale,
Quaternion rotation)
=> new()
{
Id = id,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x0200_0001u,
Position = position,
Rotation = rotation,
Scale = scale,
MeshRefs = [],
};
private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance)
{
var position = new CreateObject.ServerPosition(
0x0101_0001u,
10f,
10f,
5f,
1f,
0f,
0f,
0f);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.ReportCollisions,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x0200_0001u,
MotionTableId: 0x0900_0001u,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, instance));
return new WorldSession.EntitySpawn(
guid,
position,
0x0200_0001u,
[],
[],
[],
null,
null,
"fixture",
null,
null,
0x0900_0001u,
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
InstanceSequence: instance,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
}