From 6b8e29cde6aed3e599acd3e0e4821d948fac323e Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 12 Aug 2026 09:39:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(runtime):=20FA6=20=E2=80=94=20RuntimeFrien?= =?UTF-8?q?dlyTargetQuery,=20the=20friendly-target=20counterpart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Gameplay/RuntimeFriendlyTargetQuery.cs | 117 ++++++++ .../RuntimeFriendlyTargetQueryTests.cs | 278 ++++++++++++++++++ 2 files changed, 395 insertions(+) create mode 100644 src/AcDream.Runtime/Gameplay/RuntimeFriendlyTargetQuery.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/RuntimeFriendlyTargetQueryTests.cs 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() { } + } +}