~ruther/NosSmooth

10f73ef772df9becc2a8083192f4f595fdd3a3ba — Rutherther 2 years ago 84a3b6f + 7880943
Merge pull request #60 from Rutherther/feat/combat-v1

Make combat nonblocking, make multiple queues for distinct operations
33 files changed, 1240 insertions(+), 444 deletions(-)

M Core/NosSmooth.Core/Contracts/ContractBuilder.cs
M Core/NosSmooth.Core/Contracts/Contractor.cs
M Core/NosSmooth.Core/Contracts/DefaultContract.cs
M Core/NosSmooth.Core/Contracts/IContract.cs
M Core/NosSmooth.Game/Apis/Unsafe/UnsafeSkillsApi.cs
M Core/NosSmooth.Game/Contracts/UseSkillErrors.cs
M Core/NosSmooth.Game/Data/Characters/Skills.cs
M Core/NosSmooth.Game/PacketHandlers/Skills/PlayerSkillResponder.cs
M Extensions/NosSmooth.Extensions.Combat/CombatManager.cs
M Extensions/NosSmooth.Extensions.Combat/CombatState.cs
M Extensions/NosSmooth.Extensions.Combat/Extensions/CombatStateExtensions.cs
M Extensions/NosSmooth.Extensions.Combat/Extensions/ServiceCollectionExtensions.cs
M Extensions/NosSmooth.Extensions.Combat/ICombatState.cs
A Extensions/NosSmooth.Extensions.Combat/OperationQueueType.cs
M Extensions/NosSmooth.Extensions.Combat/Operations/ICombatOperation.cs
M Extensions/NosSmooth.Extensions.Combat/Operations/UseItemOperation.cs
D Extensions/NosSmooth.Extensions.Combat/Operations/UsePrimarySkillOperation.cs
M Extensions/NosSmooth.Extensions.Combat/Operations/UseSkillOperation.cs
M Extensions/NosSmooth.Extensions.Combat/Operations/WalkInRangeOperation.cs
M Extensions/NosSmooth.Extensions.Combat/Operations/WalkOperation.cs
M Extensions/NosSmooth.Extensions.Combat/Policies/EnemyPolicy.cs
M Extensions/NosSmooth.Extensions.Combat/Policies/UseItemPolicy.cs
D Extensions/NosSmooth.Extensions.Combat/Responders/CancelResponder.cs
D Extensions/NosSmooth.Extensions.Combat/Responders/SkillUseResponder.cs
M Extensions/NosSmooth.Extensions.Combat/Selectors/IItemSelector.cs
A Extensions/NosSmooth.Extensions.Combat/Selectors/InventoryItem.cs
M Extensions/NosSmooth.Extensions.Combat/Techniques/ICombatTechnique.cs
M Extensions/NosSmooth.Extensions.Combat/Techniques/SimpleAttackTechnique.cs
A Tests/NosSmooth.Core.Tests/Contracts/ContractData.cs
A Tests/NosSmooth.Core.Tests/Contracts/ContractError.cs
A Tests/NosSmooth.Core.Tests/Contracts/ContractMultipleStates.cs
A Tests/NosSmooth.Core.Tests/Contracts/ContractTests.cs
M Tests/NosSmooth.Core.Tests/NosSmooth.Core.Tests.csproj
M Core/NosSmooth.Core/Contracts/ContractBuilder.cs => Core/NosSmooth.Core/Contracts/ContractBuilder.cs +16 -3
@@ 29,7 29,7 @@ public class ContractBuilder<TData, TState, TError>
    private readonly TState _defaultState;

    private readonly Dictionary<TState, DefaultContract<TData, TState, TError>.StateActionAsync> _actions;
    private readonly Dictionary<TState, (TimeSpan, TState)> _timeouts;
    private readonly Dictionary<TState, (TimeSpan, TState?, TError?)> _timeouts;

    private TState? _fillAtState;
    private DefaultContract<TData, TState, TError>.FillDataAsync? _fillData;


@@ 44,7 44,7 @@ public class ContractBuilder<TData, TState, TError>
        _contractor = contractor;
        _defaultState = defaultState;
        _actions = new Dictionary<TState, DefaultContract<TData, TState, TError>.StateActionAsync>();
        _timeouts = new Dictionary<TState, (TimeSpan, TState)>();
        _timeouts = new Dictionary<TState, (TimeSpan, TState?, TError?)>();
    }

    /// <summary>


@@ 56,7 56,20 @@ public class ContractBuilder<TData, TState, TError>
    /// <returns>The updated builder.</returns>
    public ContractBuilder<TData, TState, TError> SetTimeout(TState state, TimeSpan timeout, TState nextState)
    {
        _timeouts[state] = (timeout, nextState);
        _timeouts[state] = (timeout, nextState, null);
        return this;
    }

    /// <summary>
    /// Sets timeout of the given state.
    /// </summary>
    /// <param name="state">The state to set timeout for.</param>
    /// <param name="timeout">The timeout span.</param>
    /// <param name="error">The error to set.</param>
    /// <returns>The updated builder.</returns>
    public ContractBuilder<TData, TState, TError> SetTimeout(TState state, TimeSpan timeout, TError error)
    {
        _timeouts[state] = (timeout, null, error);
        return this;
    }


M Core/NosSmooth.Core/Contracts/Contractor.cs => Core/NosSmooth.Core/Contracts/Contractor.cs +2 -1
@@ 114,6 114,7 @@ public class Contractor : IEnumerable<IContract>
                        $"A contract {info.contract} has been registered for too long and was unregistered automatically."
                    )
                );
                toRemove.Add(info);
                continue;
            }



@@ 148,7 149,7 @@ public class Contractor : IEnumerable<IContract>
        return errors.Count switch
        {
            0 => Result.FromSuccess(),
            1 => (Result)errors[0],
            1 => Result.FromError((Result<ContractUpdateResponse>)errors[0]),
            _ => new AggregateError(errors)
        };
    }

M Core/NosSmooth.Core/Contracts/DefaultContract.cs => Core/NosSmooth.Core/Contracts/DefaultContract.cs +40 -7
@@ 44,7 44,7 @@ public class DefaultContract<TData, TState, TError> : IContract<TData, TState>

    private readonly SemaphoreSlim _semaphore;

    private readonly IDictionary<TState, (TimeSpan, TState)> _timeouts;
    private readonly IDictionary<TState, (TimeSpan, TState?, TError?)> _timeouts;
    private readonly IDictionary<TState, StateActionAsync> _actions;
    private readonly Contractor _contractor;
    private readonly TState _defaultState;


@@ 75,7 75,7 @@ public class DefaultContract<TData, TState, TError> : IContract<TData, TState>
        TState fillAtState,
        FillDataAsync fillData,
        IDictionary<TState, StateActionAsync> actions,
        IDictionary<TState, (TimeSpan Timeout, TState NextState)> timeouts
        IDictionary<TState, (TimeSpan Timeout, TState? NextState, TError? Error)> timeouts
    )
    {
        _semaphore = new SemaphoreSlim(1, 1);


@@ 139,8 139,16 @@ public class DefaultContract<TData, TState, TError> : IContract<TData, TState>
        if (resultData.Error is not null)
        {
            _error = resultData.Error;
            _waitCancellationSource?.Cancel();
            return ContractUpdateResponse.Interested;
            try
            {
                _waitCancellationSource?.Cancel();
            }
            catch
            {
                // ignored
            }

            return ContractUpdateResponse.InterestedAndUnregister;
        }

        if (resultData.NextState is null)


@@ 170,6 178,7 @@ public class DefaultContract<TData, TState, TError> : IContract<TData, TState>
            {
                _error = error;
                _waitCancellationSource?.Cancel();
                return new ContractError<TError>(error.Value);
            }

            if (state is not null)


@@ 199,6 208,16 @@ public class DefaultContract<TData, TState, TError> : IContract<TData, TState>
            );
        }

        if (_error is not null)
        {
            return new ContractError<TError>(_error.Value);
        }

        if (_resultError is not null)
        {
            return Result<TData>.FromError(_resultError.Value);
        }

        if (CurrentState.CompareTo(state) >= 0)
        { // already reached.
            return Data;


@@ 234,6 253,8 @@ public class DefaultContract<TData, TState, TError> : IContract<TData, TState>
            {
                Unregister();
            }

            _waitCancellationSource?.Dispose();
        }

        if (ct.IsCancellationRequested)


@@ 259,6 280,10 @@ public class DefaultContract<TData, TState, TError> : IContract<TData, TState>
        return Data;
    }

    /// <inheritdoc />
    public bool HasReachedState(TState state)
        => _error is not null || _resultError is not null || CurrentState.CompareTo(state) >= 0;

    private async Task<Result<ContractUpdateResponse>> SetupNewState<TAny>(TAny data, CancellationToken ct)
    {
        if (_fillAtState.CompareTo(CurrentState) == 0)


@@ 304,7 329,7 @@ public class DefaultContract<TData, TState, TError> : IContract<TData, TState>
        if (_timeouts.ContainsKey(CurrentState))
        {
            var currentState = CurrentState;
            var (timeout, state) = _timeouts[CurrentState];
            var (timeout, state, error) = _timeouts[CurrentState];

            Task.Run
            (


@@ 314,8 339,16 @@ public class DefaultContract<TData, TState, TError> : IContract<TData, TState>

                    if (CurrentState.CompareTo(currentState) == 0)
                    {
                        await SetCurrentState(state);
                        await SetupNewState<int?>(null!, default);
                        if (state is not null)
                        {
                            await SetCurrentState(state.Value);
                            await SetupNewState<int?>(null!, default);
                        }
                        else if (error is not null)
                        {
                            _error = error;
                            _waitCancellationSource?.Cancel();
                        }
                    }
                }
            );

M Core/NosSmooth.Core/Contracts/IContract.cs => Core/NosSmooth.Core/Contracts/IContract.cs +7 -0
@@ 116,4 116,11 @@ public interface IContract<TData, TState> : IContract
    /// <returns>The data of the contract or an error.</returns>
    /// <exception cref="InvalidOperationError">Thrown in case the given state cannot fill the data.</exception>
    public Task<Result<TData>> WaitForAsync(TState state, bool unregisterAfter = true, CancellationToken ct = default);

    /// <summary>
    /// Gets whether the given state has been reached already.
    /// </summary>
    /// <param name="state">The state to check has been reached.</param>
    /// <returns>True in case the given state has been reached or there was an error.</returns>
    public bool HasReachedState(TState state);
}
\ No newline at end of file

M Core/NosSmooth.Game/Apis/Unsafe/UnsafeSkillsApi.cs => Core/NosSmooth.Game/Apis/Unsafe/UnsafeSkillsApi.cs +1 -0
@@ 421,6 421,7 @@ public class UnsafeSkillsApi
                skillUseEvent => skillUseEvent
            )
            .SetError<CancelPacket>(UseSkillStates.SkillUseRequested, _ => UseSkillErrors.Unknown)
            .SetTimeout(UseSkillStates.SkillUseRequested, TimeSpan.FromSeconds(1.5), UseSkillErrors.NoResponse)
            .SetTimeout(UseSkillStates.SkillUsedResponse, TimeSpan.FromSeconds(1), UseSkillStates.CharacterRestored)
            .Build();
    }

M Core/NosSmooth.Game/Contracts/UseSkillErrors.cs => Core/NosSmooth.Game/Contracts/UseSkillErrors.cs +6 -1
@@ 24,5 24,10 @@ public enum UseSkillErrors
    /// <summary>
    /// The character does not have enough mana.
    /// </summary>
    NoMana
    NoMana,

    /// <summary>
    /// There was no response from the server.
    /// </summary>
    NoResponse
}
\ No newline at end of file

M Core/NosSmooth.Game/Data/Characters/Skills.cs => Core/NosSmooth.Game/Data/Characters/Skills.cs +7 -1
@@ 17,4 17,10 @@ public record Skills
    Skill PrimarySkill,
    Skill SecondarySkill,
    IReadOnlyList<Skill> OtherSkills
);
\ No newline at end of file
)
{
    /// <summary>
    /// Gets all skills contained in this collection.
    /// </summary>
    public IEnumerable<Skill> AllSkills => new[] { PrimarySkill, SecondarySkill }.Concat(OtherSkills);
}
\ No newline at end of file

M Core/NosSmooth.Game/PacketHandlers/Skills/PlayerSkillResponder.cs => Core/NosSmooth.Game/PacketHandlers/Skills/PlayerSkillResponder.cs +1 -0
@@ 94,6 94,7 @@ public class PlayerSkillResponder : IPacketResponder<SkiPacket>
            otherSkillsFromCharacter.Add(await CreateSkill(newSkill, default));
        }

        otherSkillsFromCharacter.RemoveAll(x => x.SkillVNum == primarySkill.SkillVNum || x.SkillVNum == secondarySkill.SkillVNum);
        skills = new Data.Characters.Skills(primarySkill, secondarySkill, otherSkillsFromCharacter);

        await _game.CreateOrUpdateSkillsAsync

M Extensions/NosSmooth.Extensions.Combat/CombatManager.cs => Extensions/NosSmooth.Extensions.Combat/CombatManager.cs +103 -114
@@ 4,6 4,7 @@
//  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.Diagnostics;
using NosSmooth.Core.Client;
using NosSmooth.Core.Commands.Attack;
using NosSmooth.Core.Stateful;


@@ 19,8 20,6 @@ namespace NosSmooth.Extensions.Combat;
/// </summary>
public class CombatManager : IStatefulEntity
{
    private readonly List<CancellationTokenSource> _tokenSource;
    private readonly SemaphoreSlim _semaphore;
    private readonly INostaleClient _client;
    private readonly Game.Game _game;
    private bool _cancelling;


@@ 32,8 31,6 @@ public class CombatManager : IStatefulEntity
    /// <param name="game">The game.</param>
    public CombatManager(INostaleClient client, Game.Game game)
    {
        _semaphore = new SemaphoreSlim(1, 1);
        _tokenSource = new List<CancellationTokenSource>();
        _client = client;
        _game = game;
    }


@@ 50,76 47,37 @@ public class CombatManager : IStatefulEntity
        long? currentTarget = null;
        long? previousTarget = null;

        while (!combatState.ShouldQuit && !ct.IsCancellationRequested)
        while (!(combatState.ShouldQuit && combatState.CanQuit) && !ct.IsCancellationRequested)
        {
            var commandResult = await _client.SendCommandAsync
            (
                new AttackCommand
                (
                    currentTarget,
                    async (c) =>
                    async (ct) =>
                    {
                        while (!combatState.ShouldQuit && currentTarget == previousTarget)
                        while (!(combatState.ShouldQuit && combatState.CanQuit) && currentTarget == previousTarget)
                        {
                            if (!technique.ShouldContinue(combatState))
                            {
                                combatState.QuitCombat();
                                continue;
                            }
                            var iterationResult = await HandleAttackIterationAsync(combatState, technique, ct);

                            var operation = combatState.NextOperation();

                            if (operation is null)
                            { // The operation is null and the step has to be obtained from the technique.
                                var stepResult = technique.HandleCombatStep(combatState);
                                if (!stepResult.IsSuccess)
                                {
                                    return Result.FromError(stepResult);
                                }

                                previousTarget = currentTarget;
                                currentTarget = stepResult.Entity;
                            if (!iterationResult.IsSuccess)
                            {
                                var errorResult = technique.HandleError(combatState, Result.FromError(iterationResult));

                                if (previousTarget != currentTarget)
                                {
                                    continue;
                                if (!errorResult.IsSuccess)
                                { // end the attack.
                                    return errorResult;
                                }

                                operation = combatState.NextOperation();
                            }

                            if (operation is null)
                            { // The operation could be null just because there is currently not a skill to be used etc.
                                await Task.Delay(5, ct);
                                continue;
                            }

                            Result<CanBeUsedResponse> responseResult;
                            while ((responseResult = operation.CanBeUsed(combatState)).IsSuccess
                                && responseResult.Entity == CanBeUsedResponse.MustWait)
                            { // TODO: wait for just some amount of time
                                await Task.Delay(5, ct);
                            }

                            if (!responseResult.IsSuccess)
                            var result = iterationResult.Entity;
                            if (!result.TargetChanged)
                            {
                                return Result.FromError(responseResult);
                            }

                            if (responseResult.Entity == CanBeUsedResponse.WontBeUsable)
                            {
                                return new UnusableOperationError(operation);
                                continue;
                            }

                            var usageResult = await operation.UseAsync(combatState, ct);
                            if (!usageResult.IsSuccess)
                            {
                                var errorHandleResult = technique.HandleError(combatState, operation, usageResult);
                                if (!errorHandleResult.IsSuccess)
                                {
                                    return errorHandleResult;
                                }
                            }
                            previousTarget = currentTarget;
                            currentTarget = result.TargetId;
                        }

                        return Result.FromSuccess();


@@ 138,78 96,109 @@ public class CombatManager : IStatefulEntity
        return Result.FromSuccess();
    }

    /// <summary>
    /// Register the given cancellation token source to be cancelled on skill use/cancel.
    /// </summary>
    /// <param name="tokenSource">The token source to register.</param>
    /// <param name="ct">The cancellation token for cancelling the operation.</param>
    /// <returns>A task.</returns>
    public async Task RegisterSkillCancellationTokenAsync(CancellationTokenSource tokenSource, CancellationToken ct)
    private async Task<Result<(bool TargetChanged, long? TargetId)>> HandleAttackIterationAsync
        (CombatState combatState, ICombatTechnique technique, CancellationToken ct)
    {
        await _semaphore.WaitAsync(ct);
        try
        {
            _tokenSource.Add(tokenSource);
        }
        finally
        if (!technique.ShouldContinue(combatState))
        {
            _semaphore.Release();
            combatState.QuitCombat();
        }
    }

    /// <summary>
    /// Unregister the given cancellation token registered using RegisterSkillCancellationToken.
    /// </summary>
    /// <param name="tokenSource">The token source to unregister.</param>
    /// <param name="ct">The cancellation token for cancelling the operation.</param>
    /// <returns>A task.</returns>
    public async Task UnregisterSkillCancellationTokenAsync(CancellationTokenSource tokenSource, CancellationToken ct)
    {
        if (_cancelling)
        {
            return;
        }
        // the operations need time for execution and/or
        // wait.
        await Task.Delay(50, ct);

        await _semaphore.WaitAsync(ct);
        try
        {
            _tokenSource.Remove(tokenSource);
        }
        finally
        var tasks = technique.HandlingQueueTypes
            .Select(x => HandleTypeIterationAsync(x, combatState, technique, ct))
            .ToArray();

        var results = await Task.WhenAll(tasks);
        var errors = results.Where(x => !x.IsSuccess).Cast<IResult>().ToArray();

        return errors.Length switch
        {
            _semaphore.Release();
        }
            0 => results.FirstOrDefault
                (x => x.Entity.TargetChanged, Result<(bool TargetChanged, long?)>.FromSuccess((false, null))),
            1 => (Result<(bool, long?)>)errors[0],
            _ => new AggregateError()
        };
    }

    /// <summary>
    /// Cancel all of the skill tokens.
    /// </summary>
    /// <param name="ct">The cancellation token for cancelling the operation.</param>
    /// <returns>A task.</returns>
    internal async Task CancelSkillTokensAsync(CancellationToken ct)
    private async Task<Result<(bool TargetChanged, long? TargetId)>> HandleTypeIterationAsync
    (
        OperationQueueType queueType,
        CombatState combatState,
        ICombatTechnique technique,
        CancellationToken ct
    )
    {
        await _semaphore.WaitAsync(ct);
        _cancelling = true;
        try
        var currentOperation = combatState.GetCurrentOperation(queueType);
        if (currentOperation?.IsFinished() ?? false)
        {
            foreach (var tokenSource in _tokenSource)
            var operationResult = await currentOperation.WaitForFinishedAsync(combatState, ct);
            currentOperation.Dispose();

            if (!operationResult.IsSuccess)
            {
                try
                {
                    tokenSource.Cancel();
                }
                catch
                return Result<(bool, long?)>.FromError(operationResult);
            }

            currentOperation = null;
        }

        if (currentOperation is null && !combatState.ShouldQuit)
        { // waiting for an operation.
            currentOperation = combatState.NextOperation(queueType);

            if (currentOperation is null)
            { // The operation is null and the step has to be obtained from the technique.
                var stepResult = technique.HandleNextCombatStep(queueType, combatState);
                if (!stepResult.IsSuccess)
                {
                    // ignored
                    return Result<(bool, long?)>.FromError(stepResult);
                }

                return Result<(bool, long?)>.FromSuccess((true, stepResult.Entity));
            }
        }

            _tokenSource.Clear();
        if (currentOperation is null)
        { // should quit, do nothing.
            return (false, null);
        }
        finally
        {
            _cancelling = false;
            _semaphore.Release();

        if (!currentOperation.IsExecuting())
        { // not executing, check can be used, execute if can.
            var canBeUsedResult = currentOperation.CanBeUsed(combatState);
            if (!canBeUsedResult.IsDefined(out var canBeUsed))
            {
                return Result<(bool, long?)>.FromError(canBeUsedResult);
            }

            switch (canBeUsed)
            {
                case CanBeUsedResponse.WontBeUsable:
                    return new UnusableOperationError(currentOperation);
                case CanBeUsedResponse.MustWait:
                    var waitingResult = technique.HandleWaiting(queueType, combatState, currentOperation);

                    if (!waitingResult.IsSuccess)
                    {
                        return Result<(bool, long?)>.FromError(waitingResult);
                    }

                    return Result<(bool, long?)>.FromSuccess((false, null));
                case CanBeUsedResponse.CanBeUsed:
                    var executingResult = await currentOperation.BeginExecution(combatState, ct);

                    if (!executingResult.IsSuccess)
                    {
                        return Result<(bool, long?)>.FromError(executingResult);
                    }
                    break;
            }
        }

        return (false, null);
    }
}
\ No newline at end of file

M Extensions/NosSmooth.Extensions.Combat/CombatState.cs => Extensions/NosSmooth.Extensions.Combat/CombatState.cs +77 -32
@@ 4,18 4,17 @@
//  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.Xml;
using System.Diagnostics.CodeAnalysis;
using NosSmooth.Core.Client;
using NosSmooth.Extensions.Combat.Operations;
using NosSmooth.Game.Data.Entities;

namespace NosSmooth.Extensions.Combat;

/// <inheritdoc />
internal class CombatState : ICombatState
{
    private readonly LinkedList<ICombatOperation> _operations;
    private ICombatOperation? _currentOperation;
    private readonly Dictionary<OperationQueueType, LinkedList<ICombatOperation>> _operations;
    private readonly Dictionary<OperationQueueType, ICombatOperation> _currentOperations;

    /// <summary>
    /// Initializes a new instance of the <see cref="CombatState"/> class.


@@ 28,7 27,8 @@ internal class CombatState : ICombatState
        Client = client;
        Game = game;
        CombatManager = combatManager;
        _operations = new LinkedList<ICombatOperation>();
        _operations = new Dictionary<OperationQueueType, LinkedList<ICombatOperation>>();
        _currentOperations = new Dictionary<OperationQueueType, ICombatOperation>();
    }

    /// <summary>


@@ 45,63 45,108 @@ internal class CombatState : ICombatState
    /// <inheritdoc/>
    public INostaleClient Client { get; }

    /// <summary>
    /// Gets whether the manager may currently quit.
    /// </summary>
    /// <remarks>
    /// Used for finishing the current operations.
    /// </remarks>
    public bool CanQuit => _currentOperations.Values.All(x => !x.IsExecuting() || x.IsFinished());

    /// <inheritdoc/>
    public void QuitCombat()
    {
        ShouldQuit = true;
    }
    public bool IsWaitingOnOperation => _currentOperations.Any(x => !x.Value.IsExecuting());

    /// <summary>
    /// Make a step in the queue.
    /// Move to next operation, if available.
    /// </summary>
    /// <returns>The current operation, if any.</returns>
    public ICombatOperation? NextOperation()
    /// <param name="queueType">The queue type to move to next operation in.</param>
    /// <returns>Next operation, if any.</returns>
    public ICombatOperation? NextOperation(OperationQueueType queueType)
    {
        var operation = _currentOperation = _operations.First?.Value;
        if (operation is not null)
        if (_operations.ContainsKey(queueType))
        {
            _operations.RemoveFirst();
            var nextOperation = _operations[queueType].FirstOrDefault();

            if (nextOperation is not null)
            {
                _operations[queueType].RemoveFirst();
                _currentOperations[queueType] = nextOperation;
                return nextOperation;
            }
        }

        return operation;
        return null;
    }

    /// <inheritdoc/>
    public IReadOnlyList<ICombatOperation> GetWaitingForOperations()
    {
        return _currentOperations.Values.Where(x => !x.IsExecuting()).ToList();
    }

    /// <inheritdoc/>
    public ICombatOperation? GetCurrentOperation(OperationQueueType queueType)
        => _currentOperations.GetValueOrDefault(queueType);

    /// <inheritdoc/>
    public bool IsExecutingOperation(OperationQueueType queueType, [NotNullWhen(true)] out ICombatOperation? operation)
    {
        operation = GetCurrentOperation(queueType);
        return operation is not null && operation.IsExecuting();
    }

    /// <inheritdoc/>
    public void QuitCombat()
    {
        ShouldQuit = true;
    }

    /// <inheritdoc/>
    public void SetCurrentOperation
        (ICombatOperation operation, bool emptyQueue = false, bool prependCurrentOperationToQueue = false)
    {
        var current = _currentOperation;
        _currentOperation = operation;
        var type = operation.QueueType;

        if (!_operations.ContainsKey(type))
        {
            _operations[type] = new LinkedList<ICombatOperation>();
        }

        if (emptyQueue)
        {
            _operations.Clear();
            _operations[type].Clear();
        }

        if (prependCurrentOperationToQueue)
        {
            _operations[type].AddFirst(operation);
            return;
        }

        if (prependCurrentOperationToQueue && current is not null)
        if (_currentOperations.ContainsKey(type))
        {
            _operations.AddFirst(current);
            _currentOperations[type].Dispose();
        }

        _currentOperations[type] = operation;
    }

    /// <inheritdoc/>
    public void EnqueueOperation(ICombatOperation operation)
    {
        _operations.AddLast(operation);
        var type = operation.QueueType;

        if (!_operations.ContainsKey(type))
        {
            _operations[type] = new LinkedList<ICombatOperation>();
        }

        _operations[type].AddLast(operation);
    }

    /// <inheritdoc/>
    public void RemoveOperations(Func<ICombatOperation, bool> filter)
    {
        var node = _operations.First;
        while (node != null)
        {
            var next = node.Next;
            if (filter(node.Value))
            {
                _operations.Remove(node);
            }
            node = next;
        }
        throw new NotImplementedException();
    }
}
\ No newline at end of file

M Extensions/NosSmooth.Extensions.Combat/Extensions/CombatStateExtensions.cs => Extensions/NosSmooth.Extensions.Combat/Extensions/CombatStateExtensions.cs +6 -13
@@ 7,7 7,9 @@
using NosSmooth.Extensions.Combat.Errors;
using NosSmooth.Extensions.Combat.Operations;
using NosSmooth.Extensions.Combat.Policies;
using NosSmooth.Extensions.Combat.Selectors;
using NosSmooth.Extensions.Pathfinding;
using NosSmooth.Game.Apis.Safe;
using NosSmooth.Game.Data.Characters;
using NosSmooth.Game.Data.Entities;
using NosSmooth.Game.Data.Items;


@@ 61,26 63,17 @@ public static class CombatStateExtensions
    /// Use the given skill.
    /// </summary>
    /// <param name="combatState">The combat state.</param>
    /// <param name="skillsApi">The skills api for using a skill.</param>
    /// <param name="skill">The skill.</param>
    /// <param name="target">The target to use skill at.</param>
    public static void UseSkill(this ICombatState combatState, Skill skill, ILivingEntity target)
    public static void UseSkill(this ICombatState combatState, NostaleSkillsApi skillsApi, Skill skill, ILivingEntity target)
    {
        if (combatState.Game.Character is null)
        {
            throw new InvalidOperationException("The character is not initialized.");
        }

        combatState.EnqueueOperation(new UseSkillOperation(skill, combatState.Game.Character, target));
    }

    /// <summary>
    /// Use primary skill.
    /// </summary>
    /// <param name="combatState">The combat state.</param>
    /// <param name="target">The target to use skill at.</param>
    public static void UsePrimarySkill(this ICombatState combatState, ILivingEntity target)
    {
        combatState.EnqueueOperation(new UsePrimarySkillOperation(target));
        combatState.EnqueueOperation(new UseSkillOperation(skillsApi, skill, combatState.Game.Character, target));
    }

    /// <summary>


@@ 88,7 81,7 @@ public static class CombatStateExtensions
    /// </summary>
    /// <param name="combatState">The combat state.</param>
    /// <param name="item">The item to use.</param>
    public static void UseItem(this ICombatState combatState, Item item)
    public static void UseItem(this ICombatState combatState, InventoryItem item)
    {
        combatState.EnqueueOperation(new UseItemOperation(item));
    }

M Extensions/NosSmooth.Extensions.Combat/Extensions/ServiceCollectionExtensions.cs => Extensions/NosSmooth.Extensions.Combat/Extensions/ServiceCollectionExtensions.cs +0 -4
@@ 5,8 5,6 @@
//  Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Microsoft.Extensions.DependencyInjection;
using NosSmooth.Core.Extensions;
using NosSmooth.Extensions.Combat.Responders;

namespace NosSmooth.Extensions.Combat.Extensions;



@@ 23,8 21,6 @@ public static class ServiceCollectionExtensions
    public static IServiceCollection AddNostaleCombat(this IServiceCollection serviceCollection)
    {
        return serviceCollection
            .AddPacketResponder<CancelResponder>()
            .AddPacketResponder<SuResponder>()
            .AddSingleton<CombatManager>();
    }
}
\ No newline at end of file

M Extensions/NosSmooth.Extensions.Combat/ICombatState.cs => Extensions/NosSmooth.Extensions.Combat/ICombatState.cs +31 -0
@@ 4,6 4,7 @@
//  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.Diagnostics.CodeAnalysis;
using NosSmooth.Core.Client;
using NosSmooth.Extensions.Combat.Operations;
using NosSmooth.Game.Data.Entities;


@@ 31,6 32,36 @@ public interface ICombatState
    public INostaleClient Client { get; }

    /// <summary>
    /// Gets whether there is an operation that cannot be used
    /// and we must wait for it to be usable.
    /// </summary>
    public bool IsWaitingOnOperation { get; }

    /// <summary>
    /// Get the operations the state is waiting for to to be usable.
    /// </summary>
    /// <returns>The operations needed to wait for.</returns>
    public IReadOnlyList<ICombatOperation> GetWaitingForOperations();

    /// <summary>
    /// Gets the current operation of the given queue type.
    /// </summary>
    /// <param name="queueType">The queue type to get the current operation of.</param>
    /// <returns>The operation of the given queue, if any.</returns>
    public ICombatOperation? GetCurrentOperation(OperationQueueType queueType);

    /// <summary>
    /// Checks whether an operation is being executed in the given queue.
    /// </summary>
    /// <remarks>
    /// If not, either waiting for the operation or there is no operation enqueued.
    /// </remarks>
    /// <param name="queueType">The type of queue to look at.</param>
    /// <param name="operation">The operation currently being executed.</param>
    /// <returns>Whether an operation is being executed.</returns>
    public bool IsExecutingOperation(OperationQueueType queueType, [NotNullWhen(true)] out ICombatOperation? operation);

    /// <summary>
    /// Cancel the combat technique, quit the combat state.
    /// </summary>
    public void QuitCombat();

A Extensions/NosSmooth.Extensions.Combat/OperationQueueType.cs => Extensions/NosSmooth.Extensions.Combat/OperationQueueType.cs +73 -0
@@ 0,0 1,73 @@
//
//  OperationQueueType.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.Extensions.Combat;

/// <summary>
/// A type classifying operations into multiple sequential queues.
/// </summary>
public enum OperationQueueType
{
    /// <summary>
    /// A total control of controls is needed (walking, sitting - recovering, using a skill).
    /// </summary>
    TotalControl,

    /// <summary>
    /// An item is being used.
    /// </summary>
    Item,

    /// <summary>
    /// Any operation that should be executed sequentially with other <see cref="Reserved1"/> operations.
    /// </summary>
    Reserved1,

    /// <summary>
    /// Any operation that should be executed sequentially with other <see cref="Reserved2"/> operations.
    /// </summary>
    Reserved2,

    /// <summary>
    /// Any operation that should be executed sequentially with other <see cref="Reserved3"/> operations.
    /// </summary>
    Reserved3,

    /// <summary>
    /// Any operation that should be executed sequentially with other <see cref="Reserved4"/> operations.
    /// </summary>
    Reserved4,

    /// <summary>
    /// Any operation that should be executed sequentially with other <see cref="Reserved5"/> operations.
    /// </summary>
    Reserved5,

    /// <summary>
    /// Any operation that should be executed sequentially with other <see cref="Reserved6"/> operations.
    /// </summary>
    Reserved6,

    /// <summary>
    /// Any operation that should be executed sequentially with other <see cref="Reserved7"/> operations.
    /// </summary>
    Reserved7,

    /// <summary>
    /// Any operation that should be executed sequentially with other <see cref="Reserved8"/> operations.
    /// </summary>
    Reserved8,

    /// <summary>
    /// Any operation that should be executed sequentially with other <see cref="Reserved9"/> operations.
    /// </summary>
    Reserved9,

    /// <summary>
    /// Any operation that should be executed sequentially with other <see cref="Reserved10"/> operations.
    /// </summary>
    Reserved10,
}
\ No newline at end of file

M Extensions/NosSmooth.Extensions.Combat/Operations/ICombatOperation.cs => Extensions/NosSmooth.Extensions.Combat/Operations/ICombatOperation.cs +47 -11
@@ 4,6 4,7 @@
//  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.Core.Contracts;
using NosSmooth.Extensions.Combat.Techniques;
using Remora.Results;



@@ 12,27 13,62 @@ namespace NosSmooth.Extensions.Combat.Operations;
/// <summary>
/// A combat operation used in <see cref="ICombatTechnique"/> that can be used as one step.
/// </summary>
public interface ICombatOperation
public interface ICombatOperation : IDisposable
{
    // 1. wait for CanBeUsed
    // 2. use OnlyExecute
    // 3. periodically check IsFinished
    // 4. Finished
    // 5. Call Dispose
    // 6. Go to next operation in queue
    // go to step 1

    /// <summary>
    /// Checks whether the operation can currently be used.
    /// Gets the queue type the operation belongs to.
    /// </summary>
    /// <remarks>
    /// Ie. if the operation is to use a skill, it will return true only if the skill is not on a cooldown,
    /// the character has enough mana and is not stunned.
    /// Used for distinguishing what operations may run simultaneously.
    /// For example items may be used simultaneous to attacking. Attacking
    /// may not be simultaneous to walking.
    /// </remarks>
    public OperationQueueType QueueType { get; }

    /// <summary>
    /// Begin the execution without waiting for the finished state.
    /// </summary>
    /// <param name="combatState">The combat state.</param>
    /// <returns>Whether the operation can be used right away.</returns>
    public Result<CanBeUsedResponse> CanBeUsed(ICombatState combatState);
    /// <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> BeginExecution(ICombatState combatState, CancellationToken ct = default);

    /// <summary>
    /// Asynchronously wait for finished state.
    /// </summary>
    /// <param name="combatState">The combat state.</param>
    /// <param name="ct">The cancellation token used for cancelling the operation.</param>
    /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
    public Task<Result> WaitForFinishedAsync(ICombatState combatState, CancellationToken ct = default);

    /// <summary>
    /// Checks whether the operation is currently being executed.
    /// </summary>
    /// <returns>Whether the operation is being executed.</returns>
    public bool IsExecuting();

    /// <summary>
    /// Checks whether the operation is done.
    /// </summary>
    /// <returns>Whether the operation is finished.</returns>
    public bool IsFinished();

    /// <summary>
    /// Use the operation, if possible.
    /// Checks whether the operation can currently be used.
    /// </summary>
    /// <remarks>
    /// Should block until the operation is finished.
    /// Ie. if the operation is to use a skill, it will return true only if the skill is not on a cooldown,
    /// the character has enough mana and is not stunned.
    /// </remarks>
    /// <param name="combatState">The combat state.</param>
    /// <param name="ct">The cancellation token for cancelling the operation.</param>
    /// <returns>A result that may or may not succeed.</returns>
    public Task<Result> UseAsync(ICombatState combatState, CancellationToken ct = default);
    /// <returns>Whether the operation can be used right away.</returns>
    public Result<CanBeUsedResponse> CanBeUsed(ICombatState combatState);
}
\ No newline at end of file

M Extensions/NosSmooth.Extensions.Combat/Operations/UseItemOperation.cs => Extensions/NosSmooth.Extensions.Combat/Operations/UseItemOperation.cs +54 -6
@@ 4,7 4,12 @@
//  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.Data.Items;
using System.Diagnostics;
using NosSmooth.Data.Abstractions.Enums;
using NosSmooth.Extensions.Combat.Selectors;
using NosSmooth.Game.Data.Inventory;
using NosSmooth.Game.Extensions;
using NosSmooth.Packets.Client.Inventory;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Operations;


@@ 13,17 18,60 @@ namespace NosSmooth.Extensions.Combat.Operations;
/// A combat operation to use an item.
/// </summary>
/// <param name="Item">The item to use.</param>
public record UseItemOperation(Item Item) : ICombatOperation
public record UseItemOperation(InventoryItem Item) : ICombatOperation
{
    private Task<Result>? _useItemOperation;

    /// <inheritdoc />
    public Result<CanBeUsedResponse> CanBeUsed(ICombatState combatState)
    public OperationQueueType QueueType => OperationQueueType.Item;

    /// <inheritdoc />
    public Task<Result> BeginExecution(ICombatState combatState, CancellationToken ct = default)
    {
        if (_useItemOperation is not null)
        {
            return Task.FromResult(Result.FromSuccess());
        }

        _useItemOperation = Task.Run(
            () => combatState.Client.SendPacketAsync(new UseItemPacket(Item.Bag.Convert(), Item.Item.Slot), ct),
            ct
        );
        return Task.FromResult(Result.FromSuccess());
    }

    /// <inheritdoc />
    public Task<Result> WaitForFinishedAsync(ICombatState combatState, CancellationToken ct = default)
    {
        throw new NotImplementedException();
        if (IsFinished())
        {
            return Task.FromResult(Result.FromSuccess());
        }

        BeginExecution(combatState, ct);
        if (_useItemOperation is null)
        {
            throw new UnreachableException();
        }

        return _useItemOperation;
    }

    /// <inheritdoc />
    public Task<Result> UseAsync(ICombatState combatState, CancellationToken ct = default)
    public bool IsExecuting()
        => _useItemOperation is not null && !IsFinished();

    /// <inheritdoc />
    public bool IsFinished()
        => _useItemOperation?.IsCompleted ?? false;

    /// <inheritdoc />
    public Result<CanBeUsedResponse> CanBeUsed(ICombatState combatState)
        => CanBeUsedResponse.CanBeUsed;

    /// <inheritdoc />
    public void Dispose()
    {
        throw new NotImplementedException();
        _useItemOperation?.Dispose();
    }
}
\ No newline at end of file

D Extensions/NosSmooth.Extensions.Combat/Operations/UsePrimarySkillOperation.cs => Extensions/NosSmooth.Extensions.Combat/Operations/UsePrimarySkillOperation.cs +0 -77
@@ 1,77 0,0 @@
//
//  UsePrimarySkillOperation.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.Extensions.Combat.Errors;
using NosSmooth.Game.Data.Characters;
using NosSmooth.Game.Data.Entities;
using NosSmooth.Packets.Client.Battle;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Operations;

/// <summary>
/// An operation that uses the primary skill of the character.
/// </summary>
public record UsePrimarySkillOperation(ILivingEntity Target) : ICombatOperation
{
    private UseSkillOperation? _useSkillOperation;

    /// <inheritdoc />
    public Result<CanBeUsedResponse> CanBeUsed(ICombatState combatState)
    {
        if (_useSkillOperation is null)
        {
            var primarySkillResult = GetPrimarySkill(combatState);
            if (!primarySkillResult.IsDefined(out var primarySkill))
            {
                return Result<CanBeUsedResponse>.FromError(primarySkillResult);
            }

            if (combatState.Game.Character is null)
            {
                return new CharacterNotInitializedError();
            }

            _useSkillOperation = new UseSkillOperation(primarySkill, combatState.Game.Character, Target);
        }

        return _useSkillOperation.CanBeUsed(combatState);
    }

    /// <inheritdoc />
    public async Task<Result> UseAsync(ICombatState combatState, CancellationToken ct)
    {
        if (_useSkillOperation is null)
        {
            var primarySkillResult = GetPrimarySkill(combatState);
            if (!primarySkillResult.IsDefined(out var primarySkill))
            {
                return Result.FromError(primarySkillResult);
            }

            if (combatState.Game.Character is null)
            {
                return new CharacterNotInitializedError();
            }

            _useSkillOperation = new UseSkillOperation(primarySkill, combatState.Game.Character, Target);
        }

        return await _useSkillOperation.UseAsync(combatState, ct);
    }

    private Result<Skill> GetPrimarySkill(ICombatState combatState)
    {
        var skills = combatState.Game.Skills;

        if (skills is null)
        {
            return new CharacterNotInitializedError("Skills");
        }

        return skills.PrimarySkill;
    }
}
\ No newline at end of file

M Extensions/NosSmooth.Extensions.Combat/Operations/UseSkillOperation.cs => Extensions/NosSmooth.Extensions.Combat/Operations/UseSkillOperation.cs +80 -61
@@ 6,11 6,16 @@

using System.Diagnostics;
using System.Xml.XPath;
using NosSmooth.Core.Contracts;
using NosSmooth.Data.Abstractions.Enums;
using NosSmooth.Data.Abstractions.Infos;
using NosSmooth.Extensions.Combat.Errors;
using NosSmooth.Game.Apis.Safe;
using NosSmooth.Game.Contracts;
using NosSmooth.Game.Data.Characters;
using NosSmooth.Game.Data.Entities;
using NosSmooth.Game.Errors;
using NosSmooth.Game.Events.Battle;
using NosSmooth.Packets;
using NosSmooth.Packets.Client.Battle;
using Remora.Results;


@@ 20,109 25,123 @@ namespace NosSmooth.Extensions.Combat.Operations;
/// <summary>
/// A combat operation to use a skill.
/// </summary>
/// <param name="SkillsApi">The skills api used for executing the skills.</param>
/// <param name="Skill">The skill to use.</param>
/// <param name="Caster">The caster entity that is using the skill.</param>
/// <param name="Target">The target entity to use the skill at.</param>
public record UseSkillOperation(Skill Skill, ILivingEntity Caster, ILivingEntity Target) : ICombatOperation
public record UseSkillOperation(NostaleSkillsApi SkillsApi, Skill Skill, ILivingEntity Caster, ILivingEntity Target) : ICombatOperation
{
    private IContract<SkillUsedEvent, UseSkillStates>? _contract;

    /// <inheritdoc />
    public Result<CanBeUsedResponse> CanBeUsed(ICombatState combatState)
    public OperationQueueType QueueType => OperationQueueType.TotalControl;

    /// <inheritdoc />
    public async Task<Result> BeginExecution(ICombatState combatState, CancellationToken ct = default)
    {
        if (_contract is not null)
        {
            return Result.FromSuccess();
        }

        if (Skill.Info is null)
        {
            return new MissingInfoError("skill", Skill.SkillVNum);
        }

        var character = combatState.Game.Character;
        if (character is not null && character.Mp is not null && character.Mp.Amount is not null)
        if (Target.Position is null)
        {
            if (character.Mp.Amount < Skill.Info.MpCost)
            { // The character is in combat, mp won't restore.
                return CanBeUsedResponse.WontBeUsable;
            }
            return new NotInitializedError("target's position");
        }

        return Skill.IsOnCooldown ? CanBeUsedResponse.MustWait : CanBeUsedResponse.CanBeUsed;
        var contractResult = ContractSkill(Skill.Info);
        if (!contractResult.IsDefined(out var contract))
        {
            return Result.FromError(contractResult);
        }

        _contract = contract;
        var executed = await _contract.OnlyExecuteAsync(ct);
        _contract.Register();

        return executed;
    }

    /// <inheritdoc />
    public async Task<Result> UseAsync(ICombatState combatState, CancellationToken ct = default)
    public async Task<Result> WaitForFinishedAsync(ICombatState combatState, CancellationToken ct = default)
    {
        if (Skill.Info is null)
        var result = await BeginExecution(combatState, ct);
        if (!result.IsSuccess)
        {
            return new MissingInfoError("skill", Skill.SkillVNum);
            return result;
        }

        if (_contract is null)
        {
            throw new UnreachableException();
        }

        using var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(ct);
        await combatState.CombatManager.RegisterSkillCancellationTokenAsync(linkedSource, ct);
        var sendResponse = await combatState.Client.SendPacketAsync
        (
            CreateSkillUsePacket(Skill.Info),
            ct
        );
        var waitResult = await _contract.WaitForAsync(UseSkillStates.CharacterRestored, ct: ct);
        return waitResult.IsSuccess ? Result.FromSuccess() : Result.FromError(waitResult);
    }

        if (!sendResponse.IsSuccess)
    /// <inheritdoc />
    public bool IsExecuting()
        => _contract is not null && _contract.CurrentState > UseSkillStates.None && !IsFinished();

    /// <inheritdoc />
    public bool IsFinished()
        => _contract?.HasReachedState(UseSkillStates.CharacterRestored) ?? false;

    /// <inheritdoc />
    public Result<CanBeUsedResponse> CanBeUsed(ICombatState combatState)
    {
        if (Skill.Info is null)
        {
            await combatState.CombatManager.UnregisterSkillCancellationTokenAsync(linkedSource, ct);
            return sendResponse;
            return new MissingInfoError("skill", Skill.SkillVNum);
        }

        try
        var character = combatState.Game.Character;
        if (Target.Hp is not null && Target.Hp.Amount is not null && Target.Hp.Amount == 0)
        {
            // wait 10 times the cast delay in case su is not received.
            await Task.Delay(Skill.Info.CastTime * 1000, linkedSource.Token);
            return CanBeUsedResponse.WontBeUsable;
        }
        catch (TaskCanceledException)

        if (character is not null && character.Mp is not null && character.Mp.Amount is not null)
        {
            // ignored
            if (character.Mp.Amount < Skill.Info.MpCost)
            { // The character is in combat, mp won't restore.
                return CanBeUsedResponse.WontBeUsable;
            }
        }
        await combatState.CombatManager.UnregisterSkillCancellationTokenAsync(linkedSource, ct);
        await Task.Delay(1000, ct);

        return Result.FromSuccess();
        return Skill.IsOnCooldown ? CanBeUsedResponse.MustWait : CanBeUsedResponse.CanBeUsed;
    }

    private IPacket CreateSkillUsePacket(ISkillInfo info)
    private Result<IContract<SkillUsedEvent, UseSkillStates>> ContractSkill(ISkillInfo info)
    {
        if (info.AttackType == AttackType.Dash)
        {
            return SkillsApi.ContractUseSkillOn(Skill, Target, Target.Position!.Value.X, Target.Position!.Value.Y);
        }

        switch (info.TargetType)
        {
            case TargetType.SelfOrTarget: // a buff?
            case TargetType.Self:
                return CreateSelfTargetedSkillPacket(info);
                return SkillsApi.ContractUseSkillOnCharacter(Skill);
            case TargetType.NoTarget: // area skill?
                return CreateAreaSkillPacket(info);
                return SkillsApi.ContractUseSkillAt(Skill, Target.Position!.Value.X, Target.Position.Value.Y);
            case TargetType.Target:
                return CreateTargetedSkillPacket(info);
                return SkillsApi.ContractUseSkillOn(Skill, Target);
        }

        throw new UnreachableException();
    }

    private IPacket CreateAreaSkillPacket(ISkillInfo info)
        => new UseAOESkillPacket
        (
            info.CastId,
            Target.Position!.Value.X,
            Target.Position.Value.Y
        );

    private IPacket CreateTargetedSkillPacket(ISkillInfo info)
        => new UseSkillPacket
        (
            info.CastId,
            Target.Type,
            Target.Id,
            null,
            null
        );

    private IPacket CreateSelfTargetedSkillPacket(ISkillInfo info)
        => new UseSkillPacket
        (
            info.CastId,
            Caster.Type,
            Caster.Id,
            null,
            null
        );
    /// <inheritdoc />
    public void Dispose()
    {
        _contract?.Unregister();
    }
}
\ No newline at end of file

M Extensions/NosSmooth.Extensions.Combat/Operations/WalkInRangeOperation.cs => Extensions/NosSmooth.Extensions.Combat/Operations/WalkInRangeOperation.cs +57 -3
@@ 4,12 4,14 @@
//  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.Diagnostics;
using NosSmooth.Extensions.Combat.Errors;
using NosSmooth.Extensions.Pathfinding;
using NosSmooth.Extensions.Pathfinding.Errors;
using NosSmooth.Game.Data.Entities;
using NosSmooth.Game.Data.Info;
using NosSmooth.Game.Errors;
using NosSmooth.Packets.Client.Inventory;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Operations;


@@ 27,6 29,52 @@ public record WalkInRangeOperation
    float Distance
) : ICombatOperation
{
    private Task<Result>? _walkInRangeOperation;

    /// <inheritdoc />
    public OperationQueueType QueueType => OperationQueueType.TotalControl;

    /// <inheritdoc />
    public Task<Result> BeginExecution(ICombatState combatState, CancellationToken ct = default)
    {
        if (_walkInRangeOperation is not null)
        {
            return Task.FromResult(Result.FromSuccess());
        }

        _walkInRangeOperation = Task.Run
        (
            () => UseAsync(combatState, ct),
            ct
        );
        return Task.FromResult(Result.FromSuccess());
    }

    /// <inheritdoc />
    public async Task<Result> WaitForFinishedAsync(ICombatState combatState, CancellationToken ct = default)
    {
        if (IsFinished())
        {
            return Result.FromSuccess();
        }

        await BeginExecution(combatState, ct);
        if (_walkInRangeOperation is null)
        {
            throw new UnreachableException();
        }

        return await _walkInRangeOperation;
    }

    /// <inheritdoc />
    public bool IsExecuting()
        => _walkInRangeOperation is not null && !IsFinished();

    /// <inheritdoc />
    public bool IsFinished()
        => _walkInRangeOperation?.IsCompleted ?? false;

    /// <inheritdoc />
    public Result<CanBeUsedResponse> CanBeUsed(ICombatState combatState)
    {


@@ 39,8 87,7 @@ public record WalkInRangeOperation
        return character.CantMove ? CanBeUsedResponse.MustWait : CanBeUsedResponse.CanBeUsed;
    }

    /// <inheritdoc />
    public async Task<Result> UseAsync(ICombatState combatState, CancellationToken ct = default)
    private async Task<Result> UseAsync(ICombatState combatState, CancellationToken ct = default)
    {
        var character = combatState.Game.Character;
        if (character is null)


@@ 75,7 122,8 @@ public record WalkInRangeOperation
            }

            using var goToCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(ct);
            var walkResultTask = WalkManager.GoToAsync(closePosition.X, closePosition.Y, true, goToCancellationTokenSource.Token);
            var walkResultTask = WalkManager.GoToAsync
                (closePosition.X, closePosition.Y, true, goToCancellationTokenSource.Token);

            while (!walkResultTask.IsCompleted)
            {


@@ 129,4 177,10 @@ public record WalkInRangeOperation
        var diffLength = Math.Sqrt(diff.DistanceSquared(Position.Zero));
        return target + ((distance / diffLength) * diff);
    }

    /// <inheritdoc />
    public void Dispose()
    {
        _walkInRangeOperation?.Dispose();
    }
}
\ No newline at end of file

M Extensions/NosSmooth.Extensions.Combat/Operations/WalkOperation.cs => Extensions/NosSmooth.Extensions.Combat/Operations/WalkOperation.cs +54 -2
@@ 4,6 4,7 @@
//  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.Diagnostics;
using NosSmooth.Extensions.Combat.Errors;
using NosSmooth.Extensions.Pathfinding;
using Remora.Results;


@@ 18,6 19,52 @@ namespace NosSmooth.Extensions.Combat.Operations;
/// <param name="Y">The y coordinate to walk to.</param>
public record WalkOperation(WalkManager WalkManager, short X, short Y) : ICombatOperation
{
    private Task<Result>? _walkOperation;

    /// <inheritdoc />
    public OperationQueueType QueueType => OperationQueueType.TotalControl;

    /// <inheritdoc />
    public Task<Result> BeginExecution(ICombatState combatState, CancellationToken ct = default)
    {
        if (_walkOperation is not null)
        {
            return Task.FromResult(Result.FromSuccess());
        }

        _walkOperation = Task.Run
        (
            () => UseAsync(combatState, ct),
            ct
        );
        return Task.FromResult(Result.FromSuccess());
    }

    /// <inheritdoc />
    public async Task<Result> WaitForFinishedAsync(ICombatState combatState, CancellationToken ct = default)
    {
        if (IsFinished())
        {
            return Result.FromSuccess();
        }

        await BeginExecution(combatState, ct);
        if (_walkOperation is null)
        {
            throw new UnreachableException();
        }

        return await _walkOperation;
    }

    /// <inheritdoc />
    public bool IsExecuting()
        => _walkOperation is not null && !IsFinished();

    /// <inheritdoc />
    public bool IsFinished()
        => _walkOperation?.IsCompleted ?? false;

    /// <inheritdoc />
    public Result<CanBeUsedResponse> CanBeUsed(ICombatState combatState)
    {


@@ 30,7 77,12 @@ public record WalkOperation(WalkManager WalkManager, short X, short Y) : ICombat
        return character.CantMove ? CanBeUsedResponse.MustWait : CanBeUsedResponse.CanBeUsed;
    }

    /// <inheritdoc />
    public Task<Result> UseAsync(ICombatState combatState, CancellationToken ct = default)
    private Task<Result> UseAsync(ICombatState combatState, CancellationToken ct = default)
        => WalkManager.GoToAsync(X, Y, true, ct);

    /// <inheritdoc />
    public void Dispose()
    {
        _walkOperation?.Dispose();
    }
}
\ No newline at end of file

M Extensions/NosSmooth.Extensions.Combat/Policies/EnemyPolicy.cs => Extensions/NosSmooth.Extensions.Combat/Policies/EnemyPolicy.cs +1 -0
@@ 35,6 35,7 @@ public record EnemyPolicy
        {
            targets = targets.Where(x => MonsterVNums.Contains(x.VNum));
        }
        targets = targets.ToArray();

        if (!targets.Any())
        {

M Extensions/NosSmooth.Extensions.Combat/Policies/UseItemPolicy.cs => Extensions/NosSmooth.Extensions.Combat/Policies/UseItemPolicy.cs +7 -5
@@ 30,7 30,7 @@ public record UseItemPolicy
) : IItemSelector
{
    /// <inheritdoc />
    public Result<Item> GetSelectedItem(ICombatState combatState, ICollection<Item> possibleItems)
    public Result<InventoryItem> GetSelectedItem(ICombatState combatState, ICollection<InventoryItem> possibleItems)
    {
        var character = combatState.Game.Character;
        if (character is null)


@@ 40,19 40,21 @@ public record UseItemPolicy

        if (ShouldUseHpItem(character))
        {
            var item = possibleItems.FirstOrDefault(x => UseHealthItemsVNums.Contains(x.ItemVNum));
            var item = possibleItems.Cast<InventoryItem?>().FirstOrDefault
                (x => UseHealthItemsVNums.Contains(x?.Item.Item?.ItemVNum ?? -1));
            if (item is not null)
            {
                return item;
                return item.Value;
            }
        }

        if (ShouldUseMpItem(character))
        {
            var item = possibleItems.FirstOrDefault(x => UseManaItemsVNums.Contains(x.ItemVNum));
            var item = possibleItems.Cast<InventoryItem?>().FirstOrDefault
                (x => UseManaItemsVNums.Contains(x?.Item.Item?.ItemVNum ?? -1));
            if (item is not null)
            {
                return item;
                return item.Value;
            }
        }


D Extensions/NosSmooth.Extensions.Combat/Responders/CancelResponder.cs => Extensions/NosSmooth.Extensions.Combat/Responders/CancelResponder.cs +0 -35
@@ 1,35 0,0 @@
//
//  CancelResponder.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.Core.Packets;
using NosSmooth.Packets.Server.Skills;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Responders;

/// <summary>
/// Responds to cancel packet.
/// </summary>
public class CancelResponder : IPacketResponder<CancelPacket>
{
    private readonly CombatManager _combatManager;

    /// <summary>
    /// Initializes a new instance of the <see cref="CancelResponder"/> class.
    /// </summary>
    /// <param name="combatManager">The combat manager.</param>
    public CancelResponder(CombatManager combatManager)
    {
        _combatManager = combatManager;
    }

    /// <inheritdoc />
    public Task<Result> Respond(PacketEventArgs<CancelPacket> packetArgs, CancellationToken ct = default)
    {
        Task.Run(() => _combatManager.CancelSkillTokensAsync(default));
        return Task.FromResult(Result.FromSuccess());
    }
}
\ No newline at end of file

D Extensions/NosSmooth.Extensions.Combat/Responders/SkillUseResponder.cs => Extensions/NosSmooth.Extensions.Combat/Responders/SkillUseResponder.cs +0 -52
@@ 1,52 0,0 @@
//
//  SkillUseResponder.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.Core.Packets;
using NosSmooth.Packets;
using NosSmooth.Packets.Server.Battle;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Responders;

/// <summary>
/// Responds to su packet.
/// </summary>
public class SuResponder : IPacketResponder<SuPacket>, IPacketResponder<BsPacket>
{
    private readonly CombatManager _combatManager;
    private readonly Game.Game _game;

    /// <summary>
    /// Initializes a new instance of the <see cref="SuResponder"/> class.
    /// </summary>
    /// <param name="combatManager">The combat manager.</param>
    /// <param name="game">The game.</param>
    public SuResponder(CombatManager combatManager, Game.Game game)
    {
        _combatManager = combatManager;
        _game = game;
    }

    /// <inheritdoc />
    public Task<Result> Respond(PacketEventArgs<SuPacket> packetArgs, CancellationToken ct = default)
    {
        if (packetArgs.Packet.CasterEntityId == _game.Character?.Id)
        {
            Task.Run(() => _combatManager.CancelSkillTokensAsync(default));
        }
        return Task.FromResult(Result.FromSuccess());
    }

    /// <inheritdoc />
    public Task<Result> Respond(PacketEventArgs<BsPacket> packetArgs, CancellationToken ct = default)
    {
        if (packetArgs.Packet.CasterEntityId == _game.Character?.Id)
        {
            Task.Run(() => _combatManager.CancelSkillTokensAsync(default));
        }
        return Task.FromResult(Result.FromSuccess());
    }
}
\ No newline at end of file

M Extensions/NosSmooth.Extensions.Combat/Selectors/IItemSelector.cs => Extensions/NosSmooth.Extensions.Combat/Selectors/IItemSelector.cs +1 -1
@@ 20,7 20,7 @@ public interface IItemSelector
    /// <param name="combatState">The combat state.</param>
    /// <param name="possibleItems">The items that may be used.</param>
    /// <returns>The selected item, or an error.</returns>
    public Result<Item> GetSelectedItem(ICombatState combatState, ICollection<Item> possibleItems);
    public Result<InventoryItem> GetSelectedItem(ICombatState combatState, ICollection<InventoryItem> possibleItems);

    /// <summary>
    /// Gets whether currently an item should be used.

A Extensions/NosSmooth.Extensions.Combat/Selectors/InventoryItem.cs => Extensions/NosSmooth.Extensions.Combat/Selectors/InventoryItem.cs +14 -0
@@ 0,0 1,14 @@
//
//  InventoryItem.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.Diagnostics.CodeAnalysis;
using NosSmooth.Data.Abstractions.Enums;
using NosSmooth.Game.Data.Inventory;

namespace NosSmooth.Extensions.Combat.Selectors;

[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1313:Parameter names should begin with lower-case letter", Justification = "Fix")]
public record struct InventoryItem(BagType Bag, InventorySlot Item);
\ No newline at end of file

M Extensions/NosSmooth.Extensions.Combat/Techniques/ICombatTechnique.cs => Extensions/NosSmooth.Extensions.Combat/Techniques/ICombatTechnique.cs +23 -4
@@ 18,6 18,15 @@ namespace NosSmooth.Extensions.Combat.Techniques;
public interface ICombatTechnique
{
    /// <summary>
    /// Gets the types this technique may handle.
    /// </summary>
    /// <remarks>
    /// <see cref="HandleNextCombatStep"/> will be called only for queue types
    /// from this collection.
    /// </remarks>
    public IReadOnlyList<OperationQueueType> HandlingQueueTypes { get; }

    /// <summary>
    /// Should check whether the technique should process more steps or quit the combat.
    /// </summary>
    /// <param name="state">The combat state.</param>


@@ 26,23 35,33 @@ public interface ICombatTechnique

    /// <summary>
    /// Handle one step that should enqueue an operation.
    /// Enqueue only operation of the given queue type.
    /// </summary>
    /// <remarks>
    /// If error is returned, the combat will be cancelled.
    /// </remarks>
    /// <param name="queueType">The type of the operation to enqueue.</param>
    /// <param name="state">The combat state.</param>
    /// <returns>An id of the current target entity or an error.</returns>
    public Result<long?> HandleCombatStep(ICombatState state);
    public Result<long?> HandleNextCombatStep(OperationQueueType queueType, ICombatState state);

    /// <summary>
    /// Handle waiting for an operation.
    /// </summary>
    /// <param name="queueType">The type of the operation.</param>
    /// <param name="state">The combat state.</param>
    /// <param name="operation">The operation that needs waiting.</param>
    /// <returns>A result that may or may not have succeeded. In case of an error, <see cref="HandleError"/> will be called with the error.</returns>
    public Result HandleWaiting(OperationQueueType queueType, ICombatState state, ICombatOperation operation);

    /// <summary>
    /// Handles an error from <see cref="ICombatOperation.UseAsync"/>.
    /// Handles an arbitrary error.
    /// </summary>
    /// <remarks>
    /// If an error is returned, the combat will be cancelled.
    /// </remarks>
    /// <param name="state">The combat state.</param>
    /// <param name="operation">The combat operation that returned an error.</param>
    /// <param name="result">The errorful result.</param>
    /// <returns>A result that may or may not succeed.</returns>
    public Result HandleError(ICombatState state, ICombatOperation operation, Result result);
    public Result HandleError(ICombatState state, Result result);
}
\ No newline at end of file

M Extensions/NosSmooth.Extensions.Combat/Techniques/SimpleAttackTechnique.cs => Extensions/NosSmooth.Extensions.Combat/Techniques/SimpleAttackTechnique.cs +91 -11
@@ 4,14 4,17 @@
//  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.Diagnostics;
using NosSmooth.Data.Abstractions.Enums;
using NosSmooth.Extensions.Combat.Errors;
using NosSmooth.Extensions.Combat.Extensions;
using NosSmooth.Extensions.Combat.Operations;
using NosSmooth.Extensions.Combat.Selectors;
using NosSmooth.Extensions.Pathfinding;
using NosSmooth.Game.Apis.Safe;
using NosSmooth.Game.Data.Characters;
using NosSmooth.Game.Data.Entities;
using NosSmooth.Game.Data.Inventory;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Techniques;


@@ 21,9 24,16 @@ namespace NosSmooth.Extensions.Combat.Techniques;
/// </summary>
public class SimpleAttackTechnique : ICombatTechnique
{
    private static OperationQueueType[] _handlingTypes = new[]
    {
        OperationQueueType.Item, OperationQueueType.TotalControl
    };

    private readonly long _targetId;
    private readonly NostaleSkillsApi _skillsApi;
    private readonly WalkManager _walkManager;
    private readonly ISkillSelector _skillSelector;
    private readonly IItemSelector _itemSelector;

    private Skill? _currentSkill;
    private ILivingEntity? _target;


@@ 32,21 42,30 @@ public class SimpleAttackTechnique : ICombatTechnique
    /// Initializes a new instance of the <see cref="SimpleAttackTechnique"/> class.
    /// </summary>
    /// <param name="targetId">The target entity id.</param>
    /// <param name="skillsApi">The skills api.</param>
    /// <param name="walkManager">The walk manager.</param>
    /// <param name="skillSelector">The skill selector.</param>
    /// <param name="itemSelector">The item selector.</param>
    public SimpleAttackTechnique
    (
        long targetId,
        NostaleSkillsApi skillsApi,
        WalkManager walkManager,
        ISkillSelector skillSelector
        ISkillSelector skillSelector,
        IItemSelector itemSelector
    )
    {
        _targetId = targetId;
        _skillsApi = skillsApi;
        _walkManager = walkManager;
        _skillSelector = skillSelector;
        _itemSelector = itemSelector;
    }

    /// <inheritdoc />
    public IReadOnlyList<OperationQueueType> HandlingQueueTypes => _handlingTypes;

    /// <inheritdoc />
    public bool ShouldContinue(ICombatState state)
    {
        var map = state.Game.CurrentMap;


@@ 55,12 74,27 @@ public class SimpleAttackTechnique : ICombatTechnique
            return false;
        }

        var entity = map.Entities.GetEntity<ILivingEntity>(_targetId);
        return !(entity is null || (entity.Hp is not null && (entity.Hp.Amount <= 0 || entity.Hp.Percentage <= 0)));
        if (_target is null)
        {
            _target = map.Entities.GetEntity<ILivingEntity>(_targetId);
        }

        return !(_target is null || (_target.Hp is not null && (_target.Hp.Amount <= 0 || _target.Hp.Percentage <= 0)));
    }

    /// <inheritdoc />
    public Result HandleWaiting(OperationQueueType queueType, ICombatState state, ICombatOperation operation)
    { // does not do anything, just wait.
        return Result.FromSuccess();
    }

    /// <inheritdoc />
    public Result<long?> HandleCombatStep(ICombatState state)
    public Result HandleError(ICombatState state, Result result)
    { // no handling of errors is done
        return result;
    }

    private Result<long?> HandleTotalControl(ICombatState state)
    {
        var map = state.Game.CurrentMap;
        if (map is null)


@@ 94,9 128,8 @@ public class SimpleAttackTechnique : ICombatTechnique
            }

            var characterMp = character.Mp?.Amount ?? 0;
            var usableSkills = new[] { skills.PrimarySkill, skills.SecondarySkill }
                .Concat(skills.OtherSkills)
                .Where(x => x.Info is not null && x.Info.HitType != HitType.AlliesInZone)
            var usableSkills = skills.AllSkills
                .Where(x => x.Info is not null && x.Info.HitType != HitType.AlliesInZone && x.Info.SkillType == SkillType.Player)
                .Where(x => !x.IsOnCooldown && characterMp >= (x.Info?.MpCost ?? long.MaxValue));

            var skillResult = _skillSelector.GetSelectedSkill(usableSkills);


@@ 138,20 171,67 @@ public class SimpleAttackTechnique : ICombatTechnique

        if (!character.Position.Value.IsInRange(_target.Position.Value, range))
        {
            state.WalkInRange(_walkManager, _target, _currentSkill.Info.Range);
            state.WalkInRange(_walkManager, _target, range);
        }
        else
        {
            state.UseSkill(_currentSkill, _target);
            state.UseSkill(_skillsApi, _currentSkill, _target);
            _currentSkill = null;
        }

        return _target.Id;
    }

    private Result<long?> HandleItem(ICombatState state)
    {
        var shouldUseItemResult = _itemSelector.ShouldUseItem(state);
        if (!shouldUseItemResult.IsDefined(out var shouldUseItem))
        {
            return Result<long?>.FromError(shouldUseItemResult);
        }

        if (!shouldUseItem)
        {
            return _targetId;
        }

        var inventory = state.Game.Inventory;
        if (inventory is null)
        {
            return _targetId;
        }

        var main = inventory.GetBag(BagType.Main)
            .Where(x => x is { Amount: > 0, Item.Info.Type: ItemType.Potion })
            .Select(x => new InventoryItem(BagType.Main, x));
        var etc = inventory.GetBag(BagType.Etc)
            .Where(x => x is { Amount: > 0, Item.Info.Type: ItemType.Food or ItemType.Snack })
            .Select(x => new InventoryItem(BagType.Etc, x));

        var possibleItems = main.Concat(etc).ToList();

        var itemResult = _itemSelector.GetSelectedItem(state, possibleItems);

        if (!itemResult.IsDefined(out var item))
        {
            return Result<long?>.FromError(itemResult);
        }

        state.UseItem(item);
        return _targetId;
    }

    /// <inheritdoc />
    public Result HandleError(ICombatState state, ICombatOperation operation, Result result)
    public Result<long?> HandleNextCombatStep(OperationQueueType queueType, ICombatState state)
    {
        return result;
        switch (queueType)
        {
            case OperationQueueType.Item:
                return HandleItem(state);
            case OperationQueueType.TotalControl:
                return HandleTotalControl(state);
        }

        throw new InvalidOperationException("SimpleAttackTechnique supports only Item and TotalControl queue types.");
    }
}
\ No newline at end of file

A Tests/NosSmooth.Core.Tests/Contracts/ContractData.cs => Tests/NosSmooth.Core.Tests/Contracts/ContractData.cs +28 -0
@@ 0,0 1,28 @@
//
//  ContractData.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.Tests.Contracts;

/// <summary>
/// Data for updating a contract.
/// </summary>
/// <typeparam name="T">The type of the data.</typeparam>
public class ContractData<T>
{
    /// <summary>
    /// Initializes a new instance of the <see cref="ContractData{T}"/> class.
    /// </summary>
    /// <param name="data">The data to pass.</param>
    public ContractData(T data)
    {
        Data = data;
    }

    /// <summary>
    /// Gets the data.
    /// </summary>
    public T Data { get; }
}
\ No newline at end of file

A Tests/NosSmooth.Core.Tests/Contracts/ContractError.cs => Tests/NosSmooth.Core.Tests/Contracts/ContractError.cs +18 -0
@@ 0,0 1,18 @@
//
//  ContractError.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.Tests.Contracts;

/// <summary>
/// Errors for a contract.
/// </summary>
public enum ContractError
{
    /// <summary>
    /// An error.
    /// </summary>
    Error1
}
\ No newline at end of file

A Tests/NosSmooth.Core.Tests/Contracts/ContractMultipleStates.cs => Tests/NosSmooth.Core.Tests/Contracts/ContractMultipleStates.cs +33 -0
@@ 0,0 1,33 @@
//
//  ContractMultipleStates.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.Tests.Contracts;

/// <summary>
/// Extends DefaultStates to have more states to test.
/// </summary>
public enum ContractMultipleStates
{
    /// <summary>
    /// Initial state.
    /// </summary>
    None,

    /// <summary>
    /// Contract executed, request issued.
    /// </summary>
    Requested,

    /// <summary>
    /// A response was obtained.
    /// </summary>
    ResponseObtained,

    /// <summary>
    /// Something else happening after obtaining first response.
    /// </summary>
    AfterResponseObtained,
}
\ No newline at end of file

A Tests/NosSmooth.Core.Tests/Contracts/ContractTests.cs => Tests/NosSmooth.Core.Tests/Contracts/ContractTests.cs +360 -0
@@ 0,0 1,360 @@
//
//  ContractTests.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.Contracts;
using Remora.Results;
using Shouldly;
using Xunit;

namespace NosSmooth.Core.Tests.Contracts;

/// <summary>
/// Tests basics of contract system.
/// </summary>
public class ContractTests
{
    /// <summary>
    /// Tests that the contract is executed.
    /// </summary>
    /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
    [Fact]
    public async Task Test_GetsExecuted()
    {
        var contractor = new Contractor();
        var mock = new MockClass();

        var contract = new ContractBuilder<long, DefaultStates, NoErrors>(contractor, DefaultStates.None)
            .SetMoveAction(DefaultStates.None, mock.Setup, DefaultStates.Requested)
            .SetMoveFilter<ContractData<long>>(DefaultStates.Requested, DefaultStates.ResponseObtained)
            .SetFillData<ContractData<long>>(DefaultStates.ResponseObtained, d => d.Data)
            .Build();

        contract.CurrentState.ShouldBe(DefaultStates.None);
        await contract.OnlyExecuteAsync();
        contract.CurrentState.ShouldBe(DefaultStates.Requested);
        mock.Executed.ShouldBeTrue();
        mock.ExecutedTimes.ShouldBe(1);
    }

    /// <summary>
    /// Tests that the contract response is obtained.
    /// </summary>
    /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
    [Fact]
    public async Task Test_ResponseObtained()
    {
        var contractor = new Contractor();
        var mock = new MockClass();

        var contract = new ContractBuilder<long, DefaultStates, NoErrors>(contractor, DefaultStates.None)
            .SetMoveAction(DefaultStates.None, mock.Setup, DefaultStates.Requested)
            .SetMoveFilter<ContractData<long>>(DefaultStates.Requested, DefaultStates.ResponseObtained)
            .SetFillData<ContractData<long>>(DefaultStates.ResponseObtained, d => d.Data)
            .Build();

        await contract.OnlyExecuteAsync();
        contract.Register();
        await contractor.Update(new ContractData<long>(5));
        contract.CurrentState.ShouldBe(DefaultStates.ResponseObtained);
        contract.Data.ShouldBe(5);

        await contractor.Update(new ContractData<long>(10));
        contract.Data.ShouldBe(5);
        contract.Unregister();

        mock.ExecutedTimes.ShouldBe(1);
    }

    /// <summary>
    /// Tests that the contract response is obtained.
    /// </summary>
    /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
    [Fact]
    public async Task Test_WaitFor()
    {
        var contractor = new Contractor();
        var mock = new MockClass();

        var contract = new ContractBuilder<long, DefaultStates, NoErrors>(contractor, DefaultStates.None)
            .SetMoveAction(DefaultStates.None, mock.Setup, DefaultStates.Requested)
            .SetMoveFilter<ContractData<long>>(DefaultStates.Requested, DefaultStates.ResponseObtained)
            .SetFillData<ContractData<long>>(DefaultStates.ResponseObtained, d => d.Data)
            .Build();

        Task.Run
        (
            async () =>
            {
                await Task.Delay(500);
                await contractor.Update(new ContractData<long>(15));
            }
        );

        var result = await contract.WaitForAsync(DefaultStates.ResponseObtained);
        result.IsSuccess.ShouldBeTrue();
        result.Entity.ShouldBe(15);
        contract.IsRegistered.ShouldBeFalse(); // trust the contract for now.
        contract.CurrentState.ShouldBe(DefaultStates.ResponseObtained);
        mock.ExecutedTimes.ShouldBe(1);
    }

    /// <summary>
    /// Tests that the contract response is obtained.
    /// </summary>
    /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
    [Fact]
    public async Task Test_WaitForMoreStates()
    {
        var contractor = new Contractor();
        var mock = new MockClass();

        var contract = new ContractBuilder<long, ContractMultipleStates, NoErrors>
                (contractor, ContractMultipleStates.None)
            .SetMoveAction(ContractMultipleStates.None, mock.Setup, ContractMultipleStates.Requested)
            .SetMoveFilter<ContractData<long>>
                (ContractMultipleStates.Requested, ContractMultipleStates.ResponseObtained)
            .SetFillData<ContractData<long>>(ContractMultipleStates.ResponseObtained, d => d.Data)
            .SetMoveFilter<ContractData<bool>>
                (ContractMultipleStates.ResponseObtained, c => c.Data, ContractMultipleStates.AfterResponseObtained)
            .Build();

        Task.Run
        (
            async () =>
            {
                await Task.Delay(500);
                await contractor.Update(new ContractData<long>(15));
                await Task.Delay(200);
                await contractor.Update(new ContractData<bool>(true));
            }
        );

        var result = await contract.WaitForAsync(ContractMultipleStates.AfterResponseObtained);
        result.IsSuccess.ShouldBeTrue();
        result.Entity.ShouldBe(15);
        contract.IsRegistered.ShouldBeFalse(); // trust the contract for now.
        contract.CurrentState.ShouldBe(ContractMultipleStates.AfterResponseObtained);
        mock.ExecutedTimes.ShouldBe(1);
    }

    /// <summary>
    /// Tests that the contract response is obtained.
    /// </summary>
    /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
    [Fact]
    public async Task Test_MoreStatesFollowed()
    {
        var contractor = new Contractor();
        var mock = new MockClass();

        var contract = new ContractBuilder<long, ContractMultipleStates, NoErrors>
                (contractor, ContractMultipleStates.None)
            .SetMoveAction(ContractMultipleStates.None, mock.Setup, ContractMultipleStates.Requested)
            .SetMoveFilter<ContractData<long>>
                (ContractMultipleStates.Requested, ContractMultipleStates.ResponseObtained)
            .SetFillData<ContractData<long>>(ContractMultipleStates.ResponseObtained, d => d.Data)
            .SetMoveFilter<ContractData<bool>>
                (ContractMultipleStates.ResponseObtained, c => c.Data, ContractMultipleStates.AfterResponseObtained)
            .Build();

        await contract.OnlyExecuteAsync();
        contract.Register();
        await contractor.Update(new ContractData<long>(15));
        await contractor.Update(new ContractData<bool>(true));
        contract.Unregister();
        var result = await contract.WaitForAsync(ContractMultipleStates.AfterResponseObtained);
        result.IsSuccess.ShouldBeTrue();
        result.Entity.ShouldBe(15);
        contract.IsRegistered.ShouldBeFalse(); // trust the contract for now.
        contract.CurrentState.ShouldBe(ContractMultipleStates.AfterResponseObtained);
        mock.ExecutedTimes.ShouldBe(1);
    }

    /// <summary>
    /// Tests that the contract response is obtained.
    /// </summary>
    /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
    [Fact]
    public async Task Test_WaitForTimeout()
    {
        var contractor = new Contractor();
        var mock = new MockClass();

        var contract = new ContractBuilder<long, ContractMultipleStates, NoErrors>
                (contractor, ContractMultipleStates.None)
            .SetMoveAction(ContractMultipleStates.None, mock.Setup, ContractMultipleStates.Requested)
            .SetMoveFilter<ContractData<long>>
                (ContractMultipleStates.Requested, ContractMultipleStates.ResponseObtained)
            .SetFillData<ContractData<long>>(ContractMultipleStates.ResponseObtained, d => d.Data)
            .SetTimeout
            (
                ContractMultipleStates.ResponseObtained,
                TimeSpan.FromMilliseconds(100),
                ContractMultipleStates.AfterResponseObtained
            )
            .Build();

        Task.Run
        (
            async () =>
            {
                await Task.Delay(500);
                await contractor.Update(new ContractData<long>(15));
            }
        );

        var result = await contract.WaitForAsync(ContractMultipleStates.AfterResponseObtained);
        result.IsSuccess.ShouldBeTrue();
        result.Entity.ShouldBe(15);
        contract.IsRegistered.ShouldBeFalse(); // trust the contract for now.
        contract.CurrentState.ShouldBe(ContractMultipleStates.AfterResponseObtained);
        mock.ExecutedTimes.ShouldBe(1);
    }

    /// <summary>
    /// Tests that the contract response is obtained.
    /// </summary>
    /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
    [Fact]
    public async Task Test_MultipleContracts()
    {
        var contractor = new Contractor();
        var mock = new MockClass();

        var contract1 = new ContractBuilder<long, ContractMultipleStates, NoErrors>
                (contractor, ContractMultipleStates.None)
            .SetMoveAction(ContractMultipleStates.None, mock.Setup, ContractMultipleStates.Requested)
            .SetMoveFilter<ContractData<long>>
            (
                ContractMultipleStates.Requested,
                d => d.Data > 10 && d.Data < 20,
                ContractMultipleStates.ResponseObtained
            )
            .SetFillData<ContractData<long>>(ContractMultipleStates.ResponseObtained, d => d.Data)
            .Build();

        var contract2 = new ContractBuilder<long, ContractMultipleStates, NoErrors>
                (contractor, ContractMultipleStates.None)
            .SetMoveAction(ContractMultipleStates.None, mock.Setup, ContractMultipleStates.Requested)
            .SetMoveFilter<ContractData<long>>
                (ContractMultipleStates.Requested, d => d.Data > 20, ContractMultipleStates.ResponseObtained)
            .SetFillData<ContractData<long>>(ContractMultipleStates.ResponseObtained, d => d.Data)
            .Build();

        await contract1.OnlyExecuteAsync();
        await contract2.OnlyExecuteAsync();

        Task.Run
        (
            async () =>
            {
                await Task.Delay(500);
                await contractor.Update(new ContractData<long>(15));
                await Task.Delay(500);
                await contractor.Update(new ContractData<long>(25));
            }
        );

        var results = await Task.WhenAll
        (
            contract1.WaitForAsync(ContractMultipleStates.ResponseObtained),
            contract2.WaitForAsync(ContractMultipleStates.ResponseObtained)
        );
        results[0].IsSuccess.ShouldBeTrue();
        results[0].Entity.ShouldBe(15);
        results[1].IsSuccess.ShouldBeTrue();
        results[1].Entity.ShouldBe(25);
        contract1.CurrentState.ShouldBe(ContractMultipleStates.ResponseObtained);
        contract2.CurrentState.ShouldBe(ContractMultipleStates.ResponseObtained);
        mock.ExecutedTimes.ShouldBe(2);
    }

    /// <summary>
    /// Tests that the contract response is obtained.
    /// </summary>
    /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
    [Fact]
    public async Task Test_ErrorsFired()
    {
        var contractor = new Contractor();
        var mock = new MockClass();

        var contract = new ContractBuilder<long, ContractMultipleStates, ContractError>
                (contractor, ContractMultipleStates.None)
            .SetMoveAction(ContractMultipleStates.None, mock.Setup, ContractMultipleStates.Requested)
            .SetMoveFilter<ContractData<long>>
                (ContractMultipleStates.Requested, ContractMultipleStates.ResponseObtained)
            .SetFillData<ContractData<long>>(ContractMultipleStates.ResponseObtained, d => d.Data)
            .SetError<ContractData<long>>
            (
                ContractMultipleStates.Requested,
                d =>
                {
                    if (d.Data == 15)
                    {
                        return ContractError.Error1;
                    }

                    return null;
                }
            )
            .Build();

        Task.Run
        (
            async () =>
            {
                await Task.Delay(500);
                await contractor.Update(new ContractData<long>(15));
            }
        );

        var result = await contract.WaitForAsync(ContractMultipleStates.AfterResponseObtained);
        result.IsSuccess.ShouldBeFalse();
        result.Error.ShouldBeOfType<ContractError<ContractError>>();
        ((ContractError<ContractError>)result.Error).Error.ShouldBe(ContractError.Error1);
        contract.CurrentState.ShouldBe(ContractMultipleStates.Requested);
        contract.IsRegistered.ShouldBeFalse();
        mock.ExecutedTimes.ShouldBe(1);
    }
}

/// <summary>
/// A class for verifying setup was called.
/// </summary>
public class MockClass
{
    /// <summary>
    /// Gets the number of times <see cref="Setup"/> was called.
    /// </summary>
    public int ExecutedTimes { get; private set; }

    /// <summary>
    /// Gets whether <see cref="Executed"/> was executed.
    /// </summary>
    public bool Executed { get; private set; }

    /// <summary>
    /// Sets Executed to true..
    /// </summary>
    /// <param name="data">The data. should be null.</param>
    /// <param name="ct">The cancellation token used for cancelling the operation.</param>
    /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
    public Task<Result<bool>> Setup(object? data, CancellationToken ct)
    {
        if (data is not null)
        {
            throw new ArgumentException("Should be null.", nameof(data));
        }

        Executed = true;
        ExecutedTimes++;
        return Task.FromResult(Result<bool>.FromSuccess(true));
    }
}
\ No newline at end of file

M Tests/NosSmooth.Core.Tests/NosSmooth.Core.Tests.csproj => Tests/NosSmooth.Core.Tests/NosSmooth.Core.Tests.csproj +2 -0
@@ 10,6 10,8 @@
    <ItemGroup>
        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
        <PackageReference Include="Moq" Version="4.18.4" />
        <PackageReference Include="Shouldly" Version="4.1.0" />
        <PackageReference Include="xunit" Version="2.4.1" />
        <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
            <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

Do not follow this link