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:
parent
11641597db
commit
ab79b91f1b
5 changed files with 280 additions and 33 deletions
|
|
@ -2,6 +2,7 @@ using AcDream.Headless.Configuration;
|
||||||
using AcDream.Headless.Credentials;
|
using AcDream.Headless.Credentials;
|
||||||
using AcDream.Headless.Diagnostics;
|
using AcDream.Headless.Diagnostics;
|
||||||
using AcDream.Headless.Platform;
|
using AcDream.Headless.Platform;
|
||||||
|
using AcDream.Headless.Policies;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
using AcDream.Runtime.Session;
|
using AcDream.Runtime.Session;
|
||||||
|
|
||||||
|
|
@ -57,6 +58,10 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
configuration.Sessions.Count);
|
configuration.Sessions.Count);
|
||||||
HeadlessProcessContentOwner? content = null;
|
HeadlessProcessContentOwner? content = null;
|
||||||
HeadlessProcessResourceSampler? resources = 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
|
try
|
||||||
{
|
{
|
||||||
if (configuration.Process?.Content is { } contentDescriptor)
|
if (configuration.Process?.Content is { } contentDescriptor)
|
||||||
|
|
@ -98,7 +103,8 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
_diagnostics,
|
_diagnostics,
|
||||||
sessionOperations,
|
sessionOperations,
|
||||||
timeProvider,
|
timeProvider,
|
||||||
contentLease: contentLease));
|
contentLease: contentLease,
|
||||||
|
gateCoordinator: gateCoordinator));
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
HeadlessProcessContentOwner.HeadlessProcessContentLease?
|
HeadlessProcessContentOwner.HeadlessProcessContentLease?
|
||||||
contentLease = null,
|
contentLease = null,
|
||||||
IHeadlessBotPolicy? policyOverride = null,
|
IHeadlessBotPolicy? policyOverride = null,
|
||||||
IRuntimePlacementProjectionSink? placementSinkOverride = null)
|
IRuntimePlacementProjectionSink? placementSinkOverride = null,
|
||||||
|
FellowshipAllegianceGateCoordinator? gateCoordinator = null)
|
||||||
{
|
{
|
||||||
_descriptor = descriptor
|
_descriptor = descriptor
|
||||||
?? throw new ArgumentNullException(nameof(descriptor));
|
?? throw new ArgumentNullException(nameof(descriptor));
|
||||||
|
|
@ -288,7 +289,26 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
_ => { },
|
_ => { },
|
||||||
runtime.ActionOwner.Combat.Clear),
|
runtime.ActionOwner.Combat.Clear),
|
||||||
new LiveSessionEnteredWorldBindings(
|
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,
|
descriptor.Policy,
|
||||||
runtime,
|
runtime,
|
||||||
() => _pendingConfirmation,
|
() => _pendingConfirmation,
|
||||||
RespondToConfirmation);
|
RespondToConfirmation,
|
||||||
|
gateCoordinator);
|
||||||
policySubscription = runtime.Subscribe(policy);
|
policySubscription = runtime.Subscribe(policy);
|
||||||
diagnostics.Lifecycle(
|
diagnostics.Lifecycle(
|
||||||
descriptor.Id,
|
descriptor.Id,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ internal static class HeadlessBotPolicyFactory
|
||||||
GameRuntime runtime,
|
GameRuntime runtime,
|
||||||
Func<GameEvents.CharacterConfirmationRequest?>?
|
Func<GameEvents.CharacterConfirmationRequest?>?
|
||||||
getPendingConfirmation = null,
|
getPendingConfirmation = null,
|
||||||
Action<bool>? respondToConfirmation = null)
|
Action<bool>? respondToConfirmation = null,
|
||||||
|
FellowshipAllegianceGateCoordinator? gateCoordinator = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(descriptor);
|
ArgumentNullException.ThrowIfNull(descriptor);
|
||||||
ArgumentNullException.ThrowIfNull(runtime);
|
ArgumentNullException.ThrowIfNull(runtime);
|
||||||
|
|
@ -52,7 +53,8 @@ internal static class HeadlessBotPolicyFactory
|
||||||
descriptor,
|
descriptor,
|
||||||
runtime,
|
runtime,
|
||||||
getPendingConfirmation,
|
getPendingConfirmation,
|
||||||
respondToConfirmation),
|
respondToConfirmation,
|
||||||
|
gateCoordinator),
|
||||||
_ => throw new HeadlessConfigurationException(
|
_ => throw new HeadlessConfigurationException(
|
||||||
$"Unknown headless bot policy '{descriptor.Id}'."),
|
$"Unknown headless bot policy '{descriptor.Id}'."),
|
||||||
};
|
};
|
||||||
|
|
@ -62,7 +64,8 @@ internal static class HeadlessBotPolicyFactory
|
||||||
HeadlessBotPolicyDescriptor descriptor,
|
HeadlessBotPolicyDescriptor descriptor,
|
||||||
GameRuntime runtime,
|
GameRuntime runtime,
|
||||||
Func<GameEvents.CharacterConfirmationRequest?>? getPendingConfirmation,
|
Func<GameEvents.CharacterConfirmationRequest?>? getPendingConfirmation,
|
||||||
Action<bool>? respondToConfirmation)
|
Action<bool>? respondToConfirmation,
|
||||||
|
FellowshipAllegianceGateCoordinator? gateCoordinator)
|
||||||
{
|
{
|
||||||
if (descriptor.Role is not { } role)
|
if (descriptor.Role is not { } role)
|
||||||
{
|
{
|
||||||
|
|
@ -71,12 +74,15 @@ internal static class HeadlessBotPolicyFactory
|
||||||
+ "('leader' or 'recruit').");
|
+ "('leader' or 'recruit').");
|
||||||
}
|
}
|
||||||
if (role == HeadlessBotPolicyRole.Leader
|
if (role == HeadlessBotPolicyRole.Leader
|
||||||
&& (getPendingConfirmation is null || respondToConfirmation is null))
|
&& (getPendingConfirmation is null
|
||||||
|
|| respondToConfirmation is null
|
||||||
|
|| gateCoordinator is null))
|
||||||
{
|
{
|
||||||
throw new HeadlessConfigurationException(
|
throw new HeadlessConfigurationException(
|
||||||
"Policy 'fellowship-allegiance-gate' role 'leader' requires "
|
"Policy 'fellowship-allegiance-gate' role 'leader' requires "
|
||||||
+ "a confirmation-relay host (the swear confirmation "
|
+ "a confirmation-relay host (the swear confirmation "
|
||||||
+ "targets the patron).");
|
+ "targets the patron) and a gate coordinator (to "
|
||||||
|
+ "name-match the Recruit bot).");
|
||||||
}
|
}
|
||||||
return role switch
|
return role switch
|
||||||
{
|
{
|
||||||
|
|
@ -84,7 +90,8 @@ internal static class HeadlessBotPolicyFactory
|
||||||
new FellowshipAllegianceLeaderBotPolicy(
|
new FellowshipAllegianceLeaderBotPolicy(
|
||||||
runtime,
|
runtime,
|
||||||
getPendingConfirmation!,
|
getPendingConfirmation!,
|
||||||
respondToConfirmation!),
|
respondToConfirmation!,
|
||||||
|
gateCoordinator!),
|
||||||
HeadlessBotPolicyRole.Recruit =>
|
HeadlessBotPolicyRole.Recruit =>
|
||||||
new FellowshipAllegianceRecruitBotPolicy(runtime),
|
new FellowshipAllegianceRecruitBotPolicy(runtime),
|
||||||
_ => throw new HeadlessConfigurationException(
|
_ => throw new HeadlessConfigurationException(
|
||||||
|
|
@ -817,15 +824,26 @@ internal sealed class JumpProbeHeadlessBotPolicy : IHeadlessBotPolicy
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Proximity (D8, load-bearing — recruit fails without it).</b> Neither
|
/// <b>Proximity (D8, load-bearing — recruit fails without it).</b> Neither
|
||||||
/// bot's config declares the other's character name (D8's own instruction:
|
/// bot's config hard-codes the other's character name (D8's own
|
||||||
/// discover it live, never hard-code), so this bot cannot <c>@teleto
|
/// instruction: discover it live) — this bot cannot <c>@teleto
|
||||||
/// <name></c> the Recruit bot directly. It uses retail's admin
|
/// <name></c> the Recruit bot directly until it learns that name, so
|
||||||
/// <c>@teleallto</c> instead (no target = "teleport everyone online to
|
/// it uses retail's admin <c>@teleallto</c> instead (no target = "teleport
|
||||||
/// me") — a single idempotent GM command needing no cross-session name
|
/// everyone online to me") — a single idempotent GM command needing no
|
||||||
/// sharing at all; safe to retry blindly before the Recruit bot has even
|
/// cross-session data to ISSUE; safe to retry blindly before the Recruit
|
||||||
/// connected (a lone player teleporting to themselves is a no-op). Once the
|
/// bot has even connected (a lone player teleporting to themselves is a
|
||||||
/// Recruit bot is colocated, <see cref="RuntimeFriendlyTargetQuery"/>
|
/// no-op). <see cref="FellowshipAllegianceGateCoordinator"/> then supplies
|
||||||
/// resolves its guid from the ordinary entity-streaming radius.
|
/// the Recruit bot's OWN discovered name (set by its own
|
||||||
|
/// <c>HeadlessSessionHost</c> the moment CharacterList selection resolves
|
||||||
|
/// it — the actual "discover it live" mechanism), and
|
||||||
|
/// <see cref="RuntimeFriendlyTargetQuery.FindPlayerByName"/> resolves ITS
|
||||||
|
/// guid specifically. <b>Live-run finding (second gate attempt):</b> the
|
||||||
|
/// simpler "nearest ANY other player" query
|
||||||
|
/// (<see cref="RuntimeFriendlyTargetQuery.FindClosestOtherPlayer"/>) is
|
||||||
|
/// NOT sufficient on a shared ACE dev instance — a third, unrelated
|
||||||
|
/// character was online and nearer than the actual Recruit bot after
|
||||||
|
/// <c>@teleallto</c>, and the fellowship recruit sent to it obviously never
|
||||||
|
/// completed. Name-matching is the fix, not a workaround: it is the exact
|
||||||
|
/// identity a bot importing its own account already knows for certain.
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|
@ -847,6 +865,32 @@ internal sealed class JumpProbeHeadlessBotPolicy : IHeadlessBotPolicy
|
||||||
/// two-session assertion per the campaign contract).
|
/// two-session assertion per the campaign contract).
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FA slice FA6, second gate attempt: same-process coordination
|
||||||
|
/// point between the two-bot fellowship/allegiance gate's Leader and
|
||||||
|
/// Recruit sessions, carrying exactly the data
|
||||||
|
/// <see cref="FellowshipAllegianceLeaderBotPolicy"/>'s proximity stage
|
||||||
|
/// needs and nothing else. Plain mutable property, no locking:
|
||||||
|
/// <c>HeadlessProcessScheduler</c> ticks every session's policy
|
||||||
|
/// sequentially on ONE dedicated update thread (its own class doc, and the
|
||||||
|
/// binding #368 contract this whole process model already depends on), so
|
||||||
|
/// the Leader and Recruit policy instances never read or write this
|
||||||
|
/// concurrently. Constructed once per <c>HeadlessProcessHost</c> and handed
|
||||||
|
/// to every session — harmless for non-gate sessions, which never read or
|
||||||
|
/// write it.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FellowshipAllegianceGateCoordinator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The Recruit bot's own character name, published by its own
|
||||||
|
/// <c>HeadlessSessionHost</c> the instant CharacterList selection
|
||||||
|
/// resolves it (before the Recruit bot necessarily has a local player
|
||||||
|
/// yet) — this IS the "discover it live" mechanism D8 calls for, not a
|
||||||
|
/// hard-coded config value.
|
||||||
|
/// </summary>
|
||||||
|
public string? RecruitCharacterName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
|
internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
|
||||||
{
|
{
|
||||||
private const string FellowshipName = "AcdreamFA6Gate";
|
private const string FellowshipName = "AcdreamFA6Gate";
|
||||||
|
|
@ -878,6 +922,7 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
|
||||||
private readonly Func<GameEvents.CharacterConfirmationRequest?>
|
private readonly Func<GameEvents.CharacterConfirmationRequest?>
|
||||||
_getPendingConfirmation;
|
_getPendingConfirmation;
|
||||||
private readonly Action<bool> _respondToConfirmation;
|
private readonly Action<bool> _respondToConfirmation;
|
||||||
|
private readonly FellowshipAllegianceGateCoordinator _coordinator;
|
||||||
private Stage _stage = Stage.WaitForPlayer;
|
private Stage _stage = Stage.WaitForPlayer;
|
||||||
private double _stageDeadline;
|
private double _stageDeadline;
|
||||||
private double _nextProximityAttempt;
|
private double _nextProximityAttempt;
|
||||||
|
|
@ -888,13 +933,16 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
|
||||||
internal FellowshipAllegianceLeaderBotPolicy(
|
internal FellowshipAllegianceLeaderBotPolicy(
|
||||||
GameRuntime runtime,
|
GameRuntime runtime,
|
||||||
Func<GameEvents.CharacterConfirmationRequest?> getPendingConfirmation,
|
Func<GameEvents.CharacterConfirmationRequest?> getPendingConfirmation,
|
||||||
Action<bool> respondToConfirmation)
|
Action<bool> respondToConfirmation,
|
||||||
|
FellowshipAllegianceGateCoordinator coordinator)
|
||||||
{
|
{
|
||||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||||
_getPendingConfirmation = getPendingConfirmation
|
_getPendingConfirmation = getPendingConfirmation
|
||||||
?? throw new ArgumentNullException(nameof(getPendingConfirmation));
|
?? throw new ArgumentNullException(nameof(getPendingConfirmation));
|
||||||
_respondToConfirmation = respondToConfirmation
|
_respondToConfirmation = respondToConfirmation
|
||||||
?? throw new ArgumentNullException(nameof(respondToConfirmation));
|
?? throw new ArgumentNullException(nameof(respondToConfirmation));
|
||||||
|
_coordinator = coordinator
|
||||||
|
?? throw new ArgumentNullException(nameof(coordinator));
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsComplete => _stage == Stage.Done;
|
public bool IsComplete => _stage == Stage.Done;
|
||||||
|
|
@ -1153,16 +1201,26 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
|
||||||
IGameRuntimeView view,
|
IGameRuntimeView view,
|
||||||
IGameRuntimeCommands commands)
|
IGameRuntimeCommands commands)
|
||||||
{
|
{
|
||||||
uint? found = RuntimeFriendlyTargetQuery.FindClosestOtherPlayer(_runtime);
|
// FA6 second gate attempt: NAME-matched, not "nearest any player" —
|
||||||
if (found is { } guid)
|
// a stray third character on the shared ACE dev instance was
|
||||||
|
// nearer than the actual Recruit bot after @teleallto in the first
|
||||||
|
// live retry, and the fellowship recruit sent to it never
|
||||||
|
// completed (see the class doc's live-run-finding paragraph).
|
||||||
|
string? recruitName = _coordinator.RecruitCharacterName;
|
||||||
|
if (recruitName is not null)
|
||||||
{
|
{
|
||||||
_targetGuid = guid;
|
uint? found = RuntimeFriendlyTargetQuery.FindPlayerByName(
|
||||||
string? name = RuntimeFriendlyTargetQuery.TryGetName(_runtime, guid);
|
_runtime,
|
||||||
Console.WriteLine(
|
recruitName);
|
||||||
$"[fa6-leader] proximity established: recruit guid="
|
if (found is { } guid)
|
||||||
+ $"0x{guid:X8} name='{name}'");
|
{
|
||||||
Advance(view, Stage.CreateFellowship);
|
_targetGuid = guid;
|
||||||
return;
|
Console.WriteLine(
|
||||||
|
$"[fa6-leader] proximity established: recruit guid="
|
||||||
|
+ $"0x{guid:X8} name='{recruitName}' (name-matched)");
|
||||||
|
Advance(view, Stage.CreateFellowship);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (view.Clock.SimulationTimeSeconds >= _nextProximityAttempt)
|
if (view.Clock.SimulationTimeSeconds >= _nextProximityAttempt)
|
||||||
|
|
@ -1173,7 +1231,9 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
|
||||||
new RuntimeChatCommand(RuntimeChatChannel.Say, "@teleallto")),
|
new RuntimeChatCommand(RuntimeChatChannel.Say, "@teleallto")),
|
||||||
"send @teleallto");
|
"send @teleallto");
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
"[fa6-leader] sent @teleallto (no friendly entity resolved yet)");
|
"[fa6-leader] sent @teleallto (recruitName="
|
||||||
|
+ $"{(recruitName is null ? "<unknown>" : $"'{recruitName}'")}, "
|
||||||
|
+ "not yet resolved as a nearby player entity)");
|
||||||
_nextProximityAttempt =
|
_nextProximityAttempt =
|
||||||
view.Clock.SimulationTimeSeconds + ProximityRetryPeriodSeconds;
|
view.Clock.SimulationTimeSeconds + ProximityRetryPeriodSeconds;
|
||||||
}
|
}
|
||||||
|
|
@ -1182,9 +1242,13 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"[fa6-leader] timed out establishing proximity to the "
|
"[fa6-leader] timed out establishing proximity to the "
|
||||||
+ "recruit bot — no other player entity ever streamed in. "
|
+ "recruit bot — "
|
||||||
+ "Either the second account never connected, or "
|
+ (recruitName is null
|
||||||
+ "@teleallto was refused.");
|
? "its character name was never discovered (the second "
|
||||||
|
+ "account never reached CharacterList selection)."
|
||||||
|
: $"'{recruitName}' never streamed in as a nearby "
|
||||||
|
+ "player entity. Either the second account never "
|
||||||
|
+ "connected, or @teleallto was refused."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,62 @@ public static class RuntimeFriendlyTargetQuery
|
||||||
return closest;
|
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>
|
/// <summary>
|
||||||
/// The streamed-in <c>WeenieHeader</c> name for <paramref name="guid"/>,
|
/// The streamed-in <c>WeenieHeader</c> name for <paramref name="guid"/>,
|
||||||
/// or <see langword="null"/> when the entity is not currently resolvable
|
/// or <see langword="null"/> when the entity is not currently resolvable
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,106 @@ public sealed class RuntimeFriendlyTargetQueryTests
|
||||||
Assert.Null(RuntimeFriendlyTargetQuery.TryGetName(runtime, 0x50000099u));
|
Assert.Null(RuntimeFriendlyTargetQuery.TryGetName(runtime, 0x50000099u));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FA6 second gate attempt (live-run finding): a shared ACE dev
|
||||||
|
/// instance can have a THIRD player online and NEARER than the actual
|
||||||
|
/// expected counterpart — <see cref="RuntimeFriendlyTargetQuery.FindClosestOtherPlayer"/>
|
||||||
|
/// picked the wrong one live. <see cref="RuntimeFriendlyTargetQuery.FindPlayerByName"/>
|
||||||
|
/// must reject the closer stranger and resolve the named target even
|
||||||
|
/// though it is farther away.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void FindPlayerByName_PrefersTheNamedPlayerOverACloserStranger()
|
||||||
|
{
|
||||||
|
using GameRuntime runtime = Create();
|
||||||
|
runtime.PlayerIdentity.ServerGuid = Player;
|
||||||
|
Add(runtime, Player, 0x01010001u, 10f, 10f, "Self", PlayerBit);
|
||||||
|
Add(
|
||||||
|
runtime,
|
||||||
|
0x50000010u,
|
||||||
|
0x01010001u,
|
||||||
|
11f,
|
||||||
|
10f,
|
||||||
|
"+Je",
|
||||||
|
PlayerBit);
|
||||||
|
Add(
|
||||||
|
runtime,
|
||||||
|
0x50000011u,
|
||||||
|
0x01010001u,
|
||||||
|
30f,
|
||||||
|
10f,
|
||||||
|
"+Horan",
|
||||||
|
PlayerBit);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
0x50000011u,
|
||||||
|
RuntimeFriendlyTargetQuery.FindPlayerByName(runtime, "+Horan"));
|
||||||
|
// The plain nearest-any-player query still (correctly) prefers the
|
||||||
|
// closer stranger — this test pins that FindPlayerByName diverges
|
||||||
|
// from it deliberately, not that the sibling method is "buggy".
|
||||||
|
Assert.Equal(
|
||||||
|
0x50000010u,
|
||||||
|
RuntimeFriendlyTargetQuery.FindClosestOtherPlayer(runtime));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindPlayerByName_ReturnsNullWhenNoPlayerHasThatName()
|
||||||
|
{
|
||||||
|
using GameRuntime runtime = Create();
|
||||||
|
runtime.PlayerIdentity.ServerGuid = Player;
|
||||||
|
Add(runtime, Player, 0x01010001u, 10f, 10f, "Self", PlayerBit);
|
||||||
|
Add(runtime, 0x50000010u, 0x01010001u, 11f, 10f, "+Je", PlayerBit);
|
||||||
|
|
||||||
|
Assert.Null(
|
||||||
|
RuntimeFriendlyTargetQuery.FindPlayerByName(runtime, "+Horan"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindPlayerByName_IsCaseSensitiveAndSkipsHiddenNoDrawAndSelf()
|
||||||
|
{
|
||||||
|
using GameRuntime runtime = Create();
|
||||||
|
runtime.PlayerIdentity.ServerGuid = Player;
|
||||||
|
Add(runtime, Player, 0x01010001u, 10f, 10f, "+Horan", PlayerBit);
|
||||||
|
Add(
|
||||||
|
runtime,
|
||||||
|
0x50000010u,
|
||||||
|
0x01010001u,
|
||||||
|
11f,
|
||||||
|
10f,
|
||||||
|
"+horan",
|
||||||
|
PlayerBit);
|
||||||
|
Add(
|
||||||
|
runtime,
|
||||||
|
0x50000011u,
|
||||||
|
0x01010001u,
|
||||||
|
12f,
|
||||||
|
10f,
|
||||||
|
"+Horan",
|
||||||
|
PlayerBit,
|
||||||
|
PhysicsStateFlags.Hidden);
|
||||||
|
Add(
|
||||||
|
runtime,
|
||||||
|
0x50000012u,
|
||||||
|
0x01010001u,
|
||||||
|
13f,
|
||||||
|
10f,
|
||||||
|
"+Horan",
|
||||||
|
PlayerBit,
|
||||||
|
PhysicsStateFlags.NoDraw);
|
||||||
|
Add(
|
||||||
|
runtime,
|
||||||
|
0x50000013u,
|
||||||
|
0x01010001u,
|
||||||
|
14f,
|
||||||
|
10f,
|
||||||
|
"+Horan",
|
||||||
|
PlayerBit);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
0x50000013u,
|
||||||
|
RuntimeFriendlyTargetQuery.FindPlayerByName(runtime, "+Horan"));
|
||||||
|
}
|
||||||
|
|
||||||
private static GameRuntime Create()
|
private static GameRuntime Create()
|
||||||
{
|
{
|
||||||
var operations = new Operations();
|
var operations = new Operations();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue