// // DialogHandler.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 System.Collections.Concurrent; using NosSmooth.Core.Client; using NosSmooth.Game.Data.Dialogs; using NosSmooth.Game.Errors; using Remora.Results; namespace NosSmooth.Game.Apis; /// /// Handles accepting and denying of dialogs. /// 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. /// /// The client. public DialogHandler(INostaleClient client) { _enqueueSemaphore = new SemaphoreSlim(1, 1); _taskSemaphore = new SemaphoreSlim(1, 1); _answers = new ConcurrentQueue(); _client = client; } /// /// Accept the operation the dialog does. /// /// The opened dialog. /// The cancellation token used for cancelling the operation. /// A result that may or may not have succeeded. 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. /// /// /// Some dialogs do not allow denying, they are just ignored instead. /// /// The opened dialog. /// The cancellation token used for cancelling the operation. /// A result that may or may not have succeeded. public async Task DenyAsync(Dialog dialog, CancellationToken ct = default) { var handleResult = await HandleDialogAnswer(dialog, false, ct); if (handleResult.SendPacket && dialog.DenyCommand is not null) { return await _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; } }