feat(runtime): FA6 — RuntimeFriendlyTargetQuery, the friendly-target counterpart

RuntimeHostileTargetQuery only classifies hostile monsters (via
CombatTargetPolicy.IsHostileMonster) — the FA6 two-bot fellowship/allegiance
headless gate needs the OTHER bot's server guid as a FRIENDLY target instead.
RuntimeFriendlyTargetQuery.FindClosestOtherPlayer mirrors the hostile query's
shape exactly (same hidden/no-draw filtering, same landblock-absolute
distance metric), substituting the retail PWD-bitfield IsPlayer bit (0x8,
via the existing EntityCollisionFlagsExt.FromPwdBitfield decoder) for hostile
classification. TryGetName resolves the streamed WeenieHeader name for
reporting/logging.

4 new conformance tests mirror RuntimeHostileTargetQueryTests's fixture
pattern: cross-landblock distance, hidden/no-draw/self/non-player rejection,
null-without-player-or-target, and unresolved-guid name lookup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 09:39:42 +02:00
parent 3dde2dc149
commit 6b8e29cde6
2 changed files with 395 additions and 0 deletions

View file

@ -0,0 +1,117 @@
using System.Numerics;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Campaign FA slice FA6: presentation-independent FRIENDLY-target query —
/// the counterpart <see cref="RuntimeHostileTargetQuery"/> does not provide
/// (it classifies hostile monsters only, via <c>CombatTargetPolicy.
/// IsHostileMonster</c>). Built for the two-bot fellowship/allegiance
/// headless gate (docs/plans/2026-08-11-fellowship-allegiance-campaign.md
/// D8; docs/research/2026-08-11-fa-acdream-seams.md §6.2): the recruiting
/// bot needs the OTHER bot's server guid once both are in world near each
/// other, and neither bot knows the other's identity ahead of time.
///
/// <para>
/// "Friendly" here means exactly "another live player entity, not this
/// player" — no PK/party/alignment reasoning, since the gate's own two
/// accounts are never hostile to each other by construction. The PLAYER
/// classification bit is the same retail <c>PublicWeenieDesc._bitfield</c>
/// bit 3 (<c>BF_PLAYER = 0x8</c>) <see cref="RuntimeHostileTargetQuery"/>'s
/// sibling collision code already decodes via
/// <see cref="EntityCollisionFlagsExt.FromPwdBitfield"/> — reused here
/// rather than re-deriving a second bit test.
/// </para>
/// </summary>
public static class RuntimeFriendlyTargetQuery
{
/// <summary>
/// The nearest OTHER player entity to the local player, or
/// <see langword="null"/> when none is currently resolvable (not yet
/// streamed in, or genuinely no other player online). Mirrors
/// <see cref="RuntimeHostileTargetQuery.FindClosest"/>'s shape exactly,
/// substituting the PWD-bitfield IsPlayer bit for the hostile-monster
/// classification.
/// </summary>
public static uint? FindClosestOtherPlayer(GameRuntime runtime)
{
ArgumentNullException.ThrowIfNull(runtime);
uint playerGuid = runtime.PlayerIdentity.ServerGuid;
if (playerGuid == 0u
|| !runtime.EntityObjects.Entities.TryGetActive(
playerGuid,
out RuntimeEntityRecord playerRecord)
|| playerRecord.Snapshot.Position is not { } playerPosition)
{
return null;
}
Vector3 playerWorld = AbsolutePosition(playerPosition);
uint? closest = null;
float closestDistanceSquared = float.PositiveInfinity;
foreach (RuntimeEntityRecord record
in runtime.EntityObjects.Entities.ActiveRecords)
{
if (record.ServerGuid == playerGuid
|| record.Snapshot.Position is not { } position
|| (record.FinalPhysicsState
& (PhysicsStateFlags.Hidden
| PhysicsStateFlags.NoDraw)) != 0)
{
continue;
}
if (!IsPlayer(record))
continue;
float distanceSquared = Vector3.DistanceSquared(
playerWorld,
AbsolutePosition(position));
if (distanceSquared >= closestDistanceSquared)
continue;
closestDistanceSquared = distanceSquared;
closest = record.ServerGuid;
}
return closest;
}
/// <summary>
/// The streamed-in <c>WeenieHeader</c> name for <paramref name="guid"/>,
/// or <see langword="null"/> when the entity is not currently resolvable
/// or never carried a name.
/// </summary>
public static string? TryGetName(GameRuntime runtime, uint guid)
{
ArgumentNullException.ThrowIfNull(runtime);
return runtime.EntityObjects.Entities.TryGetActive(
guid,
out RuntimeEntityRecord record)
? record.Snapshot.Name
: null;
}
private static bool IsPlayer(RuntimeEntityRecord record) =>
EntityCollisionFlagsExt
.FromPwdBitfield(record.Snapshot.ObjectDescriptionFlags ?? 0u)
.HasFlag(EntityCollisionFlags.IsPlayer);
/// <summary>
/// Same reconstruction <see cref="RuntimeHostileTargetQuery"/> and
/// <see cref="RuntimeVendorRangeQuery"/> use: wire local XYZ plus the
/// landblock-prefix world offset (each landblock is 192 m).
/// </summary>
private static Vector3 AbsolutePosition(
CreateObject.ServerPosition position)
{
int landblockX =
(int)((position.LandblockId >> 24) & 0xFFu);
int landblockY =
(int)((position.LandblockId >> 16) & 0xFFu);
return new Vector3(
position.PositionX + landblockX * 192f,
position.PositionY + landblockY * 192f,
position.PositionZ);
}
}

View file

@ -0,0 +1,278 @@
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Spells;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
/// <summary>
/// Campaign FA slice FA6: conformance for
/// <see cref="RuntimeFriendlyTargetQuery"/>, mirroring
/// <c>RuntimeHostileTargetQueryTests</c>'s fixture shape but classifying
/// PLAYER (PWD-bitfield bit <c>0x8</c>) rather than hostile-monster.
/// </summary>
public sealed class RuntimeFriendlyTargetQueryTests
{
private const uint Player = 0x50000001u;
private const uint PlayerBit = 0x8u;
[Fact]
public void FindClosestOtherPlayer_UsesAbsoluteDerethCoordinatesAcrossLandblocks()
{
using GameRuntime runtime = Create();
runtime.PlayerIdentity.ServerGuid = Player;
Add(
runtime,
Player,
landblock: 0x01010001u,
x: 191f,
y: 100f,
name: "Self",
objectDescriptionFlags: PlayerBit);
Add(
runtime,
0x50000010u,
landblock: 0x02010001u,
x: 1f,
y: 100f,
name: "NearOtherPlayer",
objectDescriptionFlags: PlayerBit);
Add(
runtime,
0x50000011u,
landblock: 0x01010001u,
x: 180f,
y: 100f,
name: "FarOtherPlayer",
objectDescriptionFlags: PlayerBit);
// 0x50000010 sits one landblock (192 m) east of the player at local
// x=1 (absolute x = 192 + 1 = 193, vs the player's 191 — an 2 m
// gap); 0x50000011 sits in the SAME landblock at local x=180 (an
// 11 m gap). The cross-landblock entity is genuinely closer, so a
// query that forgot the landblock-prefix offset would pick the
// wrong one.
Assert.Equal(
0x50000010u,
RuntimeFriendlyTargetQuery.FindClosestOtherPlayer(runtime));
Assert.Equal(
"NearOtherPlayer",
RuntimeFriendlyTargetQuery.TryGetName(runtime, 0x50000010u));
}
[Fact]
public void FindClosestOtherPlayer_RejectsHiddenNoDrawSelfAndNonPlayerEntities()
{
using GameRuntime runtime = Create();
runtime.PlayerIdentity.ServerGuid = Player;
Add(runtime, Player, 0x01010001u, 10f, 10f, "Self", PlayerBit);
Add(
runtime,
0x50000010u,
0x01010001u,
11f,
10f,
"Hidden",
PlayerBit,
PhysicsStateFlags.Hidden);
Add(
runtime,
0x50000011u,
0x01010001u,
12f,
10f,
"NoDraw",
PlayerBit,
PhysicsStateFlags.NoDraw);
Add(
runtime,
0x50000012u,
0x01010001u,
13f,
10f,
"NotAPlayer",
objectDescriptionFlags: 0u);
Add(
runtime,
0x50000013u,
0x01010001u,
14f,
10f,
"RealOtherPlayer",
PlayerBit);
Assert.Equal(
0x50000013u,
RuntimeFriendlyTargetQuery.FindClosestOtherPlayer(runtime));
}
[Fact]
public void FindClosestOtherPlayer_ReturnsNullWithoutPlayerPositionOrOtherPlayer()
{
using GameRuntime runtime = Create();
runtime.PlayerIdentity.ServerGuid = Player;
Assert.Null(RuntimeFriendlyTargetQuery.FindClosestOtherPlayer(runtime));
Add(runtime, Player, 0x01010001u, 10f, 10f, "Self", PlayerBit);
Add(
runtime,
0x50000010u,
0x01010001u,
11f,
10f,
"NotAPlayer",
objectDescriptionFlags: 0u);
Assert.Null(RuntimeFriendlyTargetQuery.FindClosestOtherPlayer(runtime));
}
[Fact]
public void TryGetName_ReturnsNullForAnUnresolvedGuid()
{
using GameRuntime runtime = Create();
runtime.PlayerIdentity.ServerGuid = Player;
Add(runtime, Player, 0x01010001u, 10f, 10f, "Self", PlayerBit);
Assert.Null(RuntimeFriendlyTargetQuery.TryGetName(runtime, 0x50000099u));
}
private static GameRuntime Create()
{
var operations = new Operations();
return new GameRuntime(new GameRuntimeDependencies(
operations,
operations,
operations,
operations));
}
private static void Add(
GameRuntime runtime,
uint guid,
uint landblock,
float x,
float y,
string name,
uint objectDescriptionFlags,
PhysicsStateFlags state = 0)
{
RuntimeEntityRecord record = runtime.EntityObjects
.RegisterEntity(
Spawn(guid, landblock, x, y, name, objectDescriptionFlags, state))
.Canonical!;
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
record,
record.CreateIntegrationVersion,
record.Snapshot,
replaceGeneration: false));
}
private static WorldSession.EntitySpawn Spawn(
uint guid,
uint landblock,
float x,
float y,
string name,
uint objectDescriptionFlags,
PhysicsStateFlags state)
{
var position = new CreateObject.ServerPosition(
landblock,
x,
y,
5f,
1f,
0f,
0f,
0f);
var timestamps = new PhysicsTimestamps(
Position: 1,
Movement: 1,
State: 1,
Vector: 1,
Teleport: 0,
ServerControlledMove: 1,
ForcePosition: 0,
ObjDesc: 1,
Instance: 1);
var physics = new PhysicsSpawnData(
RawState: (uint)state,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: null,
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: timestamps);
return new WorldSession.EntitySpawn(
guid,
position,
0x02000001u,
[],
[],
[],
null,
null,
name,
null,
null,
null,
PhysicsState: physics.RawState,
ObjectDescriptionFlags: objectDescriptionFlags,
InstanceSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private sealed class Operations :
IRuntimeCombatAttackOperations,
IRuntimeCombatTargetOperations,
IRuntimeCombatModeOperations,
IRuntimeSpellCastOperations
{
public bool CanStartAttack() => false;
public void PrepareAttackRequest() { }
public bool SendAttack(AttackHeight height, float power) => false;
public void SendCancelAttack() { }
public bool IsDualWield => false;
public bool PlayerReadyForAttack => false;
public bool AutoRepeatAttack => false;
public bool AutoTarget => false;
public uint? SelectClosestTarget() => null;
public bool IsInWorld => false;
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
public void NotifyExplicitCombatModeRequest() { }
public void SendChangeCombatMode(CombatMode mode) { }
public uint LocalPlayerId => 0u;
public bool CanSend => false;
public bool HasRequiredComponents(uint spellId) => false;
public bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage) => false;
public void StopCompletely() { }
public void SendUntargeted(uint spellId) { }
public void SendTargeted(uint targetId, uint spellId) { }
public void DisplayMessage(string message) { }
public void IncrementBusy() { }
}
}