diff --git a/src/AcDream.Runtime/Gameplay/RuntimeFriendlyTargetQuery.cs b/src/AcDream.Runtime/Gameplay/RuntimeFriendlyTargetQuery.cs
new file mode 100644
index 00000000..db4b6e47
--- /dev/null
+++ b/src/AcDream.Runtime/Gameplay/RuntimeFriendlyTargetQuery.cs
@@ -0,0 +1,117 @@
+using System.Numerics;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+using AcDream.Runtime.Entities;
+
+namespace AcDream.Runtime.Gameplay;
+
+///
+/// Campaign FA slice FA6: presentation-independent FRIENDLY-target query —
+/// the counterpart does not provide
+/// (it classifies hostile monsters only, via CombatTargetPolicy.
+/// IsHostileMonster). 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.
+///
+///
+/// "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 PublicWeenieDesc._bitfield
+/// bit 3 (BF_PLAYER = 0x8) 's
+/// sibling collision code already decodes via
+/// — reused here
+/// rather than re-deriving a second bit test.
+///
+///
+public static class RuntimeFriendlyTargetQuery
+{
+ ///
+ /// The nearest OTHER player entity to the local player, or
+ /// when none is currently resolvable (not yet
+ /// streamed in, or genuinely no other player online). Mirrors
+ /// 's shape exactly,
+ /// substituting the PWD-bitfield IsPlayer bit for the hostile-monster
+ /// classification.
+ ///
+ 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;
+ }
+
+ ///
+ /// The streamed-in WeenieHeader name for ,
+ /// or when the entity is not currently resolvable
+ /// or never carried a name.
+ ///
+ 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);
+
+ ///
+ /// Same reconstruction and
+ /// use: wire local XYZ plus the
+ /// landblock-prefix world offset (each landblock is 192 m).
+ ///
+ 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);
+ }
+}
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeFriendlyTargetQueryTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeFriendlyTargetQueryTests.cs
new file mode 100644
index 00000000..a4faf630
--- /dev/null
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeFriendlyTargetQueryTests.cs
@@ -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;
+
+///
+/// Campaign FA slice FA6: conformance for
+/// , mirroring
+/// RuntimeHostileTargetQueryTests's fixture shape but classifying
+/// PLAYER (PWD-bitfield bit 0x8) rather than hostile-monster.
+///
+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 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() { }
+ }
+}