From ff40656293739f9c70dfc9fcd4f3c1f884d9e1fe Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 19:03:43 +0200 Subject: [PATCH] fix(runtime): isolate Campaign LA7b delete state --- .../Session/RuntimeCharacterSelectionState.cs | 22 +++- .../Session/LiveSessionControllerTests.cs | 121 ++++++++++++++++++ 2 files changed, 139 insertions(+), 4 deletions(-) diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs index f88895bd..18bfbc93 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs @@ -466,7 +466,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable _restoreResponseArmed = false; _restoreCorrelationStartedTimestamp = 0; _flagOnlyRestoreResponseAmbiguous = true; - _operation = RuntimeCharacterSelectionOperation.None; + if (!HasConfirmedDelete()) + _operation = RuntimeCharacterSelectionOperation.None; _revision++; } Publish( @@ -533,6 +534,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable { uint characterId; uint errorCode = 0u; + bool deleteInFlight; lock (_gate) { if (_disposed @@ -553,6 +555,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable _restoreCorrelationStartedTimestamp = 0; characterId = response.Guid ?? _lastRestoreRequestedCharacterId; + deleteInFlight = HasConfirmedDelete(); if (response.IsOk && response.Guid is { } guid @@ -573,13 +576,21 @@ public sealed class RuntimeCharacterSelectionState : IDisposable string.CompareOrdinal(left.Name, right.Name)); _entries = StablePartitionGreyedToTail(_entries); } - _operation = RuntimeCharacterSelectionOperation.RestoreSucceeded; + if (!deleteInFlight) + { + _operation = + RuntimeCharacterSelectionOperation.RestoreSucceeded; + } _error = null; } else { errorCode = response.VerificationFlag; - _operation = RuntimeCharacterSelectionOperation.RestoreRejected; + if (!deleteInFlight) + { + _operation = + RuntimeCharacterSelectionOperation.RestoreRejected; + } _error = new RuntimeCharacterSelectionError( response.VerificationFlag, CharacterError.Code.Undefined, @@ -804,7 +815,10 @@ public sealed class RuntimeCharacterSelectionState : IDisposable private bool HasDeleteModalOrRequest() => _pendingDeleteCharacterId != 0u - || _operation is RuntimeCharacterSelectionOperation.DeleteRequested + || HasConfirmedDelete(); + + private bool HasConfirmedDelete() => + _operation is RuntimeCharacterSelectionOperation.DeleteRequested or RuntimeCharacterSelectionOperation.DeleteAcknowledged; private static RuntimeCharacterSelectionEntry[] StablePartitionGreyedToTail( diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index 2f0fe01d..02d8bee4 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -530,6 +530,127 @@ public sealed class LiveSessionControllerTests Assert.False(controller.IsInWorld); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RestoreCompletionDuringConfirmedDelete_PreservesDeleteUntilAck( + bool acknowledgeBeforeCompletion) + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + Assert.Equal( + LiveSessionStartStatus.AwaitingCharacterSelection, + controller.Start(LiveOptions(awaitSelection: true), host).Status); + RuntimeGenerationToken generation = controller.Generation; + + Assert.True(controller.Highlight(generation, 0x50000001u).Accepted); + Assert.True(controller.Restore(generation).Accepted); + Assert.True(controller.Highlight(generation, 0x50000002u).Accepted); + Assert.True(controller.RequestDelete(generation).Accepted); + Assert.True(controller.ConfirmDelete(generation).Accepted); + Assert.Equal([("Canonical", 1)], operations.DeleteRequests); + Assert.Equal( + RuntimeCharacterSelectionOperation.DeleteRequested, + controller.CharacterSelection.Snapshot.Operation); + Assert.Equal( + RuntimeCharacterSelectionButtons.None, + controller.CharacterSelection.Snapshot.Buttons); + + if (acknowledgeBeforeCompletion) + controller.CharacterSelectionState.ApplyDeleteAcknowledged(); + + controller.CharacterSelectionState.ApplyRestore( + new CharacterRestore.Parsed( + VerificationFlag: 1u, + Guid: 0x50000001u, + Name: "Restored", + SecondsGreyedOut: 0u)); + + Assert.Equal( + acknowledgeBeforeCompletion + ? RuntimeCharacterSelectionOperation.DeleteAcknowledged + : RuntimeCharacterSelectionOperation.DeleteRequested, + controller.CharacterSelection.Snapshot.Operation); + Assert.Equal( + RuntimeCharacterSelectionButtons.None, + controller.CharacterSelection.Snapshot.Buttons); + Assert.True(controller.CharacterSelection.TryGet( + 0x50000001u, + out RuntimeCharacterSelectionEntry restored)); + Assert.False(restored.IsPendingDelete); + Assert.Equal( + RuntimeCommandStatus.Rejected, + controller.ConfirmDelete(generation).Status); + Assert.Equal([("Canonical", 1)], operations.DeleteRequests); + + if (!acknowledgeBeforeCompletion) + controller.CharacterSelectionState.ApplyDeleteAcknowledged(); + + Assert.Equal( + RuntimeCharacterSelectionOperation.DeleteAcknowledged, + controller.CharacterSelection.Snapshot.Operation); + Assert.Equal( + RuntimeCharacterSelectionButtons.None, + controller.CharacterSelection.Snapshot.Buttons); + Assert.Equal([("Canonical", 1)], operations.DeleteRequests); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RestoreTimeoutDuringConfirmedDelete_PreservesDeleteUntilAck( + bool acknowledgeBeforeTimeout) + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var time = new ManualTimeProvider(); + var controller = new LiveSessionController(operations, time); + Assert.Equal( + LiveSessionStartStatus.AwaitingCharacterSelection, + controller.Start(LiveOptions(awaitSelection: true), host).Status); + RuntimeGenerationToken generation = controller.Generation; + + Assert.True(controller.Highlight(generation, 0x50000001u).Accepted); + Assert.True(controller.Restore(generation).Accepted); + Assert.True(controller.Highlight(generation, 0x50000002u).Accepted); + Assert.True(controller.RequestDelete(generation).Accepted); + Assert.True(controller.ConfirmDelete(generation).Accepted); + Assert.Equal([("Canonical", 1)], operations.DeleteRequests); + + if (acknowledgeBeforeTimeout) + controller.CharacterSelectionState.ApplyDeleteAcknowledged(); + + time.Advance(RuntimeCharacterSelectionState.RestoreCorrelationTimeout); + controller.Tick(); + + Assert.Equal( + acknowledgeBeforeTimeout + ? RuntimeCharacterSelectionOperation.DeleteAcknowledged + : RuntimeCharacterSelectionOperation.DeleteRequested, + controller.CharacterSelection.Snapshot.Operation); + Assert.Equal( + RuntimeCharacterSelectionButtons.None, + controller.CharacterSelection.Snapshot.Buttons); + Assert.Equal( + RuntimeCommandStatus.Rejected, + controller.ConfirmDelete(generation).Status); + Assert.Equal([("Canonical", 1)], operations.DeleteRequests); + + if (!acknowledgeBeforeTimeout) + controller.CharacterSelectionState.ApplyDeleteAcknowledged(); + + Assert.Equal( + RuntimeCharacterSelectionOperation.DeleteAcknowledged, + controller.CharacterSelection.Snapshot.Operation); + Assert.Equal( + RuntimeCharacterSelectionButtons.None, + controller.CharacterSelection.Snapshot.Buttons); + Assert.Equal([("Canonical", 1)], operations.DeleteRequests); + } + [Fact] public void CharacterCommands_AreGenerationGatedAcrossReconnectAndStop() {