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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue