~ruther/NosSmooth

674cee15aaa8921e78e6935331e18278f055c36e — František Boháček 2 years ago 2490c12
feat(core): add basics of a contract system
A Core/NosSmooth.Core/Contracts/ContractError.cs => Core/NosSmooth.Core/Contracts/ContractError.cs +16 -0
@@ 0,0 1,16 @@
//
//  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.

using Remora.Results;

namespace NosSmooth.Core.Contracts;

/// <summary>
/// An error from contract.
/// </summary>
/// <param name="Error">The error.</param>
/// <typeparam name="TError">The error.</typeparam>
public record ContractError<TError>(TError Error) : ResultError($"Contract has returned an error {Error}.");
\ No newline at end of file

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

/// <summary>
/// A response to Contract.Update.
/// </summary>
public enum ContractUpdateResponse
{
    /// <summary>
    /// The contract is not interested in the given data.
    /// </summary>
    NotInterested,

    /// <summary>
    /// The contract is interested in the given data,
    /// the data was used to update the contract and
    /// the contract wants to stay registered.
    /// </summary>
    Interested,

    /// <summary>
    /// The contract is interested in the given data,
    /// the data was used to update the contract and
    /// the contract wants to be unregistered from the contractor.
    /// </summary>
    InterestedAndUnregister
}
\ No newline at end of file

A Core/NosSmooth.Core/Contracts/Contractor.cs => Core/NosSmooth.Core/Contracts/Contractor.cs +149 -0
@@ 0,0 1,149 @@
//
//  Contractor.cs
//
//  Copyright (c) František Boháček. All rights reserved.
//  Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NosSmooth.Core.Contracts.Responders;
using Remora.Results;

namespace NosSmooth.Core.Contracts;

/// <summary>
/// A class holding <see cref="IContract"/>s,
/// updates the contracts.
/// </summary>
public class Contractor : IEnumerable<IContract>
{
    /// <summary>
    /// Maximum time a contract may be registered for.
    /// </summary>
    public static readonly TimeSpan Timeout = new TimeSpan(0, 5, 0);

    private readonly List<ContractInfo> _contracts;
    private readonly SemaphoreSlim _semaphore;

    /// <summary>
    /// Initializes a new instance of the <see cref="Contractor"/> class.
    /// </summary>
    public Contractor()
    {
        _semaphore = new SemaphoreSlim(1, 1);
        _contracts = new List<ContractInfo>();
    }

    /// <summary>
    /// Register the given contract to receive feedback for it.
    /// </summary>
    /// <param name="contract">The contract to register.</param>
    public void Register(IContract contract)
    {
        try
        {
            _semaphore.Wait();
            _contracts.Add(new ContractInfo(contract, DateTime.Now));
        }
        finally
        {
            _semaphore.Release();
        }
    }

    /// <summary>
    /// Unregister the given contract, no more info will be received.
    /// </summary>
    /// <param name="contract">The contract.</param>
    public void Unregister(IContract contract)
    {
        try
        {
            _semaphore.Wait();
            _contracts.RemoveAll(ci => ci.contract == contract);
        }
        finally
        {
            _semaphore.Release();
        }
    }

    /// <summary>
    /// Update all of the contracts with the given data.
    /// </summary>
    /// <remarks>
    /// Called from <see cref="ContractPacketResponder"/>
    /// or similar. Used for updating the state.
    /// The contracts look for actions that trigger updates
    /// and in case it matches the <paramref name="data"/>,
    /// the state is switched.
    /// </remarks>
    /// <param name="data">The data that were received.</param>
    /// <param name="ct">The cancellation token used for cancelling the operation.</param>
    /// <typeparam name="TData">The type of the data.</typeparam>
    /// <returns>The result that may or may not have succeeded.</returns>
    public async Task<Result> Update<TData>(TData data, CancellationToken ct = default)
    {
        var errors = new List<IResult>();
        var toRemove = new List<ContractInfo>();
        try
        {
            await _semaphore.WaitAsync(ct);
            foreach (var info in _contracts)
            {
                if (DateTime.Now - info.addedAt > Timeout)
                {
                    errors.Add
                    (
                        (Result)new GenericError
                        (
                            $"A contract {info.contract} has been registered for too long and was unregistered automatically."
                        )
                    );
                    continue;
                }

                var result = await info.contract.Update(data);
                if (!result.IsDefined(out var response))
                {
                    errors.Add(result);
                }

                if (response == ContractUpdateResponse.InterestedAndUnregister)
                {
                    toRemove.Add(info);
                }
            }

            foreach (var contract in toRemove)
            {
                _contracts.Remove(contract);
            }

            return errors.Count switch
            {
                0 => Result.FromSuccess(),
                1 => (Result)errors[0],
                _ => new AggregateError(errors)
            };
        }
        finally
        {
            _semaphore.Release();
        }
    }

    /// <inheritdoc />
    public IEnumerator<IContract> GetEnumerator()
        => _contracts.Select(x => x.contract).GetEnumerator();

    /// <inheritdoc/>
    IEnumerator IEnumerable.GetEnumerator()
        => GetEnumerator();

    private record struct ContractInfo(IContract contract, DateTime addedAt);
}
\ No newline at end of file

A Core/NosSmooth.Core/Contracts/DefaultContract.cs => Core/NosSmooth.Core/Contracts/DefaultContract.cs +263 -0
@@ 0,0 1,263 @@
//
//  DefaultContract.cs
//
//  Copyright (c) František Boháček. All rights reserved.
//  Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Remora.Results;

namespace NosSmooth.Core.Contracts;

/// <summary>
/// A generic implementation of contract
/// supporting any data.
/// </summary>
/// <typeparam name="TData">The data type.</typeparam>
/// <typeparam name="TState">The states.</typeparam>
/// <typeparam name="TError">The errors that may be returned.</typeparam>
public class DefaultContract<TData, TState, TError> : IContract<TData, TState>
    where TState : struct, IComparable
    where TData : notnull
{
    /// <summary>
    /// An action to execute when a state is reached.
    /// </summary>
    /// <param name="data">The data that led to the state.</param>
    /// <param name="ct">The cancellation token used for cancelling the operation.</param>
    /// <returns>The result that may or may not have succeeded.</returns>
    public delegate Task<Result<(TError? Error, TState? NextState)>> StateActionAsync(object? data, CancellationToken ct);

    /// <summary>
    /// An action to execute when a state that may fill the data is reached.
    /// Returns the data to fill.
    /// </summary>
    /// <param name="data">The data that led to the state.</param>
    /// <param name="ct">The cancellation token used for cancelling the operation.</param>
    /// <returns>The result that may or may not have succeeded.</returns>
    public delegate Task<Result<TData>> FillDataAsync(object data, CancellationToken ct);

    private readonly IDictionary<TState, StateActionAsync> _actions;
    private readonly Contractor _contractor;
    private readonly TState _defaultState;

    private readonly TState _fillAtState;
    private readonly FillDataAsync _fillData;

    private readonly TimeSpan? _timeout;

    private TError? _error;
    private Result? _resultError;

    private TState? _waitingFor;
    private bool _unregisterAtWaitingFor;
    private CancellationTokenSource? _waitCancellationSource;

    /// <summary>
    /// Initializes a new instance of the <see cref="DefaultContract{TData, TState, TError}"/> class.
    /// </summary>
    /// <param name="contractor">The contractor.</param>
    /// <param name="defaultState">The default state.</param>
    /// <param name="fillAtState">The state to fill data at.</param>
    /// <param name="fillData">The function to fill the data.</param>
    /// <param name="actions">The actions to execute at each state.</param>
    /// <param name="timeout">The timeout.</param>
    public DefaultContract
    (
        Contractor contractor,
        TState defaultState,
        TState fillAtState,
        FillDataAsync fillData,
        IDictionary<TState, StateActionAsync> actions,
        TimeSpan? timeout
    )
    {
        _timeout = timeout;

        _defaultState = defaultState;
        _contractor = contractor;
        CurrentState = defaultState;

        _actions = actions;
        _fillData = fillData;
        _fillAtState = fillAtState;
    }

    /// <inheritdoc />
    public TState CurrentState { get; private set; }

    /// <inheritdoc />
    public TData? Data { get; private set; }

    /// <inheritdoc />
    public bool IsRegistered { get; private set; }

    /// <inheritdoc />
    public void Register()
    {
        if (!IsRegistered)
        {
            _contractor.Register(this);
            IsRegistered = true;
        }
    }

    /// <inheritdoc />
    public void Unregister()
    {
        if (IsRegistered)
        {
            _contractor.Unregister(this);
            IsRegistered = false;
        }
    }

    /// <inheritdoc />
    public async Task<Result<ContractUpdateResponse>> Update<TAny>(TAny data, CancellationToken ct = default)
    {
        if (!_actions.ContainsKey(CurrentState))
        {
            throw new Exception(); // ?
        }

        var result = await _actions[CurrentState](data, ct);
        if (!result.IsDefined(out var resultData))
        {
            _resultError = Result.FromError(result);
            _waitCancellationSource?.Cancel();
            return Result<ContractUpdateResponse>.FromError(result);
        }

        if (resultData.Error is not null)
        {
            _error = resultData.Error;
            _waitCancellationSource?.Cancel();
        }

        if (resultData.NextState is null)
        {
            return ContractUpdateResponse.NotInterested;
        }

        CurrentState = resultData.NextState.Value;
        if (_fillAtState.CompareTo(CurrentState) == 0)
        {
            var filledResult = await _fillData(data!, ct);

            if (!filledResult.IsDefined(out var filled))
            {
                _resultError = Result.FromError(filledResult);
                _waitCancellationSource?.Cancel();
                return Result<ContractUpdateResponse>.FromError(filledResult);
            }

            Data = filled;
        }

        if (_waitingFor is not null && _waitingFor.Value.CompareTo(CurrentState) == 0)
        {
            IsRegistered = false; // avoid deadlock. The cancellation will trigger unregister,

                                  // but we are inside of the lock now.
            _waitCancellationSource?.Cancel();

            if (_unregisterAtWaitingFor)
            {
                return ContractUpdateResponse.InterestedAndUnregister;
            }
        }

        // TODO: timeouts!
        return ContractUpdateResponse.Interested;
    }

    /// <inheritdoc />
    public async Task<Result> OnlyExecuteAsync(CancellationToken ct = default)
    {
        if (_actions.ContainsKey(_defaultState))
        {
            var result = await _actions[_defaultState](default, ct);
            if (!result.IsSuccess)
            {
                return Result.FromError(result);
            }
        }

        return Result.FromSuccess();
    }

    /// <inheritdoc />
    public async Task<Result<TData>> WaitForAsync
        (TState state, bool unregisterAfter = true, CancellationToken ct = default)
    {
        if (_fillAtState.CompareTo(state) > 0)
        {
            throw new InvalidOperationException
            (
                $"The requested state {state} does not guarantee data filled. The state that fills data is {_defaultState}"
            );
        }

        _waitingFor = state;
        _unregisterAtWaitingFor = unregisterAfter;
        _waitCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(ct);

        if (_timeout is not null)
        {
            _waitCancellationSource.CancelAfter(_timeout.Value);
        }

        Register();

        if (CurrentState.CompareTo(_defaultState) == 0)
        {
            var result = await OnlyExecuteAsync(ct);
            if (!result.IsSuccess)
            {
                Unregister();
                return Result<TData>.FromError(result);
            }
        }

        try
        {
            await Task.Delay(-1, _waitCancellationSource.Token);
        }
        catch
        {
            // ignored
        }
        finally
        {
            if (unregisterAfter)
            {
                Unregister();
            }
        }

        if (ct.IsCancellationRequested)
        {
            throw new TaskCanceledException();
        }

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

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

        if (Data is null)
        {
            throw new Exception("Data was null, but shouldn't have. There is an error in DefaultContract.");
        }

        return Data;
    }
}
\ No newline at end of file

A Core/NosSmooth.Core/Contracts/IContract.cs => Core/NosSmooth.Core/Contracts/IContract.cs +118 -0
@@ 0,0 1,118 @@
//
//  IContract.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.Responders;
using Remora.Results;

namespace NosSmooth.Core.Contracts;

/// <summary>
/// A contract, used for executing an operation with feedback.
/// </summary>
/// <remarks>
/// Do not use this type directly, use the generic one instead.
/// </remarks>
public interface IContract
{
    /// <summary>
    /// Gets whether this contract
    /// is registered to the contractor.
    /// </summary>
    public bool IsRegistered { get; }

    /// <summary>
    /// Register this contract into contractor.
    /// </summary>
    /// <remarks>
    /// The contract will receive data from the contractor,
    /// using CheckDataAsync method. This way there may be a
    /// feedback coming back to the contract.
    /// </remarks>
    public void Register();

    /// <summary>
    /// Unregister this contract from contractor.
    /// </summary>
    public void Unregister();

    /// <summary>
    /// Update the contract with the given received data.
    /// </summary>
    /// <remarks>
    /// Called from <see cref="ContractPacketResponder"/>
    /// or similar. Used for updating the state.
    /// The contract looks for actions that trigger updates
    /// and in case it matches the <paramref name="data"/>,
    /// the state is switched.
    /// </remarks>
    /// <param name="data">The data that were received.</param>
    /// <typeparam name="TAny">The type of the data.</typeparam>
    /// <param name="ct">The cancellation token used for cancelling the operation.</param>
    /// <returns>The result that may or may not have succeeded.</returns>
    public Task<Result<ContractUpdateResponse>> Update<TAny>(TAny data, CancellationToken ct = default);

    /// <summary>
    /// Executes the contract without registering it,
    /// running only the initial operation.
    /// </summary>
    /// <remarks>
    /// For example, to use skill, create a contract for
    /// using a skill and call this method.
    /// If you want to wait for response from the server,
    /// use <see cref="WaitForAsync"/> instead.
    /// That will register the contract and wait for response.
    /// </remarks>
    /// <param name="ct">The cancellation token used for cancelling the operation.</param>
    /// <returns>The result that may or may not have succeeded.</returns>
    public Task<Result> OnlyExecuteAsync(CancellationToken ct = default);
}

/// <summary>
/// A contract, used for executing an operation with feedback.
/// </summary>
/// <remarks>
/// Could be used for operations that may end successfully or fail
/// after some time, with response from the server.
///
/// Look at <see cref="ContractBuilder"/> for example usage.
/// </remarks>
/// <typeparam name="TData">The data returned by the contract in case of success.</typeparam>
/// <typeparam name="TState">Type containing the states of the contract.</typeparam>
public interface IContract<TData, TState> : IContract
    where TData : notnull
    where TState : IComparable
{
    /// <summary>
    /// Gets the current state of the contract.
    /// </summary>
    /// <remarks>
    /// To wait for any state, see <see cref="WaitForAsync"/>.
    /// </remarks>
    public TState CurrentState { get; }

    /// <summary>
    /// Gets the data of the contract obtained from packets/.
    /// </summary>
    /// <remarks>
    /// This won't be filled in case the contract
    /// is not registered.
    /// </remarks>
    public TData? Data { get; }

    /// <summary>
    /// Register to contractor and wait for the given state.
    /// Execute the initial action.
    /// </summary>
    /// <param name="state">The state to wait for.</param>
    /// <param name="unregisterAfter">Whether to unregister the contract from the contractor after the state is reached. The contract won't be updated anymore.</param>
    /// <param name="ct">The cancellation token used for cancelling the operation.</param>
    /// <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);
}
\ No newline at end of file

A Core/NosSmooth.Core/Contracts/Responders/ContractPacketResponder.cs => Core/NosSmooth.Core/Contracts/Responders/ContractPacketResponder.cs +35 -0
@@ 0,0 1,35 @@
//
//  ContractPacketResponder.cs
//
//  Copyright (c) František Boháček. All rights reserved.
//  Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Threading;
using System.Threading.Tasks;
using NosSmooth.Core.Packets;
using NosSmooth.Packets;
using Remora.Results;

namespace NosSmooth.Core.Contracts.Responders;

/// <summary>
/// A responder that calls Contractor update.
/// </summary>
public class ContractPacketResponder : IEveryPacketResponder
{
    private readonly Contractor _contractor;

    /// <summary>
    /// Initializes a new instance of the <see cref="ContractPacketResponder"/> class.
    /// </summary>
    /// <param name="contractor">The contractor.</param>
    public ContractPacketResponder(Contractor contractor)
    {
        _contractor = contractor;
    }

    /// <inheritdoc />
    public Task<Result> Respond<TPacket>(PacketEventArgs<TPacket> packetArgs, CancellationToken ct = default)
        where TPacket : IPacket
        => _contractor.Update(packetArgs.Packet, ct);
}
\ No newline at end of file

M Core/NosSmooth.Core/Extensions/ServiceCollectionExtensions.cs => Core/NosSmooth.Core/Extensions/ServiceCollectionExtensions.cs +6 -0
@@ 12,6 12,8 @@ using NosSmooth.Core.Client;
using NosSmooth.Core.Commands;
using NosSmooth.Core.Commands.Control;
using NosSmooth.Core.Commands.Walking;
using NosSmooth.Core.Contracts;
using NosSmooth.Core.Contracts.Responders;
using NosSmooth.Core.Packets;
using NosSmooth.Core.Stateful;
using NosSmooth.PacketSerializer.Extensions;


@@ 39,6 41,10 @@ public static class ServiceCollectionExtensions
        serviceCollection.AddPacketSerialization();
        serviceCollection.AddSingleton<CommandProcessor>();

        serviceCollection
            .AddSingleton<Contractor>()
            .AddPacketResponder<ContractPacketResponder>();

        return serviceCollection;
    }


A Core/NosSmooth.Game/Contracts/ContractEventResponder.cs => Core/NosSmooth.Game/Contracts/ContractEventResponder.cs +33 -0
@@ 0,0 1,33 @@
//
//  ContractEventResponder.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.Contracts;
using NosSmooth.Game.Events.Core;
using Remora.Results;

namespace NosSmooth.Game.Contracts;

/// <summary>
/// A responder that calls Contractor update.
/// </summary>
public class ContractEventResponder : IEveryGameResponder
{
    private readonly Contractor _contractor;

    /// <summary>
    /// Initializes a new instance of the <see cref="ContractEventResponder"/> class.
    /// </summary>
    /// <param name="contractor">The contractor.</param>
    public ContractEventResponder(Contractor contractor)
    {
        _contractor = contractor;

    }

    /// <inheritdoc />
    public Task<Result> Respond<TEvent>(TEvent gameEvent, CancellationToken ct = default)
        => _contractor.Update(gameEvent, ct);
}
\ No newline at end of file

M Core/NosSmooth.Game/Extensions/ServiceCollectionExtensions.cs => Core/NosSmooth.Game/Extensions/ServiceCollectionExtensions.cs +4 -0
@@ 8,6 8,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using NosSmooth.Core.Extensions;
using NosSmooth.Game.Apis;
using NosSmooth.Game.Contracts;
using NosSmooth.Game.Events.Core;
using NosSmooth.Game.Events.Inventory;
using NosSmooth.Game.PacketHandlers.Act4;


@@ 72,6 73,9 @@ public static class ServiceCollectionExtensions
            .AddTransient<NostaleChatPacketApi>()
            .AddTransient<NostaleSkillsPacketApi>();

        serviceCollection
            .AddScoped<IEveryGameResponder, ContractEventResponder>();

        return serviceCollection;
    }


Do not follow this link