refactor(runtime): extract local teleport and player mode

Move local teleport, player-mode, animation, shadow, and sealed-dungeon lifetimes out of GameWindow. Make player-mode entry transactional and isolate approach completions by controller lifetime and approach generation so stale callbacks cannot affect replacements.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-22 02:50:15 +02:00
parent c557038353
commit eeb0f6b45c
29 changed files with 3311 additions and 1073 deletions

View file

@ -0,0 +1,141 @@
using AcDream.Core.Physics;
namespace AcDream.App.Interaction;
internal interface IPlayerApproachCompletionSink
{
void PublishNaturalCompletion();
void PublishCancellation(WeenieError error);
}
internal interface IPlayerApproachCompletionLifetimeOwner
{
IPlayerApproachCompletionSink BeginControllerLifetime();
void RetireControllerLifetime(IPlayerApproachCompletionSink lifetime);
}
internal interface IPlayerApproachTokenSource
{
bool TryBeginApproach(out PlayerApproachToken token);
}
internal readonly record struct PlayerApproachToken(
ulong ControllerLifetime,
ulong ApproachGeneration);
internal readonly record struct PlayerApproachCompletion(
PlayerApproachToken Token,
bool IsNatural,
WeenieError Error);
/// <summary>
/// Same-thread mailbox between local MoveTo completion and the selection/
/// item interaction phase. It deliberately owns no query, item, transport,
/// window, or callback reference.
/// </summary>
internal sealed class PlayerApproachCompletionState
: IPlayerApproachCompletionLifetimeOwner,
IPlayerApproachTokenSource
{
private readonly Queue<PlayerApproachCompletion> _pending = new();
private ControllerLifetime? _active;
private ulong _nextLifetime;
public IPlayerApproachCompletionSink BeginControllerLifetime()
{
if (_active is not null)
throw new InvalidOperationException(
"A player approach-completion lifetime is already active.");
var lifetime = new ControllerLifetime(this, ++_nextLifetime);
_active = lifetime;
return lifetime;
}
public void RetireControllerLifetime(IPlayerApproachCompletionSink lifetime)
{
ArgumentNullException.ThrowIfNull(lifetime);
if (!ReferenceEquals(_active, lifetime)
|| lifetime is not ControllerLifetime retiring)
return;
PurgeLifetime(retiring.LifetimeId);
if (retiring.TryGetCurrentToken(out PlayerApproachToken token))
{
_pending.Enqueue(new PlayerApproachCompletion(
token,
IsNatural: false,
WeenieError.ActionCancelled));
}
_active = null;
}
public bool TryBeginApproach(out PlayerApproachToken token)
{
if (_active is null)
{
token = default;
return false;
}
token = _active.BeginApproach();
return true;
}
public bool TryTake(out PlayerApproachCompletion completion) =>
_pending.TryDequeue(out completion);
public void Clear()
{
_active = null;
_pending.Clear();
}
private void Publish(
ControllerLifetime lifetime,
bool isNatural,
WeenieError error)
{
if (!ReferenceEquals(_active, lifetime)
|| !lifetime.TryGetCurrentToken(out PlayerApproachToken token))
{
return;
}
_pending.Enqueue(new PlayerApproachCompletion(token, isNatural, error));
}
private void PurgeLifetime(ulong lifetimeId)
{
int retained = _pending.Count;
for (int i = 0; i < retained; i++)
{
PlayerApproachCompletion completion = _pending.Dequeue();
if (completion.Token.ControllerLifetime != lifetimeId)
_pending.Enqueue(completion);
}
}
private sealed class ControllerLifetime(
PlayerApproachCompletionState owner,
ulong lifetimeId) : IPlayerApproachCompletionSink
{
private ulong _approachGeneration;
public ulong LifetimeId => lifetimeId;
public PlayerApproachToken BeginApproach() =>
new(lifetimeId, ++_approachGeneration);
public bool TryGetCurrentToken(out PlayerApproachToken token)
{
token = new PlayerApproachToken(lifetimeId, _approachGeneration);
return _approachGeneration != 0;
}
public void PublishNaturalCompletion() =>
owner.Publish(this, isNatural: true, WeenieError.None);
public void PublishCancellation(WeenieError error) =>
owner.Publish(this, isNatural: false, error);
}
}

View file

@ -12,7 +12,9 @@ internal interface IPlayerInteractionMovementSink
/// owner arm completion state before an already-facing TurnTo can finish
/// synchronously, without letting the preceding cancellation clear it.
/// </summary>
bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null);
bool BeginApproach(
InteractionApproach approach,
Action<PlayerApproachToken>? armAfterCancel = null);
}
/// <summary>
@ -20,13 +22,18 @@ internal interface IPlayerInteractionMovementSink
/// the same MovementManager used by authoritative movement packets.
/// </summary>
internal sealed class PlayerInteractionMovementSink(
Func<PlayerMovementController?> player)
Func<PlayerMovementController?> player,
IPlayerApproachTokenSource approachTokens)
: IPlayerInteractionMovementSink
{
private readonly Func<PlayerMovementController?> _player = player
?? throw new ArgumentNullException(nameof(player));
private readonly IPlayerApproachTokenSource _approachTokens = approachTokens
?? throw new ArgumentNullException(nameof(approachTokens));
public bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null)
public bool BeginApproach(
InteractionApproach approach,
Action<PlayerApproachToken>? armAfterCancel = null)
{
PlayerMovementController? controller = _player();
if (controller?.MoveTo is null)
@ -57,7 +64,9 @@ internal sealed class PlayerInteractionMovementSink(
// intent is armed so cancellation of the preceding move cannot clear
// the new request; the internal second call is then a retail no-op.
controller.Movement.CancelMoveTo(WeenieError.ActionCancelled);
armAfterCancel?.Invoke();
if (!_approachTokens.TryBeginApproach(out PlayerApproachToken token))
return false;
armAfterCancel?.Invoke(token);
// P1's wire MoveToObject store marks the command non-autonomous.
// The speculative local install must do the same or the next raw-input

View file

@ -22,6 +22,7 @@ internal sealed class SelectionInteractionController
private readonly ISelectionInteractionTransport _transport;
private readonly IPlayerInteractionMovementSink _movement;
private readonly OutboundInteractionQueue _outbound = new();
private readonly PlayerApproachCompletionState _approachCompletions;
private readonly Action<string>? _toast;
private PendingPostArrivalAction? _pendingPostArrival;
@ -36,7 +37,8 @@ internal sealed class SelectionInteractionController
bool IsPickup,
uint DestinationContainerId,
int Placement,
ulong PendingPlacementToken);
ulong PendingPlacementToken,
PlayerApproachToken ApproachToken);
public SelectionInteractionController(
SelectionState selection,
@ -44,7 +46,8 @@ internal sealed class SelectionInteractionController
ItemInteractionController items,
ISelectionInteractionTransport transport,
IPlayerInteractionMovementSink movement,
Action<string>? toast = null)
Action<string>? toast = null,
PlayerApproachCompletionState? approachCompletions = null)
{
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_query = query ?? throw new ArgumentNullException(nameof(query));
@ -52,6 +55,8 @@ internal sealed class SelectionInteractionController
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_movement = movement ?? throw new ArgumentNullException(nameof(movement));
_toast = toast;
_approachCompletions = approachCompletions
?? new PlayerApproachCompletionState();
}
public bool HandleInputAction(InputAction action)
@ -213,11 +218,12 @@ internal sealed class SelectionInteractionController
IsPickup: false,
DestinationContainerId: 0u,
Placement: 0,
PendingPlacementToken: 0u);
PendingPlacementToken: 0u,
ApproachToken: default);
bool started = _movement.BeginApproach(
approach,
() => _pendingPostArrival = pending);
if (!started && _pendingPostArrival == pending)
token => _pendingPostArrival = pending with { ApproachToken = token });
if (!started && _pendingPostArrival?.ServerGuid == pending.ServerGuid)
CancelPendingApproach();
return;
}
@ -265,11 +271,12 @@ internal sealed class SelectionInteractionController
IsPickup: true,
destinationContainerId,
placement,
pendingPlacementToken);
pendingPlacementToken,
ApproachToken: default);
bool started = _movement.BeginApproach(
approach,
() => _pendingPostArrival = pending);
if (!started && _pendingPostArrival == pending)
token => _pendingPostArrival = pending with { ApproachToken = token });
if (!started && _pendingPostArrival?.ServerGuid == pending.ServerGuid)
CancelPendingApproach();
else if (!started)
CancelPickupPresentation(itemGuid, pendingPlacementToken);
@ -307,6 +314,9 @@ internal sealed class SelectionInteractionController
/// <summary>Fires only after natural MoveToComplete(None), never cancellation.</summary>
public void OnNaturalMoveToComplete()
=> HandleNaturalMoveToComplete();
private void HandleNaturalMoveToComplete()
{
if (_pendingPostArrival is not { } pending)
return;
@ -351,7 +361,19 @@ internal sealed class SelectionInteractionController
}
}
public void DrainOutbound() => _outbound.Drain();
public void DrainOutbound()
{
while (_approachCompletions.TryTake(out PlayerApproachCompletion completion))
{
if (_pendingPostArrival?.ApproachToken != completion.Token)
continue;
if (completion.IsNatural)
HandleNaturalMoveToComplete();
else
CancelPendingApproach();
}
_outbound.Drain();
}
public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach();
@ -395,6 +417,8 @@ internal sealed class SelectionInteractionController
catch (Exception error) { failures.Add(error); }
try { _outbound.Clear(); }
catch (Exception error) { failures.Add(error); }
try { _approachCompletions.Clear(); }
catch (Exception error) { failures.Add(error); }
_pendingPostArrival = null;
if (failures.Count != 0)