fix(runtime): isolate Campaign LA7b delete state

This commit is contained in:
Erik 2026-08-14 19:03:43 +02:00
parent 1b9e7e41f9
commit ff40656293
2 changed files with 139 additions and 4 deletions

View file

@ -466,7 +466,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
_restoreResponseArmed = false; _restoreResponseArmed = false;
_restoreCorrelationStartedTimestamp = 0; _restoreCorrelationStartedTimestamp = 0;
_flagOnlyRestoreResponseAmbiguous = true; _flagOnlyRestoreResponseAmbiguous = true;
_operation = RuntimeCharacterSelectionOperation.None; if (!HasConfirmedDelete())
_operation = RuntimeCharacterSelectionOperation.None;
_revision++; _revision++;
} }
Publish( Publish(
@ -533,6 +534,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
{ {
uint characterId; uint characterId;
uint errorCode = 0u; uint errorCode = 0u;
bool deleteInFlight;
lock (_gate) lock (_gate)
{ {
if (_disposed if (_disposed
@ -553,6 +555,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
_restoreCorrelationStartedTimestamp = 0; _restoreCorrelationStartedTimestamp = 0;
characterId = response.Guid characterId = response.Guid
?? _lastRestoreRequestedCharacterId; ?? _lastRestoreRequestedCharacterId;
deleteInFlight = HasConfirmedDelete();
if (response.IsOk if (response.IsOk
&& response.Guid is { } guid && response.Guid is { } guid
@ -573,13 +576,21 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
string.CompareOrdinal(left.Name, right.Name)); string.CompareOrdinal(left.Name, right.Name));
_entries = StablePartitionGreyedToTail(_entries); _entries = StablePartitionGreyedToTail(_entries);
} }
_operation = RuntimeCharacterSelectionOperation.RestoreSucceeded; if (!deleteInFlight)
{
_operation =
RuntimeCharacterSelectionOperation.RestoreSucceeded;
}
_error = null; _error = null;
} }
else else
{ {
errorCode = response.VerificationFlag; errorCode = response.VerificationFlag;
_operation = RuntimeCharacterSelectionOperation.RestoreRejected; if (!deleteInFlight)
{
_operation =
RuntimeCharacterSelectionOperation.RestoreRejected;
}
_error = new RuntimeCharacterSelectionError( _error = new RuntimeCharacterSelectionError(
response.VerificationFlag, response.VerificationFlag,
CharacterError.Code.Undefined, CharacterError.Code.Undefined,
@ -804,7 +815,10 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
private bool HasDeleteModalOrRequest() => private bool HasDeleteModalOrRequest() =>
_pendingDeleteCharacterId != 0u _pendingDeleteCharacterId != 0u
|| _operation is RuntimeCharacterSelectionOperation.DeleteRequested || HasConfirmedDelete();
private bool HasConfirmedDelete() =>
_operation is RuntimeCharacterSelectionOperation.DeleteRequested
or RuntimeCharacterSelectionOperation.DeleteAcknowledged; or RuntimeCharacterSelectionOperation.DeleteAcknowledged;
private static RuntimeCharacterSelectionEntry[] StablePartitionGreyedToTail( private static RuntimeCharacterSelectionEntry[] StablePartitionGreyedToTail(

View file

@ -530,6 +530,127 @@ public sealed class LiveSessionControllerTests
Assert.False(controller.IsInWorld); Assert.False(controller.IsInWorld);
} }
[Theory]
[InlineData(false)]
[InlineData(true)]
public void RestoreCompletionDuringConfirmedDelete_PreservesDeleteUntilAck(
bool acknowledgeBeforeCompletion)
{
var calls = new List<string>();
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<string>();
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] [Fact]
public void CharacterCommands_AreGenerationGatedAcrossReconnectAndStop() public void CharacterCommands_AreGenerationGatedAcrossReconnectAndStop()
{ {