using System.Numerics; using AcDream.App.Combat; using AcDream.App.Input; using AcDream.App.Rendering; 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.Combat; public sealed class CombatAttackTargetSourceTests { private const uint Player = 0x5000_0001u; [Fact] public void ExplicitSelectedHostileIsAcceptedWithoutAutoTarget() { var harness = new Harness(); const uint target = 0x7000_0001u; harness.Add(target, new Vector3(2f, 0f, 0f), attackable: true); harness.Selection.Select(target, SelectionChangeSource.Keyboard); Assert.Equal( target, harness.Targets.GetSelectedOrClosestCombatTarget(autoTarget: false)); } /// /// #298: explicit selection of a compatible-PK player is a valid attack /// target. Retail ObjectIsAttackable @ 0x0056A600 admits a player /// target when both sides carry the same PKLite status, and this is /// reachable ONLY through explicit selection — never automatic /// acquisition (register row IA-19). /// [Fact] public void ExplicitSelectedPkLitePlayerIsAcceptedWithoutAutoTarget() { var harness = new Harness(); harness.SetLocalPlayerPvpFlags(SelectedObjectHealthPolicy.BfPkLiteStatus); const uint target = 0x7000_0002u; harness.Add( target, new Vector3(2f, 0f, 0f), attackable: false, isPlayer: true, extraFlags: SelectedObjectHealthPolicy.BfPkLiteStatus); harness.Selection.Select(target, SelectionChangeSource.Keyboard); Assert.Equal( target, harness.Targets.GetSelectedOrClosestCombatTarget(autoTarget: false)); } /// /// #298: explicit selection of a player whose PK pool does not match /// (neither side is PKLite/PK/Free) is still refused — the fix widens /// admission to compatible-PK players, it does not admit every player. /// [Fact] public void ExplicitSelectedNonPkPlayerIsRefused() { var harness = new Harness(); const uint target = 0x7000_0003u; harness.Add( target, new Vector3(2f, 0f, 0f), attackable: false, isPlayer: true); harness.Selection.Select(target, SelectionChangeSource.Keyboard); Assert.Null( harness.Targets.GetSelectedOrClosestCombatTarget(autoTarget: false)); } /// /// #298 follow-up: explicit selection no longer short-circuits on /// PetOwnerId != 0 the way CombatTargetPolicy.IsHostileMonster /// does (`:33`) — it now reaches ObjectIsAttackable's OWN pet arm /// (retail `else if (esi->pwd._pet_owner == 0)` @ 0x0056a683) for the /// first time. That arm still refuses an attackable, owned pet, so the /// end behavior is unchanged; this pins reachability through the new /// path, not just the Core predicate (see /// SelectedObjectHealthPolicyTests.ObjectIsAttackable_AttackablePetIsRejected). /// [Fact] public void ExplicitSelectedAttackablePetIsRefused() { var harness = new Harness(); const uint pet = 0x7000_0007u; harness.Add( pet, new Vector3(2f, 0f, 0f), attackable: true, petOwnerId: Player); harness.Selection.Select(pet, SelectionChangeSource.Keyboard); Assert.Null( harness.Targets.GetSelectedOrClosestCombatTarget(autoTarget: false)); } /// /// Mutation guard for : /// with nothing explicitly selected, auto-acquisition must still filter /// the nearer compatible-PK player out and pick the farther monster. This /// does NOT exercise the explicit-selection branch (nothing is selected), /// so it does not by itself prove the #298 split kept /// narrow — it proves /// nobody swapped FindClosestHostileMonster's gate for the wider /// predicate. See /// for the case that actually forces the narrow policy to reject the /// only candidate. /// [Fact] public void AutoTargetNeverAcquiresAPlayerOverAMonster() { var harness = new Harness(); harness.SetLocalPlayerPvpFlags(SelectedObjectHealthPolicy.BfPkLiteStatus); const uint monster = 0x7000_0004u; const uint pkLitePlayer = 0x7000_0005u; harness.Add(monster, new Vector3(8f, 0f, 0f), attackable: true); harness.Add( pkLitePlayer, new Vector3(1f, 0f, 0f), attackable: false, isPlayer: true, extraFlags: SelectedObjectHealthPolicy.BfPkLiteStatus); uint? selected = harness.Targets.GetSelectedOrClosestCombatTarget( autoTarget: true); Assert.Equal(monster, selected); Assert.Equal(monster, harness.Selection.SelectedObjectId); } /// /// The actual IA-19 invariant: with nothing selected and NO monster in /// range at all, a nearby compatible-PK player must still be rejected by /// automatic acquisition — FindClosestHostileMonster filters /// through the narrow , /// finds no candidate, and the stale selection is cleared, exactly as /// retail's IA-19 divergence documents (auto-target never acquires a /// player even though retail's own AutoTarget would). /// [Fact] public void AutoTargetNeverAcquiresAPlayerWhenNoMonsterIsInRange() { var harness = new Harness(); harness.SetLocalPlayerPvpFlags(SelectedObjectHealthPolicy.BfPkLiteStatus); const uint pkLitePlayer = 0x7000_0006u; harness.Add( pkLitePlayer, new Vector3(1f, 0f, 0f), attackable: false, isPlayer: true, extraFlags: SelectedObjectHealthPolicy.BfPkLiteStatus); uint? selected = harness.Targets.GetSelectedOrClosestCombatTarget( autoTarget: true); Assert.Null(selected); Assert.Null(harness.Selection.SelectedObjectId); } [Fact] public void AutoTargetUsesNearestEligibleLiveHostile() { var harness = new Harness(); const uint valid = 0x7000_0010u; harness.Add(valid, new Vector3(8f, 0f, 0f), attackable: true); harness.Add(0x7000_0011u, new Vector3(2f, 0f, 0f), attackable: false); WorldEntity dead = harness.Add( 0x7000_0012u, new Vector3(3f, 0f, 0f), attackable: true); harness.Runtime.SetAnimationRuntime( dead.ServerGuid, new Animation(dead, MotionCommand.Dead)); WorldEntity hidden = harness.Add( 0x7000_0013u, new Vector3(4f, 0f, 0f), attackable: true); Assert.True(harness.Runtime.TryApplyState( new SetState.Parsed( hidden.ServerGuid, (uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Hidden), InstanceSequence: 1, StateSequence: 2), out _)); WorldEntity pending = harness.Add( 0x7000_0014u, new Vector3(5f, 0f, 0f), attackable: true); Assert.True(harness.Runtime.WithdrawLiveEntityProjection(pending.ServerGuid)); uint? selected = harness.Targets.GetSelectedOrClosestCombatTarget( autoTarget: true); Assert.Equal(valid, selected); Assert.Equal(valid, harness.Selection.SelectedObjectId); } [Fact] public void MissingAutoTargetClearsAnInvalidSelection() { var harness = new Harness(); const uint nonHostile = 0x7000_0020u; harness.Add(nonHostile, Vector3.UnitX, attackable: false); harness.Selection.Select(nonHostile, SelectionChangeSource.Keyboard); Assert.Null(harness.Targets.GetSelectedOrClosestCombatTarget(autoTarget: true)); Assert.Null(harness.Selection.SelectedObjectId); } private sealed class Harness { public ClientObjectTable Objects { get; } = new(); public SelectionState Selection { get; } = new(); public LiveEntityRuntime Runtime { get; } public CombatAttackTargetSource Targets { get; } public Harness() { var spatial = new GpuWorldState(); spatial.AddLandblock(new LoadedLandblock( 0x0101_FFFFu, new LandBlock(), Array.Empty())); Runtime = LiveEntityRuntimeFixture.Create(spatial, new Resources()); var identity = new LocalPlayerIdentityState { ServerGuid = Player }; Targets = new CombatAttackTargetSource( Selection, Runtime, Objects, identity); Add(Player, Vector3.Zero, attackable: false, isPlayer: true); } public void SetLocalPlayerPvpFlags(uint extraFlags) => Objects.AddOrUpdate(new ClientObject { ObjectId = Player, Name = $"Object {Player:X8}", Type = ItemType.Creature, PublicWeenieBitfield = SelectedObjectHealthPolicy.BfPlayer | extraFlags, }); public WorldEntity Add( uint guid, Vector3 position, bool attackable, bool isPlayer = false, uint extraFlags = 0u, uint petOwnerId = 0u) { Runtime.RegisterLiveEntity(Spawn(guid)); WorldEntity entity = Runtime.MaterializeLiveEntity( guid, 0x0101_0001u, id => new WorldEntity { Id = id, ServerGuid = guid, SourceGfxObjOrSetupId = 0x0200_0001u, Position = position, Rotation = Quaternion.Identity, Scale = 1f, MeshRefs = [], })!; uint flags = attackable ? SelectedObjectHealthPolicy.BfAttackable : 0u; if (isPlayer) flags |= SelectedObjectHealthPolicy.BfPlayer; flags |= extraFlags; Objects.AddOrUpdate(new ClientObject { ObjectId = guid, Name = $"Object {guid:X8}", Type = ItemType.Creature, PublicWeenieBitfield = flags, PetOwnerId = petOwnerId, }); return entity; } } private sealed record Animation(WorldEntity Entity, uint CurrentMotion) : ILiveEntityAnimationRuntime; private sealed class Resources : ILiveEntityResourceLifecycle { public void Register(WorldEntity entity) { } public void Unregister(WorldEntity entity) { } } private static WorldSession.EntitySpawn Spawn(uint guid) { 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, 1)); return new WorldSession.EntitySpawn( guid, position, 0x0200_0001u, [], [], [], null, null, "fixture", null, null, 0x0900_0001u, PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, InstanceSequence: 1, MovementSequence: 1, ServerControlSequence: 1, PositionSequence: 1, Physics: physics); } }