~ruther/NosSmooth

07230027a9b02530d80320a88c78ecc7bd711902 — Rutherther 3 years ago 35fb44a
feat(core): add command for taking control

Allows only one silmutaneous action in the given group, user cancellable
A Core/NosSmooth.Core/Commands/Control/ControlCancelReason.cs => Core/NosSmooth.Core/Commands/Control/ControlCancelReason.cs +28 -0
@@ 0,0 1,28 @@
//
//  ControlCancelReason.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.

namespace NosSmooth.Core.Commands.Control;

/// <summary>
/// Reason for control cancellation.
/// </summary>
public enum ControlCancelReason
{
    /// <summary>
    /// Unknown reason for cancellation.
    /// </summary>
    Unknown,

    /// <summary>
    /// The user has took walk/unfollow action.
    /// </summary>
    UserAction,

    /// <summary>
    /// There was another task that cancelled this one.
    /// </summary>
    AnotherTask
}
\ No newline at end of file

A Core/NosSmooth.Core/Commands/Control/ControlCommandPacketResponders.cs => Core/NosSmooth.Core/Commands/Control/ControlCommandPacketResponders.cs +39 -0
@@ 0,0 1,39 @@
//
//  ControlCommandPacketResponders.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.Threading;
using System.Threading.Tasks;
using NosSmooth.Core.Commands.Walking;
using NosSmooth.Core.Packets;
using NosSmooth.Packets.Client.Movement;
using NosSmooth.Packets.Server.Maps;
using Remora.Results;

namespace NosSmooth.Core.Commands.Control;

/// <summary>
/// Packet responder for cancellation of <see cref="TakeControlCommand"/>.
/// </summary>
public class ControlCommandPacketResponders : IPacketResponder<CMapPacket>
{
    private readonly ControlCommands _controlCommands;

    /// <summary>
    /// Initializes a new instance of the <see cref="ControlCommandPacketResponders"/> class.
    /// </summary>
    /// <param name="controlCommands">The control commands.</param>
    public ControlCommandPacketResponders(ControlCommands controlCommands)
    {
        _controlCommands = controlCommands;

    }

    /// <inheritdoc />
    public Task<Result> Respond(PacketEventArgs<CMapPacket> packet, CancellationToken ct = default)
    {
        return _controlCommands.CancelAsync(ControlCommandsFilter.MapChangeCancellable);
    }
}
\ No newline at end of file

A Core/NosSmooth.Core/Commands/Control/ControlCommands.cs => Core/NosSmooth.Core/Commands/Control/ControlCommands.cs +318 -0
@@ 0,0 1,318 @@
//
//  ControlCommands.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;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Threading.Tasks.Sources;
using NosSmooth.Core.Errors;
using Remora.Results;

namespace NosSmooth.Core.Commands.Control;

/// <summary>
/// The state of <see cref="TakeControlCommand"/>.
/// </summary>
public class ControlCommands
{
    /// <summary>
    /// The group representing every group.
    /// </summary>
    /// <remarks>
    /// This will cancel all ongoing control operations
    /// upon registration.
    ///
    /// This will also be cancelled if any operation tries
    /// to take control.
    /// </remarks>
    public const string AllGroup = "__all";

    private ConcurrentDictionary<string, CommandData> _data;
    private ConcurrentDictionary<string, SemaphoreSlim> _addSemaphores;
    private ConcurrentDictionary<string, SemaphoreSlim> _removeSemaphores;

    /// <summary>
    /// Initializes a new instance of the <see cref="ControlCommands"/> class.
    /// </summary>
    public ControlCommands()
    {
        _data = new ConcurrentDictionary<string, CommandData>();
        _addSemaphores = new ConcurrentDictionary<string, SemaphoreSlim>();
        _removeSemaphores = new ConcurrentDictionary<string, SemaphoreSlim>();
    }

    /// <summary>
    /// Gets whether user actions are currently allowed.
    /// </summary>
    public bool AllowUserActions { get; private set; } = true;

    /// <summary>
    /// Register the given command.
    /// </summary>
    /// <remarks>
    /// The command will grant control if the result is successful.
    /// After execution the command should call CancelAsync.
    /// </remarks>
    /// <param name="command">The command data.</param>
    /// <param name="cancellationTokenSource">The cancellation token source that will be cancelled.</param>
    /// <param name="ct">The cancellation token for cancelling the operation.</param>
    /// <returns>The cancellation token that will symbolize the operation should be cancelled.</returns>
    public async Task<Result> RegisterAsync
        (TakeControlCommand command, CancellationTokenSource cancellationTokenSource, CancellationToken ct = default)
    {
        var semaphore = _addSemaphores.GetOrAdd(command.Group, _ => new SemaphoreSlim(1, 1));
        var removeSempahore = _removeSemaphores.GetOrAdd(command.Group, _ => new SemaphoreSlim(1, 1));
        await semaphore.WaitAsync(ct);

        var matchingCommands = FindMatchingCommands(command.Group);
        var cancelOperations = new List<Task<Result>>();
        foreach (var matchingCommand in matchingCommands)
        {
            cancelOperations.Add
                (CancelCommandAsync(matchingCommand, command.WaitForCancellation, ControlCommandsFilter.None, ct));
        }

        if (command.WaitForCancellation && cancelOperations.Count > 0)
        {
            semaphore.Release();
        }

        var results = await Task.WhenAll(cancelOperations);
        var errorResults = results.Where(x => !x.IsSuccess).ToArray();

        if (errorResults.Length > 0)
        {
            return errorResults.Length switch
            {
                1 => errorResults[0],
                _ => new AggregateError(errorResults.Cast<IResult>().ToList())
            };
        }

        if (command.WaitForCancellation && cancelOperations.Count > 0)
        {
            // There could be a new take of control already.
            return await RegisterAsync(command, cancellationTokenSource, ct);
        }

        await removeSempahore.WaitAsync(ct); // Should be right away
        _data.TryAdd(command.Group, new CommandData(command, cancellationTokenSource));
        cancellationTokenSource.Token.Register
        (
            () =>
            {
                _data.TryRemove(command.Group, out _);
                removeSempahore.Release();
            }
        );
        semaphore.Release();
        if (!command.AllowUserCancel)
        {
            AllowUserActions = false;
        }

        return Result.FromSuccess();
    }

    /// <summary>
    /// Finish command from the given group gracefully.
    /// </summary>
    /// <param name="group">The group to finish.</param>
    /// <returns>A result that may or may not have succeeded.</returns>
    public Task<Result> FinishAsync(string group)
    {
        var commandsToFinish = FindMatchingCommands(group);
        return FinishCommandsAsync(commandsToFinish);
    }

    /// <summary>
    /// Cancel the given group command.
    /// </summary>
    /// <param name="group">The name of the group to cancel the command.</param>
    /// <param name="waitForCancellation">Whether to wait if the ongoing operation is not cancellable.</param>
    /// <param name="ct">The cancellation token for cancelling the operation.</param>
    /// <returns>A result that may or may not have succeeded.</returns>
    public Task<Result> CancelAsync(string group, bool waitForCancellation = true, CancellationToken ct = default)
    {
        var commandsToCancel = FindMatchingCommands(group);
        return CancelCommandsAsync(commandsToCancel, waitForCancellation, ct: ct);
    }

    /// <summary>
    /// Cancel the given commands.
    /// </summary>
    /// <param name="filter">The filter to apply.</param>
    /// <param name="waitForCancellation">Whether to wait for cancellation of non cancellable commands.</param>
    /// <param name="ct">The cancellation token for cancelling the operation.</param>
    /// <returns>A result that may or may not have succeeded.</returns>
    public async Task<Result> CancelAsync
        (ControlCommandsFilter filter, bool waitForCancellation = true, CancellationToken ct = default)
    {
        bool cancelUser = filter.HasFlag(ControlCommandsFilter.UserCancellable);
        bool cancelMapChanged = filter.HasFlag(ControlCommandsFilter.MapChangeCancellable);
        bool cancelAll = !cancelUser && !cancelMapChanged;

        var commandsToCancel = _data.Values.Where
        (
            x => cancelAll || (cancelUser && x.Command.AllowUserCancel)
                || (cancelMapChanged && x.Command.CancelOnMapChange)
        );

        return await CancelCommandsAsync(commandsToCancel, waitForCancellation, filter, ct);
    }

    private async Task<Result> FinishCommandsAsync(IEnumerable<CommandData> commandsToFinish)
    {
        var tasks = commandsToFinish.Select(x => FinishCommandAsync(x, null));
        var results = await Task.WhenAll(tasks);
        var errorResults = results.Where(x => !x.IsSuccess).ToArray();

        return errorResults.Length switch
        {
            0 => Result.FromSuccess(),
            1 => errorResults[0],
            _ => new AggregateError(errorResults.Cast<IResult>().ToList())
        };
    }

    private async Task<Result> FinishCommandAsync(CommandData data, ControlCancelReason? cancelReason)
    {
        Result cancelledResult = Result.FromSuccess();
        if (cancelReason is not null)
        {
            try
            {
                 cancelledResult = await data.Command.CancelledCallback((ControlCancelReason)cancelReason);
            }
            catch (Exception e)
            {
                cancelledResult = e;
            }
        }

        try
        {
            data.CancellationTokenSource.Cancel();
        }
        catch
        {
            // Don't handle
        }

        if (!AllowUserActions && !data.Command.AllowUserCancel)
        {
            AllowUserActions = _data.Values.All(x => x.Command.AllowUserCancel);
        }

        return cancelledResult;
    }

    private async Task<Result> CancelCommandsAsync
    (
        IEnumerable<CommandData> data,
        bool waitForCancellation = true,
        ControlCommandsFilter filter = ControlCommandsFilter.None,
        CancellationToken ct = default
    )
    {
        var commands = data.ToArray();
        if (commands.Length == 0)
        {
            return Result.FromSuccess();
        }

        if (commands.Length == 1)
        {
            return await CancelCommandAsync(commands[0], waitForCancellation, filter, ct);
        }

        var tasks = new List<Task<Result>>();
        foreach (var command in commands)
        {
            tasks.Add(CancelCommandAsync(command, waitForCancellation, filter, ct));
        }

        var results = await Task.WhenAll(tasks);
        var errorResults = results.Where(x => !x.IsSuccess).ToArray();
        return errorResults.Length switch
        {
            1 => errorResults[0],
            _ => new AggregateError(errorResults.Cast<IResult>().ToArray())
        };
    }

    private async Task<Result> CancelCommandAsync
    (
        CommandData data,
        bool waitForCancellation = true,
        ControlCommandsFilter filter = ControlCommandsFilter.None,
        CancellationToken ct = default
    )
    {
        if (!data.Command.CanBeCancelledByAnother && !filter.HasFlag(ControlCommandsFilter.UserCancellable))
        {
            if (!waitForCancellation)
            {
                return Result.FromError(new CouldNotGainControlError(data.Command.Group, "would wait"));
            }

            // Wait for the successful finish.
            var found = _removeSemaphores.TryGetValue(data.Command.Group, out var semaphore);
            if (!found || semaphore is null)
            {
                return Result.FromError
                    (new CouldNotGainControlError(data.Command.Group, "did not find remove semaphore. Bug?"));
            }

            await semaphore.WaitAsync(ct);
            semaphore.Release();
        }

        var cancelReason = filter switch
        {
            ControlCommandsFilter.UserCancellable => ControlCancelReason.UserAction,
            ControlCommandsFilter.MapChangeCancellable => ControlCancelReason.UserAction,
            _ => ControlCancelReason.AnotherTask
        };

        return await FinishCommandAsync
        (
            data,
            cancelReason
        );
    }

    private IEnumerable<CommandData> FindMatchingCommands(string group)
    {
        if (group == AllGroup)
        {
            return _data.Values.ToArray();
        }

        if (!_data.ContainsKey(group))
        {
            return Array.Empty<CommandData>();
        }

        return new[] { _data[group] };
    }

    private struct CommandData
    {
        public CommandData(TakeControlCommand command, CancellationTokenSource cancellationTokenSource)
        {
            Command = command;
            CancellationTokenSource = cancellationTokenSource;
        }

        public TakeControlCommand Command { get; }

        public CancellationTokenSource CancellationTokenSource { get; }
    }
}
\ No newline at end of file

A Core/NosSmooth.Core/Commands/Control/ControlCommandsFilter.cs => Core/NosSmooth.Core/Commands/Control/ControlCommandsFilter.cs +31 -0
@@ 0,0 1,31 @@
//
//  ControlCommandsFilter.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;

namespace NosSmooth.Core.Commands.Control;

/// <summary>
/// Filter for cancellation of <see cref="TakeControlCommand"/>.
/// </summary>
[Flags]
public enum ControlCommandsFilter
{
    /// <summary>
    /// No filter, cancel all commands.
    /// </summary>
    None,

    /// <summary>
    /// Cancel commands that should be cancelled upon user action.
    /// </summary>
    UserCancellable,

    /// <summary>
    /// Cancel commands that should be cancelled upon map change.
    /// </summary>
    MapChangeCancellable
}
\ No newline at end of file

A Core/NosSmooth.Core/Commands/Control/ITakeControlCommand.cs => Core/NosSmooth.Core/Commands/Control/ITakeControlCommand.cs +33 -0
@@ 0,0 1,33 @@
//
//  ITakeControlCommand.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.

namespace NosSmooth.Core.Commands.Control;

/// <summary>
/// Represents command that supports taking control. See <see cref="TakeControlCommand"/>.
/// </summary>
public interface ITakeControlCommand : ICommand
{
    /// <summary>
    /// Gets whether the command may be cancelled by another task within the same group.
    /// </summary>
    bool CanBeCancelledByAnother { get; }

    /// <summary>
    /// Gets whether to wait for finish of the previous task.
    /// </summary>
    bool WaitForCancellation { get; }

    /// <summary>
    /// Whether to allow the user to cancel by taking any walk/focus/unfollow action.
    /// </summary>
    bool AllowUserCancel { get; }

    /// <summary>
    /// Whether the command should be cancelled on map change.
    /// </summary>
    bool CancelOnMapChange { get; }
}
\ No newline at end of file

A Core/NosSmooth.Core/Commands/Control/TakeControlCommand.cs => Core/NosSmooth.Core/Commands/Control/TakeControlCommand.cs +33 -0
@@ 0,0 1,33 @@
//
//  TakeControlCommand.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;
using System.Threading;
using System.Threading.Tasks;
using Remora.Results;

namespace NosSmooth.Core.Commands.Control;

/// <summary>
/// Take control in the given group and call the given callback with a cancellation token.
/// </summary>
/// <remarks>
/// The command will be cancelled either if the program tries to take control in the same group again
/// or the user has clicked somewhere to cancel the operation.
/// </remarks>
/// <param name="HandleCallback">The callback to be called when control is granted.</param>
/// <param name="CancelledCallback">The callback to be called if the operation was cancelled and the control was revoked.</param>
/// <param name="Group">The group of the take control.</param>
public record TakeControlCommand
(
    Func<CancellationToken, Task<Result>> HandleCallback,
    Func<ControlCancelReason, Task<Result>> CancelledCallback,
    string Group = "__default",
    bool CanBeCancelledByAnother = true,
    bool WaitForCancellation = true,
    bool AllowUserCancel = true,
    bool CancelOnMapChange = true
) : ITakeControlCommand;
\ No newline at end of file

A Core/NosSmooth.Core/Commands/Control/TakeControlCommandHandler.cs => Core/NosSmooth.Core/Commands/Control/TakeControlCommandHandler.cs +53 -0
@@ 0,0 1,53 @@
//
//  TakeControlCommandHandler.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;
using System.Threading;
using System.Threading.Tasks;
using Remora.Results;

namespace NosSmooth.Core.Commands.Control;

/// <summary>
/// Handles <see cref="TakeControlCommand"/>.
/// </summary>
public class TakeControlCommandHandler : ICommandHandler<TakeControlCommand>
{
    private readonly ControlCommands _commands;

    /// <summary>
    /// Initializes a new instance of the <see cref="TakeControlCommandHandler"/> class.
    /// </summary>
    /// <param name="commands">The control commands.</param>
    public TakeControlCommandHandler(ControlCommands commands)
    {
        _commands = commands;
    }

    /// <inheritdoc />
    public async Task<Result> HandleCommand(TakeControlCommand command, CancellationToken ct = default)
    {
        using var source = CancellationTokenSource.CreateLinkedTokenSource(ct);
        var registrationResult = await _commands.RegisterAsync(command, source, ct);
        if (!registrationResult.IsSuccess)
        {
            return registrationResult;
        }

        var token = source.Token;
        try
        {
            var handlerResult = await command.HandleCallback(token);
            await _commands.FinishAsync(command.Group);

            return handlerResult;
        }
        catch (Exception e)
        {
            return e;
        }
    }
}
\ No newline at end of file

A Core/NosSmooth.Core/Errors/CouldNotGainControlError.cs => Core/NosSmooth.Core/Errors/CouldNotGainControlError.cs +17 -0
@@ 0,0 1,17 @@
//
//  CouldNotGainControlError.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 Remora.Results;

namespace NosSmooth.Core.Errors;

/// <summary>
/// Could not gain control of the given group.
/// </summary>
/// <param name="Group">The group name.</param>
/// <param name="Message">The message.</param>
public record CouldNotGainControlError(string Group, string Message)
    : ResultError($"Could not cancel an operation from {Group} due to {Message}");
\ No newline at end of file

M Core/NosSmooth.Core/Extensions/ServiceCollectionExtensions.cs => Core/NosSmooth.Core/Extensions/ServiceCollectionExtensions.cs +15 -3
@@ 9,6 9,7 @@ using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using NosSmooth.Core.Commands;
using NosSmooth.Core.Commands.Control;
using NosSmooth.Core.Packets;
using NosSmooth.Packets.Extensions;



@@ 23,12 24,10 @@ public static class ServiceCollectionExtensions
    /// Adds base packet and command handling for nostale client.
    /// </summary>
    /// <param name="serviceCollection">The service collection to register the responder to.</param>
    /// <param name="additionalPacketTypes">Custom types of packets to serialize and deserialize.</param>
    /// <returns>The collection.</returns>
    public static IServiceCollection AddNostaleCore
    (
        this IServiceCollection serviceCollection,
        params Type[] additionalPacketTypes
        this IServiceCollection serviceCollection
    )
    {
        serviceCollection


@@ 41,6 40,19 @@ public static class ServiceCollectionExtensions
    }

    /// <summary>
    /// Adds command handling of <see cref="TakeControlCommand"/>.
    /// </summary>
    /// <param name="serviceCollection">The service collection to register the responder to.</param>
    /// <returns>The collection.</returns>
    public static IServiceCollection AddTakeControlCommand(this IServiceCollection serviceCollection)
    {
        return serviceCollection
            .AddSingleton<ControlCommands>()
            .AddPacketResponder<ControlCommandPacketResponders>()
            .AddCommandHandler<TakeControlCommandHandler>();
    }

    /// <summary>
    /// Adds the specified packet responder that will be called upon receiving the given event.
    /// </summary>
    /// <param name="serviceCollection">The service collection to register the responder to.</param>

A Core/NosSmooth.Core/Extensions/TakeControlCommandExtensions.cs => Core/NosSmooth.Core/Extensions/TakeControlCommandExtensions.cs +47 -0
@@ 0,0 1,47 @@
//
//  TakeControlCommandExtensions.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;
using System.Threading;
using System.Threading.Tasks;
using NosSmooth.Core.Commands.Control;
using Remora.Results;

namespace NosSmooth.Core.Extensions;

/// <summary>
/// Extension methods for <see cref="ITakeControlCommand"/>.
/// </summary>
public static class TakeControlCommandExtensions
{
    /// <summary>
    /// Create a take control command base on a command that supports taking control over.
    /// </summary>
    /// <param name="takeControlCommand">The command implementing take control to copy.</param>
    /// <param name="group">The group of the new take control.</param>
    /// <param name="handleCallback">The callback to be called when control is granted.</param>
    /// <param name="cancellationCallback">The callback to be called if the operation was cancelled and the control was revoked.</param>
    /// <returns>The copied take control command.</returns>
    public static TakeControlCommand CreateTakeControl
    (
        this ITakeControlCommand takeControlCommand,
        string group,
        Func<CancellationToken, Task<Result>> handleCallback,
        Func<ControlCancelReason, Task<Result>> cancellationCallback
    )
    {
        return new TakeControlCommand
        (
            handleCallback,
            cancellationCallback,
            group,
            takeControlCommand.CanBeCancelledByAnother,
            takeControlCommand.WaitForCancellation,
            takeControlCommand.AllowUserCancel,
            takeControlCommand.CancelOnMapChange
        );
    }
}
\ No newline at end of file

Do not follow this link