From 3b11a9dc11977d6093f31e086d043365df96c526 Mon Sep 17 00:00:00 2001 From: Rutherther Date: Wed, 15 Feb 2023 14:23:03 +0100 Subject: [PATCH] feat(game): add smart dialog handling, rejecting conflicting answers to same dialog Resolves #57. --- Core/NosSmooth.Game/Apis/DialogHandler.cs | 130 +++++++++++++++++- .../Errors/DialogConflictError.cs | 17 +++ 2 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 Core/NosSmooth.Game/Errors/DialogConflictError.cs diff --git a/Core/NosSmooth.Game/Apis/DialogHandler.cs b/Core/NosSmooth.Game/Apis/DialogHandler.cs index b62a67151f6f8e85ee21d578bcb4e11596a4d319..318d869833cb68a7b40e99385fd1a8989a36539e 100644 --- a/Core/NosSmooth.Game/Apis/DialogHandler.cs +++ b/Core/NosSmooth.Game/Apis/DialogHandler.cs @@ -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; /// /// Handles accepting and denying of dialogs. /// -public class DialogHandler +public class DialogHandler : IDisposable { + private static readonly TimeSpan ForgetAfter = TimeSpan.FromMinutes(1); + private readonly INostaleClient _client; + private readonly ConcurrentQueue _answers; + private readonly SemaphoreSlim _taskSemaphore; + private readonly SemaphoreSlim _enqueueSemaphore; + private CancellationTokenSource? _dialogCleanupCancel; + private Task? _dialogCleanupTask; /// /// Initializes a new instance of the class. @@ -23,6 +32,9 @@ public class DialogHandler /// The client. public DialogHandler(INostaleClient client) { + _enqueueSemaphore = new SemaphoreSlim(1, 1); + _taskSemaphore = new SemaphoreSlim(1, 1); + _answers = new ConcurrentQueue(); _client = client; } @@ -32,8 +44,19 @@ public class DialogHandler /// The opened dialog. /// The cancellation token used for cancelling the operation. /// A result that may or may not have succeeded. - public Task AcceptAsync(Dialog dialog, CancellationToken ct = default) - => _client.SendPacketAsync(dialog.AcceptCommand, ct); + public async Task 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); + } /// /// Try to deny the operation the dialog does. @@ -44,13 +67,106 @@ public class DialogHandler /// The opened dialog. /// The cancellation token used for cancelling the operation. /// A result that may or may not have succeeded. - public Task DenyAsync(Dialog dialog, CancellationToken ct = default) + public async Task 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); + + /// + public void Dispose() + { + _taskSemaphore.Dispose(); + _dialogCleanupCancel?.Cancel(); + _dialogCleanupCancel = null; + _dialogCleanupTask = null; } } \ No newline at end of file diff --git a/Core/NosSmooth.Game/Errors/DialogConflictError.cs b/Core/NosSmooth.Game/Errors/DialogConflictError.cs new file mode 100644 index 0000000000000000000000000000000000000000..a523a83e4cd04b009830cf65d189c50de1b49769 --- /dev/null +++ b/Core/NosSmooth.Game/Errors/DialogConflictError.cs @@ -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; + +/// +/// An error returned from in case the dialog was answered multiple times +/// and the answers are in conflict (was accepted, but tried to deny and vice versa) +/// +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