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