Bind queued actions and pending inventory requests to exact live incarnations, separate optimistic placement from authoritative responses, and serialize retail-style inventory ownership across UI surfaces. Co-authored-by: OpenAI Codex <codex@openai.com>
395 lines
13 KiB
C#
395 lines
13 KiB
C#
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 record Animation(WorldEntity Entity, uint CurrentMotion)
|
|
: ILiveEntityAnimationRuntime;
|
|
|
|
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));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(1)]
|
|
[InlineData(2)]
|
|
public void PickRejectsPublishedPartAfterVisibilityLifetimeEnds(int transition)
|
|
{
|
|
var h = new Harness();
|
|
WorldEntity target = h.Add(Target, new Vector3(0f, 0f, -5f), ItemType.Misc);
|
|
h.Publish(target);
|
|
Assert.Equal(Target, h.Query.PickAtCursor(includeSelf: false));
|
|
|
|
switch (transition)
|
|
{
|
|
case 0:
|
|
Assert.True(h.Runtime.TryApplyState(
|
|
new SetState.Parsed(
|
|
Target,
|
|
(uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Hidden),
|
|
InstanceSequence: 1,
|
|
StateSequence: 2),
|
|
out _));
|
|
break;
|
|
case 1:
|
|
Assert.True(h.Runtime.WithdrawLiveEntityProjection(Target));
|
|
break;
|
|
default:
|
|
Assert.True(h.Runtime.UnregisterLiveEntity(
|
|
new DeleteObject.Parsed(Target, 1),
|
|
isLocalPlayer: false));
|
|
break;
|
|
}
|
|
|
|
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);
|
|
h.Add(
|
|
0x7000_0013u,
|
|
new Vector3(3f, 0f, 0f),
|
|
ItemType.Creature,
|
|
SelectedObjectHealthPolicy.BfAttackable | SelectedObjectHealthPolicy.BfPlayer);
|
|
WorldEntity pet = h.Add(
|
|
0x7000_0014u,
|
|
new Vector3(4f, 0f, 0f),
|
|
ItemType.Creature,
|
|
SelectedObjectHealthPolicy.BfAttackable);
|
|
h.Objects.Get(pet.ServerGuid)!.PetOwnerId = Player;
|
|
WorldEntity dead = h.Add(
|
|
0x7000_0015u,
|
|
new Vector3(5f, 0f, 0f),
|
|
ItemType.Creature,
|
|
SelectedObjectHealthPolicy.BfAttackable);
|
|
h.Runtime.SetAnimationRuntime(
|
|
dead.ServerGuid,
|
|
new Animation(dead, MotionCommand.Dead));
|
|
WorldEntity hidden = h.Add(
|
|
0x7000_0016u,
|
|
new Vector3(6f, 0f, 0f),
|
|
ItemType.Creature,
|
|
SelectedObjectHealthPolicy.BfAttackable);
|
|
Assert.True(h.Runtime.TryApplyState(
|
|
new SetState.Parsed(
|
|
hidden.ServerGuid,
|
|
(uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Hidden),
|
|
InstanceSequence: 1,
|
|
StateSequence: 2),
|
|
out _));
|
|
WorldEntity pending = h.Add(
|
|
0x7000_0017u,
|
|
new Vector3(7f, 0f, 0f),
|
|
ItemType.Creature,
|
|
SelectedObjectHealthPolicy.BfAttackable);
|
|
Assert.True(h.Runtime.WithdrawLiveEntityProjection(pending.ServerGuid));
|
|
|
|
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);
|
|
}
|
|
}
|