fix(session): acknowledge login after first placement
ACE intentionally creates the local player Hidden and releases that materialization state on LoginComplete. Sending LoginComplete from raw F746 receipt raced canonical placement and left the login haze visible. Route one one-shot completion callback from Runtime's local first-entry terminal edge to graphical and prepared headless hosts; retain a guarded accepted-Create edge only for content-less headless sessions. Focused Runtime login tests, all 79 Headless tests, the connected user gate, and the Release build pass.
This commit is contained in:
parent
f24532adf3
commit
175ad6b0d0
10 changed files with 107 additions and 48 deletions
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Physics;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
|||
private readonly Func<RuntimeGenerationToken> _generation;
|
||||
private readonly RuntimePlacementProjectionRetrySlot _retries;
|
||||
private readonly RuntimeFirstEntryDriveController? _firstEntry;
|
||||
private readonly Action<RuntimeEntityRecord>? _localPlayerCompleted;
|
||||
private RuntimePlacementProjectionSubscription? _subscription;
|
||||
private IDisposable? _retryLease;
|
||||
private bool _attachStarted;
|
||||
|
|
@ -27,7 +29,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
|||
GameRuntime runtime,
|
||||
IRuntimePlacementProjectionSink placements,
|
||||
RuntimePlacementProjectionRetrySlot retries,
|
||||
RuntimeFirstEntryDriveController? firstEntry = null)
|
||||
RuntimeFirstEntryDriveController? firstEntry = null,
|
||||
Action<RuntimeEntityRecord>? localPlayerCompleted = null)
|
||||
: this(
|
||||
events,
|
||||
() => new RuntimePlacementProjectionSubscription(
|
||||
|
|
@ -36,7 +39,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
|||
retryPendingOnSubscribe: false),
|
||||
() => runtime.Generation,
|
||||
retries,
|
||||
firstEntry)
|
||||
firstEntry,
|
||||
localPlayerCompleted)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(placements);
|
||||
|
|
@ -47,7 +51,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
|||
Func<RuntimePlacementProjectionSubscription> createSubscription,
|
||||
Func<RuntimeGenerationToken> generation,
|
||||
RuntimePlacementProjectionRetrySlot retries,
|
||||
RuntimeFirstEntryDriveController? firstEntry = null)
|
||||
RuntimeFirstEntryDriveController? firstEntry = null,
|
||||
Action<RuntimeEntityRecord>? localPlayerCompleted = null)
|
||||
{
|
||||
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||
_createSubscription = createSubscription
|
||||
|
|
@ -56,6 +61,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
|||
?? throw new ArgumentNullException(nameof(generation));
|
||||
_retries = retries ?? throw new ArgumentNullException(nameof(retries));
|
||||
_firstEntry = firstEntry;
|
||||
_localPlayerCompleted = localPlayerCompleted;
|
||||
}
|
||||
|
||||
public void Attach()
|
||||
|
|
@ -68,7 +74,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
|||
// C3c-R1 review F6: assert (not assume) that the prior route
|
||||
// detached — session reset precedes a new route — before this route
|
||||
// takes ownership of the shared drive controller's tracked entries.
|
||||
_firstEntry?.AttachRoute(this);
|
||||
_firstEntry?.AttachRoute(this, _localPlayerCompleted);
|
||||
_events.Attach();
|
||||
|
||||
RuntimePlacementProjectionSubscription? subscription = null;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ using AcDream.Core.Chat;
|
|||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Social;
|
||||
using AcDream.Core.Spells;
|
||||
|
|
@ -262,7 +263,8 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
_domain.Runtime,
|
||||
_world.PlacementProjection,
|
||||
_world.PlacementRetries,
|
||||
_world.FirstEntryDrive);
|
||||
_world.FirstEntryDrive,
|
||||
_ => session.SendGameAction(GameActionLoginComplete.Build()));
|
||||
}
|
||||
|
||||
private LiveInventorySessionBindings CreateInventoryBindings() => new(
|
||||
|
|
|
|||
|
|
@ -29,8 +29,9 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// Retail clients send it once the portal-space transition animation finishes.
|
||||
/// acdream's F751 teleport path now does the same through
|
||||
/// <c>TeleportAnimSequencer.FireLoginComplete</c>. Initial session bootstrap
|
||||
/// still sends from the PlayerCreate handler; that remaining ordering gap is
|
||||
/// tracked as TS-28 in the divergence register.
|
||||
/// sends from the canonical local-player first-placement completion edge, not
|
||||
/// from raw packet receipt, so ACE's intentional Hidden/pink-bubble state is
|
||||
/// released only after the client can actually present the player.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class GameActionLoginComplete
|
||||
|
|
|
|||
|
|
@ -555,16 +555,6 @@ public sealed class WorldSession : IDisposable
|
|||
/// </summary>
|
||||
public double LastServerTimeTicks { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allow re-sending LoginComplete after a portal teleport. The normal
|
||||
/// _loginCompleteSent latch prevents duplicate sends on the initial spawn
|
||||
/// path; this method resets it so the teleport completion path can send
|
||||
/// another LoginComplete to tell the server the client has finished loading
|
||||
/// the destination cell. Pattern from holtburger's PlayerTeleport handler
|
||||
/// (client/messages.rs line 434-440: call send_login_complete on teleport).
|
||||
/// </summary>
|
||||
public void ResetLoginComplete() => _loginCompleteSent = false;
|
||||
|
||||
/// <summary>Raised every time the state machine transitions.</summary>
|
||||
public event Action<State>? StateChanged;
|
||||
|
||||
|
|
@ -771,14 +761,6 @@ public sealed class WorldSession : IDisposable
|
|||
Buffer.AsMemory(0, Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 4.10 latch — true after we've sent the LoginComplete game
|
||||
/// action in response to PlayerCreate. Prevents re-sending if the
|
||||
/// server emits multiple PlayerCreate messages (rare but possible
|
||||
/// across recall / portal teleports).
|
||||
/// </summary>
|
||||
private bool _loginCompleteSent;
|
||||
|
||||
/// <summary>L.2g slice 1: one-shot guard so the [setstate-hex] probe
|
||||
/// emits the first SetState's body bytes only, not 5–10/sec.</summary>
|
||||
private bool _setStateHexDumped;
|
||||
|
|
@ -1061,13 +1043,10 @@ public sealed class WorldSession : IDisposable
|
|||
// login form. ACE validates this canonical account value.
|
||||
SendGameMessage(selection.EnterWorldBody);
|
||||
|
||||
// NOTE: LoginComplete used to be sent here unconditionally. That was
|
||||
// wrong — per holtburger's flow (see references/holtburger/.../client/
|
||||
// messages.rs lines 391-422), LoginComplete is sent in response to the
|
||||
// server's PlayerCreate (0xF746) game message, NOT immediately after
|
||||
// EnterWorld. Sending it too early means the player object isn't
|
||||
// ready and the server ignores it. The actual trigger lives in
|
||||
// ProcessDatagram.
|
||||
// LoginComplete is emitted by the host only after the accepted local
|
||||
// Create has completed its canonical first placement. Sending it at
|
||||
// EnterWorld or merely on PlayerCreate races the server's intentional
|
||||
// Hidden/pink-bubble login state.
|
||||
Transition(State.InWorld);
|
||||
|
||||
// Phase A.3: start the background receive thread now that the
|
||||
|
|
@ -1705,17 +1684,6 @@ public sealed class WorldSession : IDisposable
|
|||
// references/holtburger/.../client/messages.rs::DddInterrogation
|
||||
SendGameMessage(DddInterrogationResponse.Build());
|
||||
}
|
||||
else if (op == 0xF746u && !_loginCompleteSent) // PlayerCreate — server creates our player object
|
||||
{
|
||||
// Phase 4.10: PlayerCreate for our character is the cue to
|
||||
// send LoginComplete. Sending it earlier (right after the
|
||||
// outbound CharacterEnterWorld) was wrong because the server
|
||||
// hadn't finished spawning the player yet. Holtburger's
|
||||
// client/messages.rs (PlayerCreate handler) confirms this is
|
||||
// the correct trigger. Send once per session.
|
||||
_loginCompleteSent = true;
|
||||
SendGameMessage(GameActionLoginComplete.Build());
|
||||
}
|
||||
else if (op == CreateObject.Opcode)
|
||||
{
|
||||
var parsed = CreateObject.TryParse(body);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Physics;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
|
|||
private readonly GameRuntime _runtime;
|
||||
private readonly IRuntimePlacementProjectionSink _placements;
|
||||
private readonly RuntimeFirstEntryDriveController? _firstEntry;
|
||||
private readonly Action<RuntimeEntityRecord>? _localPlayerCompleted;
|
||||
private RuntimePlacementProjectionSubscription? _subscription;
|
||||
private bool _attachStarted;
|
||||
private bool _eventsDisposed;
|
||||
|
|
@ -25,13 +27,15 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
|
|||
ILiveSessionEventRouting events,
|
||||
GameRuntime runtime,
|
||||
IRuntimePlacementProjectionSink placements,
|
||||
RuntimeFirstEntryDriveController? firstEntry = null)
|
||||
RuntimeFirstEntryDriveController? firstEntry = null,
|
||||
Action<RuntimeEntityRecord>? localPlayerCompleted = null)
|
||||
{
|
||||
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
_placements = placements
|
||||
?? throw new ArgumentNullException(nameof(placements));
|
||||
_firstEntry = firstEntry;
|
||||
_localPlayerCompleted = localPlayerCompleted;
|
||||
}
|
||||
|
||||
public void Attach()
|
||||
|
|
@ -47,7 +51,7 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
|
|||
// C3c-R1 review F6: assert (not assume) that the prior route
|
||||
// detached — session reset precedes a new route — before this route
|
||||
// takes ownership of the shared drive controller's tracked entries.
|
||||
_firstEntry?.AttachRoute(this);
|
||||
_firstEntry?.AttachRoute(this, _localPlayerCompleted);
|
||||
_events.Attach();
|
||||
_subscription = new RuntimePlacementProjectionSubscription(
|
||||
_runtime,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using AcDream.Headless.Configuration;
|
|||
using AcDream.Headless.Credentials;
|
||||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Headless.Policies;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Physics;
|
||||
|
|
@ -618,7 +619,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
route,
|
||||
Runtime,
|
||||
new HeadlessRuntimePlacementProjectionSink(Runtime),
|
||||
_firstEntryDrive);
|
||||
_firstEntryDrive,
|
||||
_ => session.SendGameAction(GameActionLoginComplete.Build()));
|
||||
}
|
||||
|
||||
private static LiveSessionCharacterSelector MapCharacterSelector(
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ internal sealed class RuntimeFirstEntryDriveController
|
|||
private bool _driving;
|
||||
/// <summary>C3c-R1 review F6: see <see cref="AttachRoute"/>.</summary>
|
||||
private object? _routeOwner;
|
||||
private Action<RuntimeEntityRecord>? _localPlayerCompleted;
|
||||
|
||||
internal RuntimeFirstEntryDriveController(
|
||||
RuntimeEntityObjectLifetime entityObjects,
|
||||
|
|
@ -155,7 +156,9 @@ internal sealed class RuntimeFirstEntryDriveController
|
|||
/// before the prior route detached would otherwise let the OLD route's
|
||||
/// dispose wipe the NEW route's tracked entries.
|
||||
/// </summary>
|
||||
internal void AttachRoute(object route)
|
||||
internal void AttachRoute(
|
||||
object route,
|
||||
Action<RuntimeEntityRecord>? localPlayerCompleted = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(route);
|
||||
if (_routeOwner is not null && !ReferenceEquals(_routeOwner, route))
|
||||
|
|
@ -166,6 +169,7 @@ internal sealed class RuntimeFirstEntryDriveController
|
|||
+ "precedes a new route) before a replacement attaches.");
|
||||
}
|
||||
_routeOwner = route;
|
||||
_localPlayerCompleted = localPlayerCompleted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -181,6 +185,7 @@ internal sealed class RuntimeFirstEntryDriveController
|
|||
if (!ReferenceEquals(_routeOwner, route))
|
||||
return;
|
||||
_routeOwner = null;
|
||||
_localPlayerCompleted = null;
|
||||
_pending.Clear();
|
||||
}
|
||||
|
||||
|
|
@ -198,6 +203,7 @@ internal sealed class RuntimeFirstEntryDriveController
|
|||
|
||||
bool terminal;
|
||||
bool awaitingContinuationPlacement;
|
||||
bool localPlayerCompleted = false;
|
||||
if (pending.IsLocalPlayer)
|
||||
{
|
||||
RuntimeLocalPlayerFirstEntryStatus status =
|
||||
|
|
@ -214,6 +220,8 @@ internal sealed class RuntimeFirstEntryDriveController
|
|||
is RuntimeLocalPlayerFirstEntryStatus.Completed
|
||||
or RuntimeLocalPlayerFirstEntryStatus.RejectedToken
|
||||
or RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority;
|
||||
localPlayerCompleted = status
|
||||
is RuntimeLocalPlayerFirstEntryStatus.Completed;
|
||||
awaitingContinuationPlacement = status
|
||||
is RuntimeLocalPlayerFirstEntryStatus
|
||||
.AwaitingContinuationPlacement;
|
||||
|
|
@ -241,6 +249,8 @@ internal sealed class RuntimeFirstEntryDriveController
|
|||
if (terminal)
|
||||
{
|
||||
_pending.Remove(key);
|
||||
if (localPlayerCompleted)
|
||||
_localPlayerCompleted?.Invoke(pending.Record);
|
||||
return;
|
||||
}
|
||||
if (!awaitingContinuationPlacement)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
private readonly IRuntimeDirectWorldProjection? _worldProjection;
|
||||
private readonly LocalPlayerOutboundController _localPlayerOutbound =
|
||||
new((_, _, _, _, _, _) => { });
|
||||
private bool _initialLoginCompleteSent;
|
||||
|
||||
public RuntimeLiveEntitySessionController(
|
||||
GameRuntime runtime,
|
||||
|
|
@ -111,6 +112,17 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
canonical,
|
||||
canonical.ServerGuid
|
||||
== _runtime.PlayerIdentity.ServerGuid);
|
||||
if (_worldProjection is null
|
||||
&& canonical.ServerGuid
|
||||
== _runtime.PlayerIdentity.ServerGuid
|
||||
&& !_initialLoginCompleteSent)
|
||||
{
|
||||
// A content-less direct host has no first-entry placement
|
||||
// conductor. Its accepted local Create is therefore its
|
||||
// truthful terminal admission edge.
|
||||
_initialLoginCompleteSent = true;
|
||||
_session.SendGameAction(GameActionLoginComplete.Build());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue