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

@ -2,6 +2,7 @@ using AcDream.Headless.Configuration;
using AcDream.Headless.Credentials;
using AcDream.Headless.Diagnostics;
using AcDream.Headless.Platform;
using AcDream.Headless.Policies;
using AcDream.Runtime;
using AcDream.Runtime.Session;
@ -57,6 +58,10 @@ internal sealed class HeadlessProcessHost : IDisposable
configuration.Sessions.Count);
HeadlessProcessContentOwner? content = null;
HeadlessProcessResourceSampler? resources = null;
// FA6: constructed unconditionally — cheap, and every non-gate
// session simply never reads or writes it (see the coordinator's
// own class doc).
var gateCoordinator = new FellowshipAllegianceGateCoordinator();
try
{
if (configuration.Process?.Content is { } contentDescriptor)
@ -98,7 +103,8 @@ internal sealed class HeadlessProcessHost : IDisposable
_diagnostics,
sessionOperations,
timeProvider,
contentLease: contentLease));
contentLease: contentLease,
gateCoordinator: gateCoordinator));
}
catch
{

View file

@ -215,7 +215,8 @@ internal sealed class HeadlessSessionHost : IDisposable
HeadlessProcessContentOwner.HeadlessProcessContentLease?
contentLease = null,
IHeadlessBotPolicy? policyOverride = null,
IRuntimePlacementProjectionSink? placementSinkOverride = null)
IRuntimePlacementProjectionSink? placementSinkOverride = null,
FellowshipAllegianceGateCoordinator? gateCoordinator = null)
{
_descriptor = descriptor
?? throw new ArgumentNullException(nameof(descriptor));
@ -288,7 +289,26 @@ internal sealed class HeadlessSessionHost : IDisposable
_ => { },
runtime.ActionOwner.Combat.Clear),
new LiveSessionEnteredWorldBindings(
name => ActiveCharacterName = name,
name =>
{
ActiveCharacterName = name;
// FA6: only the Recruit half publishes its own
// discovered name — the Leader's proximity
// stage reads this to disambiguate the actual
// counterpart bot from any other player entity
// a shared ACE dev instance happens to have
// online (live-run finding, see
// RuntimeFriendlyTargetQuery.FindPlayerByName's
// doc). Gating on role keeps two sessions
// writing the SAME field from ever racing —
// only one role ever writes it.
if (descriptor.Policy.Role
== HeadlessBotPolicyRole.Recruit
&& gateCoordinator is not null)
{
gateCoordinator.RecruitCharacterName = name;
}
},
() => { },
() => { },
_ => { },
@ -323,7 +343,8 @@ internal sealed class HeadlessSessionHost : IDisposable
descriptor.Policy,
runtime,
() => _pendingConfirmation,
RespondToConfirmation);
RespondToConfirmation,
gateCoordinator);
policySubscription = runtime.Subscribe(policy);
diagnostics.Lifecycle(
descriptor.Id,