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,79 @@
using AcDream.App.Interaction;
using AcDream.Core.Physics;
namespace AcDream.App.Tests.Interaction;
public sealed class PlayerApproachCompletionStateTests
{
[Fact]
public void CompletionMailbox_PreservesPublicationOrder()
{
var state = new PlayerApproachCompletionState();
IPlayerApproachCompletionSink lifetime = state.BeginControllerLifetime();
Assert.True(state.TryBeginApproach(out PlayerApproachToken token));
lifetime.PublishNaturalCompletion();
lifetime.PublishCancellation(WeenieError.NoObject);
Assert.True(state.TryTake(out PlayerApproachCompletion natural));
Assert.Equal(token, natural.Token);
Assert.True(natural.IsNatural);
Assert.Equal(WeenieError.None, natural.Error);
Assert.True(state.TryTake(out PlayerApproachCompletion cancellation));
Assert.False(cancellation.IsNatural);
Assert.Equal(WeenieError.NoObject, cancellation.Error);
Assert.False(state.TryTake(out _));
}
[Fact]
public void Clear_DiscardsEveryPendingCompletion()
{
var state = new PlayerApproachCompletionState();
IPlayerApproachCompletionSink lifetime = state.BeginControllerLifetime();
Assert.True(state.TryBeginApproach(out _));
lifetime.PublishNaturalCompletion();
lifetime.PublishCancellation(WeenieError.ActionCancelled);
state.Clear();
Assert.False(state.TryTake(out _));
}
[Fact]
public void RetiredLifetime_CannotPublishIntoReplacementLifetime()
{
var state = new PlayerApproachCompletionState();
IPlayerApproachCompletionSink stale = state.BeginControllerLifetime();
Assert.True(state.TryBeginApproach(out PlayerApproachToken staleToken));
state.RetireControllerLifetime(stale);
Assert.True(state.TryTake(out PlayerApproachCompletion retirement));
Assert.Equal(staleToken, retirement.Token);
Assert.False(retirement.IsNatural);
IPlayerApproachCompletionSink current = state.BeginControllerLifetime();
Assert.True(state.TryBeginApproach(out PlayerApproachToken currentToken));
stale.PublishNaturalCompletion();
current.PublishNaturalCompletion();
Assert.True(state.TryTake(out PlayerApproachCompletion completion));
Assert.Equal(currentToken, completion.Token);
Assert.False(state.TryTake(out _));
}
[Fact]
public void Retirement_ReplacesQueuedSuccessWithMatchingCancellation()
{
var state = new PlayerApproachCompletionState();
IPlayerApproachCompletionSink lifetime = state.BeginControllerLifetime();
Assert.True(state.TryBeginApproach(out PlayerApproachToken token));
lifetime.PublishNaturalCompletion();
state.RetireControllerLifetime(lifetime);
Assert.True(state.TryTake(out PlayerApproachCompletion completion));
Assert.Equal(token, completion.Token);
Assert.False(completion.IsNatural);
Assert.Equal(WeenieError.ActionCancelled, completion.Error);
Assert.False(state.TryTake(out _));
}
}

View file

@ -16,10 +16,11 @@ public sealed class PlayerInteractionMovementSinkTests
[Fact]
public void MissingPlayerDoesNotArmTheIntent()
{
var sink = new PlayerInteractionMovementSink(() => null);
var completions = new PlayerApproachCompletionState();
var sink = new PlayerInteractionMovementSink(() => null, completions);
bool armed = false;
Assert.False(sink.BeginApproach(Approach(closeRange: true), () => armed = true));
Assert.False(sink.BeginApproach(Approach(closeRange: true), _ => armed = true));
Assert.False(armed);
}
@ -66,11 +67,15 @@ public sealed class PlayerInteractionMovementSinkTests
new Position(Cell, new Vector3(2f, 0f, 0f), Quaternion.Identity),
new MovementParameters { UseSpheres = false });
bool armedAfterCancellation = false;
var sink = new PlayerInteractionMovementSink(() => controller);
var completions = new PlayerApproachCompletionState();
_ = completions.BeginControllerLifetime();
var sink = new PlayerInteractionMovementSink(
() => controller,
completions);
Assert.True(sink.BeginApproach(
Approach(closeRange),
() => armedAfterCancellation = cancellations == 1 && !moveTo.IsMovingTo()));
_ => armedAfterCancellation = cancellations == 1 && !moveTo.IsMovingTo()));
Assert.True(armedAfterCancellation);
Assert.True(nonAutonomousAtTargetInstall);

View file

@ -104,17 +104,20 @@ public sealed class SelectionInteractionControllerTests
}
}
private sealed class Movement : IPlayerInteractionMovementSink
private sealed class Movement(IPlayerApproachTokenSource approachTokens)
: IPlayerInteractionMovementSink
{
public List<InteractionApproach> Approaches { get; } = new();
public bool Starts { get; set; } = true;
public Action? AfterArm { get; set; }
public bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null)
public bool BeginApproach(
InteractionApproach approach,
Action<PlayerApproachToken>? armAfterCancel = null)
{
Approaches.Add(approach);
if (!Starts)
if (!Starts || !approachTokens.TryBeginApproach(out PlayerApproachToken token))
return false;
armAfterCancel?.Invoke();
armAfterCancel?.Invoke(token);
AfterArm?.Invoke();
return true;
}
@ -124,7 +127,9 @@ public sealed class SelectionInteractionControllerTests
{
public readonly Query Query = new();
public readonly Transport Transport = new();
public readonly Movement Movement = new();
public readonly PlayerApproachCompletionState Completions = new();
public readonly IPlayerApproachCompletionSink CompletionLifetime;
public readonly Movement Movement;
public readonly SelectionState Selection = new();
public readonly ClientObjectTable Objects = new();
public readonly List<string> Toasts = new();
@ -136,6 +141,8 @@ public sealed class SelectionInteractionControllerTests
public Harness()
{
CompletionLifetime = Completions.BeginControllerLifetime();
Movement = new Movement(Completions);
SelectionInteractionController? controller = null;
Objects.AddOrUpdate(new ClientObject
{
@ -168,7 +175,8 @@ public sealed class SelectionInteractionControllerTests
Items,
Transport,
Movement,
Toasts.Add);
Toasts.Add,
Completions);
Items.PendingBackpackPlacementRequested += PendingPlacements.Add;
Items.PendingBackpackPlacementCancelled += CancelledPlacements.Add;
}
@ -386,6 +394,47 @@ public sealed class SelectionInteractionControllerTests
Assert.Empty(h.Transport.Uses);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void DeferredCompletionFromPriorApproachCannotAffectReplacement(
bool priorCompletedNaturally)
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Controller.SendUse(Target);
if (priorCompletedNaturally)
h.CompletionLifetime.PublishNaturalCompletion();
else
h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled);
h.Controller.SendUse(Target);
h.Controller.DrainOutbound();
Assert.Empty(h.Transport.Uses);
h.CompletionLifetime.PublishNaturalCompletion();
h.Controller.DrainOutbound();
Assert.Equal(new[] { Target }, h.Transport.Uses);
}
[Fact]
public void RetiringControllerLifetimeInvalidatesQueuedArrival()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Controller.SendUse(Target);
h.CompletionLifetime.PublishNaturalCompletion();
h.Completions.RetireControllerLifetime(h.CompletionLifetime);
h.Controller.DrainOutbound();
Assert.Empty(h.Transport.Uses);
h.Controller.OnNaturalMoveToComplete();
Assert.Empty(h.Transport.Uses);
}
[Fact]
public void FailedClosePickupStartWithdrawsPresentation()
{