diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs index daf02576..4d4d1b83 100644 --- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs @@ -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 { diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index dddd120f..66e096e2 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -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, diff --git a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs index 76a32217..2a85ffe3 100644 --- a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs +++ b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs @@ -36,7 +36,8 @@ internal static class HeadlessBotPolicyFactory GameRuntime runtime, Func? getPendingConfirmation = null, - Action? respondToConfirmation = null) + Action? respondToConfirmation = null, + FellowshipAllegianceGateCoordinator? gateCoordinator = null) { ArgumentNullException.ThrowIfNull(descriptor); ArgumentNullException.ThrowIfNull(runtime); @@ -52,7 +53,8 @@ internal static class HeadlessBotPolicyFactory descriptor, runtime, getPendingConfirmation, - respondToConfirmation), + respondToConfirmation, + gateCoordinator), _ => throw new HeadlessConfigurationException( $"Unknown headless bot policy '{descriptor.Id}'."), }; @@ -62,7 +64,8 @@ internal static class HeadlessBotPolicyFactory HeadlessBotPolicyDescriptor descriptor, GameRuntime runtime, Func? getPendingConfirmation, - Action? respondToConfirmation) + Action? respondToConfirmation, + FellowshipAllegianceGateCoordinator? gateCoordinator) { if (descriptor.Role is not { } role) { @@ -71,12 +74,15 @@ internal static class HeadlessBotPolicyFactory + "('leader' or 'recruit')."); } if (role == HeadlessBotPolicyRole.Leader - && (getPendingConfirmation is null || respondToConfirmation is null)) + && (getPendingConfirmation is null + || respondToConfirmation is null + || gateCoordinator is null)) { throw new HeadlessConfigurationException( "Policy 'fellowship-allegiance-gate' role 'leader' requires " + "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 { @@ -84,7 +90,8 @@ internal static class HeadlessBotPolicyFactory new FellowshipAllegianceLeaderBotPolicy( runtime, getPendingConfirmation!, - respondToConfirmation!), + respondToConfirmation!, + gateCoordinator!), HeadlessBotPolicyRole.Recruit => new FellowshipAllegianceRecruitBotPolicy(runtime), _ => throw new HeadlessConfigurationException( @@ -817,15 +824,26 @@ internal sealed class JumpProbeHeadlessBotPolicy : IHeadlessBotPolicy /// /// /// Proximity (D8, load-bearing — recruit fails without it). Neither -/// bot's config declares the other's character name (D8's own instruction: -/// discover it live, never hard-code), so this bot cannot @teleto -/// <name> the Recruit bot directly. It uses retail's admin -/// @teleallto instead (no target = "teleport everyone online to -/// me") — a single idempotent GM command needing no cross-session name -/// sharing at all; safe to retry blindly before the Recruit bot has even -/// connected (a lone player teleporting to themselves is a no-op). Once the -/// Recruit bot is colocated, -/// resolves its guid from the ordinary entity-streaming radius. +/// bot's config hard-codes the other's character name (D8's own +/// instruction: discover it live) — this bot cannot @teleto +/// <name> the Recruit bot directly until it learns that name, so +/// it uses retail's admin @teleallto instead (no target = "teleport +/// everyone online to me") — a single idempotent GM command needing no +/// cross-session data to ISSUE; safe to retry blindly before the Recruit +/// bot has even connected (a lone player teleporting to themselves is a +/// no-op). then supplies +/// the Recruit bot's OWN discovered name (set by its own +/// HeadlessSessionHost the moment CharacterList selection resolves +/// it — the actual "discover it live" mechanism), and +/// resolves ITS +/// guid specifically. Live-run finding (second gate attempt): the +/// simpler "nearest ANY other player" query +/// () is +/// NOT sufficient on a shared ACE dev instance — a third, unrelated +/// character was online and nearer than the actual Recruit bot after +/// @teleallto, 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. /// /// /// @@ -847,6 +865,32 @@ internal sealed class JumpProbeHeadlessBotPolicy : IHeadlessBotPolicy /// two-session assertion per the campaign contract). /// /// +/// +/// 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 +/// 's proximity stage +/// needs and nothing else. Plain mutable property, no locking: +/// HeadlessProcessScheduler 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 HeadlessProcessHost and handed +/// to every session — harmless for non-gate sessions, which never read or +/// write it. +/// +internal sealed class FellowshipAllegianceGateCoordinator +{ + /// + /// The Recruit bot's own character name, published by its own + /// HeadlessSessionHost 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. + /// + public string? RecruitCharacterName { get; set; } +} + internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy { private const string FellowshipName = "AcdreamFA6Gate"; @@ -878,6 +922,7 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy private readonly Func _getPendingConfirmation; private readonly Action _respondToConfirmation; + private readonly FellowshipAllegianceGateCoordinator _coordinator; private Stage _stage = Stage.WaitForPlayer; private double _stageDeadline; private double _nextProximityAttempt; @@ -888,13 +933,16 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy internal FellowshipAllegianceLeaderBotPolicy( GameRuntime runtime, Func getPendingConfirmation, - Action respondToConfirmation) + Action respondToConfirmation, + FellowshipAllegianceGateCoordinator coordinator) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _getPendingConfirmation = getPendingConfirmation ?? throw new ArgumentNullException(nameof(getPendingConfirmation)); _respondToConfirmation = respondToConfirmation ?? throw new ArgumentNullException(nameof(respondToConfirmation)); + _coordinator = coordinator + ?? throw new ArgumentNullException(nameof(coordinator)); } public bool IsComplete => _stage == Stage.Done; @@ -1153,16 +1201,26 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy IGameRuntimeView view, IGameRuntimeCommands commands) { - uint? found = RuntimeFriendlyTargetQuery.FindClosestOtherPlayer(_runtime); - if (found is { } guid) + // FA6 second gate attempt: NAME-matched, not "nearest any player" — + // 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; - string? name = RuntimeFriendlyTargetQuery.TryGetName(_runtime, guid); - Console.WriteLine( - $"[fa6-leader] proximity established: recruit guid=" - + $"0x{guid:X8} name='{name}'"); - Advance(view, Stage.CreateFellowship); - return; + uint? found = RuntimeFriendlyTargetQuery.FindPlayerByName( + _runtime, + recruitName); + if (found is { } guid) + { + _targetGuid = guid; + 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) @@ -1173,7 +1231,9 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy new RuntimeChatCommand(RuntimeChatChannel.Say, "@teleallto")), "send @teleallto"); Console.WriteLine( - "[fa6-leader] sent @teleallto (no friendly entity resolved yet)"); + "[fa6-leader] sent @teleallto (recruitName=" + + $"{(recruitName is null ? "" : $"'{recruitName}'")}, " + + "not yet resolved as a nearby player entity)"); _nextProximityAttempt = view.Clock.SimulationTimeSeconds + ProximityRetryPeriodSeconds; } @@ -1182,9 +1242,13 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy { throw new InvalidOperationException( "[fa6-leader] timed out establishing proximity to the " - + "recruit bot — no other player entity ever streamed in. " - + "Either the second account never connected, or " - + "@teleallto was refused."); + + "recruit bot — " + + (recruitName is null + ? "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.")); } } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeFriendlyTargetQuery.cs b/src/AcDream.Runtime/Gameplay/RuntimeFriendlyTargetQuery.cs index db4b6e47..59e7d81e 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeFriendlyTargetQuery.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeFriendlyTargetQuery.cs @@ -77,6 +77,62 @@ public static class RuntimeFriendlyTargetQuery return closest; } + /// + /// The nearest OTHER player entity whose streamed name matches + /// exactly (ordinal), or + /// 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 @teleallto 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. + /// + 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; + } + /// /// The streamed-in WeenieHeader name for , /// or when the entity is not currently resolvable diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeFriendlyTargetQueryTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeFriendlyTargetQueryTests.cs index a4faf630..7e4a15f7 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeFriendlyTargetQueryTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeFriendlyTargetQueryTests.cs @@ -141,6 +141,106 @@ public sealed class RuntimeFriendlyTargetQueryTests Assert.Null(RuntimeFriendlyTargetQuery.TryGetName(runtime, 0x50000099u)); } + /// + /// 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 — + /// picked the wrong one live. + /// must reject the closer stranger and resolve the named target even + /// though it is farther away. + /// + [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() { var operations = new Operations();