Selecting a PKLite player and attacking did nothing: with auto-target on it retargeted to the nearest monster, with auto-target off it logged "combat: attack ignored; no creature target found". Spells on the same target worked, which was the clue. Root cause: CombatTargetPolicy.IsHostileMonster:31-33 rejects any candidate carrying BfPlayer BEFORE reaching ObjectIsAttackable, so the both-PKLite pool match at SelectedObjectHealthPolicy.cs:70-71 was unreachable for players. Melee and missile targeting never supported player targets at all — the gate is named IsHostileMonster and does exactly what it says. Nobody could hit it until69ba9486made PK Lite reachable. Retail uses ONE predicate for monsters and players, with no player exclusion: ClientCombatSystem::ExecuteAttack @0x0056BB70 gates unconditionally on ObjectIsAttackable @0x0056A600 (creature type, Free-PK short-circuit on either side, then IsPlayer -> bothPK || bothPKLite, else BF_ATTACKABLE with pets excluded). acdream already ported that predicate verbatim; it was simply unreachable. The fix SPLITS the two concerns rather than relaxing the shared predicate: explicit-target admission routes through ObjectIsAttackable, while auto-target ACQUISITION keeps the monster-only gate. That is required by register row IA-19 — explicit product direction that Auto Target must never select NPCs, players or pets. IA-19 is not overridden here; its own justification promises "manual player-selection commands remain available", and that promise was never implemented, so this makes the row true. Review confirmed no path lets auto-acquisition select a player: every automatic Select is fed by a FindClosest* filtered through IsHostileMonster. Review also found a second site with the same bug, which the first pass froze in place on my instruction: retail gates combat-camera tracking on the SAME predicate as the attack. ClientCombatSystem::UpdateTargetTracking @0x0056A950 reads GetAttackTarget() then gates CameraSet::TrackTarget on ObjectIsAttackable. Ours used the monster-only gate, so with ViewCombatTarget on by default the attack would land while the camera refused to track the opponent — user-visible in exactly the duel this fix enables. GetCombatCameraTargetPoint now uses the wide predicate. IA-19 does not reach the camera: it performs no acquisition, only presentation on an already-chosen target. The first pass had added a source comment asserting IA-19 covered it; that comment and the matching text in docs/ISSUES.md are corrected, since a wrong citation is how a real divergence becomes invisible. Depends on9b1e6fc6(#297): the both-PKLite arm needs the LOCAL player's own bit to be live. Review confirmed both admission sites read ClientObjectTable on every call, so this is not inert in production. Newly reachable and now pinned: ObjectIsAttackable's pet-exclusion arm, which CombatTargetPolicy rejected before it could ever run. Follow-ups filed: #304 (SelectionInteractionController.GetSelectedOrClosestCombatTarget has no production caller — one of the two widened call sites is dead code), #305 (HeadlessGameplayOperations has the identical pre-existing bug, so the graphical/headless hosts now diverge). Gates: complete Release solution 10,904 passed / 4 skipped / 0 failed (baseline 10,900). Adversarial + retail-conformance review PASS after one FAIL round; the predicate was re-verified branch-for-branch against 0x0056A600 since it goes live here for the first time. Camera fix discrimination-verified by revert. Connected acceptance NOT run — needs a live two-client PKLite session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
358 lines
13 KiB
C#
358 lines
13 KiB
C#
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// #298: explicit selection of a compatible-PK player is a valid attack
|
|
/// target. Retail <c>ObjectIsAttackable @ 0x0056A600</c> 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).
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// #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.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// #298 follow-up: explicit selection no longer short-circuits on
|
|
/// <c>PetOwnerId != 0</c> the way <c>CombatTargetPolicy.IsHostileMonster</c>
|
|
/// does (`:33`) — it now reaches <c>ObjectIsAttackable</c>'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
|
|
/// <c>SelectedObjectHealthPolicyTests.ObjectIsAttackable_AttackablePetIsRejected</c>).
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mutation guard for <see cref="CombatAttackTargetSource.FindClosestHostileMonster"/>:
|
|
/// 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
|
|
/// <see cref="CombatTargetPolicy.IsHostileMonster"/> narrow — it proves
|
|
/// nobody swapped <c>FindClosestHostileMonster</c>'s gate for the wider
|
|
/// predicate. See <see cref="AutoTargetNeverAcquiresAPlayerWhenNoMonsterIsInRange"/>
|
|
/// for the case that actually forces the narrow policy to reject the
|
|
/// only candidate.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 — <c>FindClosestHostileMonster</c> filters
|
|
/// through the narrow <see cref="CombatTargetPolicy.IsHostileMonster"/>,
|
|
/// 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 <c>AutoTarget</c> would).
|
|
/// </summary>
|
|
[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<WorldEntity>()));
|
|
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);
|
|
}
|
|
}
|