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:
parent
3dde2dc149
commit
6b8e29cde6
2 changed files with 395 additions and 0 deletions
117
src/AcDream.Runtime/Gameplay/RuntimeFriendlyTargetQuery.cs
Normal file
117
src/AcDream.Runtime/Gameplay/RuntimeFriendlyTargetQuery.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue