@@ 4,8 4,10 @@
// Copyright (c) František Boháček. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+using System.Collections.Concurrent;
using NosSmooth.Core.Client;
using NosSmooth.Game.Data.Dialogs;
+using NosSmooth.Game.Errors;
using Remora.Results;
namespace NosSmooth.Game.Apis;
@@ 13,9 15,16 @@ namespace NosSmooth.Game.Apis;
/// <summary>
/// Handles accepting and denying of dialogs.
/// </summary>
-public class DialogHandler
+public class DialogHandler : IDisposable
{
+ private static readonly TimeSpan ForgetAfter = TimeSpan.FromMinutes(1);
+
private readonly INostaleClient _client;
+ private readonly ConcurrentQueue<DialogAnswer> _answers;
+ private readonly SemaphoreSlim _taskSemaphore;
+ private readonly SemaphoreSlim _enqueueSemaphore;
+ private CancellationTokenSource? _dialogCleanupCancel;
+ private Task? _dialogCleanupTask;
/// <summary>
/// Initializes a new instance of the <see cref="DialogHandler"/> class.
@@ 23,6 32,9 @@ public class DialogHandler
/// <param name="client">The client.</param>
public DialogHandler(INostaleClient client)
{
+ _enqueueSemaphore = new SemaphoreSlim(1, 1);
+ _taskSemaphore = new SemaphoreSlim(1, 1);
+ _answers = new ConcurrentQueue<DialogAnswer>();
_client = client;
}
@@ 32,8 44,19 @@ public class DialogHandler
/// <param name="dialog">The opened dialog.</param>
/// <param name="ct">The cancellation token used for cancelling the operation.</param>
/// <returns>A result that may or may not have succeeded.</returns>
- public Task<Result> AcceptAsync(Dialog dialog, CancellationToken ct = default)
- => _client.SendPacketAsync(dialog.AcceptCommand, ct);
+ public async Task<Result> AcceptAsync(Dialog dialog, CancellationToken ct = default)
+ {
+ var handleResult = await HandleDialogAnswer(dialog, true, ct);
+
+ if (handleResult.SendPacket)
+ {
+ return await _client.SendPacketAsync(dialog.AcceptCommand, ct);
+ }
+
+ return handleResult.AnsweredSame
+ ? Result.FromSuccess()
+ : new DialogConflictError(dialog, false, true);
+ }
/// <summary>
/// Try to deny the operation the dialog does.
@@ 44,13 67,106 @@ public class DialogHandler
/// <param name="dialog">The opened dialog.</param>
/// <param name="ct">The cancellation token used for cancelling the operation.</param>
/// <returns>A result that may or may not have succeeded.</returns>
- public Task<Result> DenyAsync(Dialog dialog, CancellationToken ct = default)
+ public async Task<Result> DenyAsync(Dialog dialog, CancellationToken ct = default)
{
- if (dialog.DenyCommand is null)
+ var handleResult = await HandleDialogAnswer(dialog, false, ct);
+
+ if (handleResult.SendPacket && dialog.DenyCommand is not null)
{
- return Task.FromResult(Result.FromSuccess());
+ return await _client.SendPacketAsync(dialog.DenyCommand, ct);
}
- return _client.SendPacketAsync(dialog.DenyCommand, ct);
+ return handleResult.AnsweredSame
+ ? Result.FromSuccess()
+ : new DialogConflictError(dialog, true, false);
+ }
+
+ private async Task<(bool AnsweredSame, bool SendPacket)> HandleDialogAnswer
+ (Dialog dialog, bool newAnswer, CancellationToken ct)
+ {
+ // there could be two concurrent answers, both different,
+ // because of that we have to lock here as well...
+ await _enqueueSemaphore.WaitAsync(ct);
+ try
+ {
+ bool? answerSame = null;
+ foreach (var answer in _answers)
+ {
+ if (answer.Dialog == dialog)
+ {
+ answerSame = answer.Accept == newAnswer;
+ break;
+ }
+ }
+
+ if (answerSame is null)
+ {
+ _answers.Enqueue(new DialogAnswer(dialog, newAnswer, DateTimeOffset.Now));
+ await StartQueueHandler();
+ }
+
+ return answerSame switch
+ {
+ true => (true, false),
+ false => (false, false),
+ null => (true, true)
+ };
+ }
+ finally
+ {
+ _enqueueSemaphore.Release();
+ }
+ }
+
+ private async Task StartQueueHandler()
+ {
+ await _taskSemaphore.WaitAsync();
+ if (_dialogCleanupTask is null)
+ {
+ _dialogCleanupCancel?.Dispose();
+ _dialogCleanupCancel = new CancellationTokenSource();
+ _dialogCleanupTask = Task.Run(() => DeletionTask(_dialogCleanupCancel.Token));
+ }
+ _taskSemaphore.Release();
+ }
+
+ private async Task DeletionTask(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ // taskSemaphore is for ensuring a task will always be started in case
+ // it is needed.
+ await _taskSemaphore.WaitAsync(ct);
+ if (!_answers.TryPeek(out var answer))
+ {
+ _dialogCleanupTask = null;
+ _taskSemaphore.Release();
+ return;
+ }
+ _taskSemaphore.Release();
+
+ DateTimeOffset deleteAt = answer.AnsweredAt.Add(ForgetAfter);
+
+ if (DateTimeOffset.Now >= deleteAt)
+ {
+ _answers.TryDequeue(out _);
+
+ // nothing else dequeues, the time is not changing.
+ }
+
+ // tasks are in chronological order => we can wait for the first one without any issue.
+ await Task.Delay(deleteAt - DateTimeOffset.Now + TimeSpan.FromMilliseconds(10), ct);
+ }
+ }
+
+ private record DialogAnswer(Dialog Dialog, bool Accept, DateTimeOffset AnsweredAt);
+
+ /// <inheritdoc />
+ public void Dispose()
+ {
+ _taskSemaphore.Dispose();
+ _dialogCleanupCancel?.Cancel();
+ _dialogCleanupCancel = null;
+ _dialogCleanupTask = null;
}
}=
\ No newline at end of file
@@ 0,0 1,17 @@
+//
+// DialogConflictError.cs
+//
+// Copyright (c) František Boháček. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using NosSmooth.Game.Apis;
+using NosSmooth.Game.Data.Dialogs;
+using Remora.Results;
+
+namespace NosSmooth.Game.Errors;
+
+/// <summary>
+/// An error returned from <see cref="DialogHandler"/> in case the dialog was answered multiple times
+/// and the answers are in conflict (was accepted, but tried to deny and vice versa)
+/// </summary>
+public record DialogConflictError(Dialog dialog, bool OriginalAccept, bool NewAccept) : ResultError("Dialog was already handled and the current response conflicts with the old one, cannot proceed.");<
\ No newline at end of file