fix(headless): FA6 — name-match the Recruit bot instead of nearest-any-player

The second live gate run exposed a real environmental hazard: this shared
ACE dev instance has a THIRD player character online (+Je, guid
0x50000001), and after @teleallto it ended up nearer to the Leader bot than
the actual Recruit bot (+Horan, 0x5000000B). RuntimeFriendlyTargetQuery.
FindClosestOtherPlayer — "nearest ANY other player" — picked +Je, and the
fellowship recruit sent to it obviously never completed (confirmed live:
WaitRecruited/WaitForRecruit both timed out, both bots quarantined and
gracefully logged out cleanly).

RuntimeFriendlyTargetQuery.FindPlayerByName resolves the nearest player
whose streamed name matches exactly, with 3 new conformance tests
(preferring the named player over a closer stranger, returning null when
absent, and case-sensitivity/hidden/no-draw/self rejection).

FellowshipAllegianceGateCoordinator (AcDream.Headless.Policies) is a small
same-process, no-locking (single update thread) carrier for the Recruit
bot's own discovered character name — set by its own HeadlessSessionHost
the instant CharacterList selection resolves it, which IS D8's "discover it
live" mechanism, not a hard-coded value. Constructed once per
HeadlessProcessHost and threaded through HeadlessBotPolicyFactory.Create
into the Leader policy, which now name-matches instead of taking whichever
player entity happens to be closest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 09:53:46 +02:00
parent 11641597db
commit ab79b91f1b
5 changed files with 280 additions and 33 deletions

View file

@ -77,6 +77,62 @@ public static class RuntimeFriendlyTargetQuery
return closest;
}
/// <summary>
/// The nearest OTHER player entity whose streamed name matches
/// <paramref name="name"/> exactly (ordinal), or <see langword="null"/>
/// when none is currently resolvable. Live-run finding (FA6, second
/// gate attempt): a shared ACE dev instance can have a THIRD player
/// character online and nearer than the actual counterpart bot after
/// an <c>@teleallto</c> — <see cref="FindClosestOtherPlayer"/> alone
/// picked the wrong one in that case. Callers that know the exact
/// expected name should prefer this method over the ambiguous
/// "nearest ANY player" query.
/// </summary>
public static uint? FindPlayerByName(GameRuntime runtime, string name)
{
ArgumentNullException.ThrowIfNull(runtime);
ArgumentException.ThrowIfNullOrEmpty(name);
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
|| !IsPlayer(record)
|| !string.Equals(
record.Snapshot.Name,
name,
StringComparison.Ordinal))
{
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