~ruther/NosSmooth

3b11a9dc11977d6093f31e086d043365df96c526 — Rutherther 2 years ago bbb771c
feat(game): add smart dialog handling, rejecting conflicting answers to same dialog

Resolves #57.
M Core/NosSmooth.Game/Apis/DialogHandler.cs => Core/NosSmooth.Game/Apis/DialogHandler.cs +123 -7
@@ 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

A Core/NosSmooth.Game/Errors/DialogConflictError.cs => Core/NosSmooth.Game/Errors/DialogConflictError.cs +17 -0
@@ 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

Do not follow this link