From 674cee15aaa8921e78e6935331e18278f055c36e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Boh=C3=A1=C4=8Dek?= Date: Wed, 11 Jan 2023 22:12:36 +0100 Subject: [PATCH] feat(core): add basics of a contract system --- .../NosSmooth.Core/Contracts/ContractError.cs | 16 ++ .../Contracts/ContractUpdateResponse.cs | 32 +++ Core/NosSmooth.Core/Contracts/Contractor.cs | 149 ++++++++++ .../Contracts/DefaultContract.cs | 263 ++++++++++++++++++ Core/NosSmooth.Core/Contracts/IContract.cs | 118 ++++++++ .../Responders/ContractPacketResponder.cs | 35 +++ .../Extensions/ServiceCollectionExtensions.cs | 6 + .../Contracts/ContractEventResponder.cs | 33 +++ .../Extensions/ServiceCollectionExtensions.cs | 4 + 9 files changed, 656 insertions(+) create mode 100644 Core/NosSmooth.Core/Contracts/ContractError.cs create mode 100644 Core/NosSmooth.Core/Contracts/ContractUpdateResponse.cs create mode 100644 Core/NosSmooth.Core/Contracts/Contractor.cs create mode 100644 Core/NosSmooth.Core/Contracts/DefaultContract.cs create mode 100644 Core/NosSmooth.Core/Contracts/IContract.cs create mode 100644 Core/NosSmooth.Core/Contracts/Responders/ContractPacketResponder.cs create mode 100644 Core/NosSmooth.Game/Contracts/ContractEventResponder.cs diff --git a/Core/NosSmooth.Core/Contracts/ContractError.cs b/Core/NosSmooth.Core/Contracts/ContractError.cs new file mode 100644 index 0000000..8f71a2f --- /dev/null +++ b/Core/NosSmooth.Core/Contracts/ContractError.cs @@ -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; + +/// +/// An error from contract. +/// +/// The error. +/// The error. +public record ContractError(TError Error) : ResultError($"Contract has returned an error {Error}."); \ No newline at end of file diff --git a/Core/NosSmooth.Core/Contracts/ContractUpdateResponse.cs b/Core/NosSmooth.Core/Contracts/ContractUpdateResponse.cs new file mode 100644 index 0000000..b3c9850 --- /dev/null +++ b/Core/NosSmooth.Core/Contracts/ContractUpdateResponse.cs @@ -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; + +/// +/// A response to Contract.Update. +/// +public enum ContractUpdateResponse +{ + /// + /// The contract is not interested in the given data. + /// + NotInterested, + + /// + /// The contract is interested in the given data, + /// the data was used to update the contract and + /// the contract wants to stay registered. + /// + Interested, + + /// + /// 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. + /// + InterestedAndUnregister +} \ No newline at end of file diff --git a/Core/NosSmooth.Core/Contracts/Contractor.cs b/Core/NosSmooth.Core/Contracts/Contractor.cs new file mode 100644 index 0000000..3075029 --- /dev/null +++ b/Core/NosSmooth.Core/Contracts/Contractor.cs @@ -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; + +/// +/// A class holding s, +/// updates the contracts. +/// +public class Contractor : IEnumerable +{ + /// + /// Maximum time a contract may be registered for. + /// + public static readonly TimeSpan Timeout = new TimeSpan(0, 5, 0); + + private readonly List _contracts; + private readonly SemaphoreSlim _semaphore; + + /// + /// Initializes a new instance of the class. + /// + public Contractor() + { + _semaphore = new SemaphoreSlim(1, 1); + _contracts = new List(); + } + + /// + /// Register the given contract to receive feedback for it. + /// + /// The contract to register. + public void Register(IContract contract) + { + try + { + _semaphore.Wait(); + _contracts.Add(new ContractInfo(contract, DateTime.Now)); + } + finally + { + _semaphore.Release(); + } + } + + /// + /// Unregister the given contract, no more info will be received. + /// + /// The contract. + public void Unregister(IContract contract) + { + try + { + _semaphore.Wait(); + _contracts.RemoveAll(ci => ci.contract == contract); + } + finally + { + _semaphore.Release(); + } + } + + /// + /// Update all of the contracts with the given data. + /// + /// + /// Called from + /// or similar. Used for updating the state. + /// The contracts look for actions that trigger updates + /// and in case it matches the , + /// the state is switched. + /// + /// The data that were received. + /// The cancellation token used for cancelling the operation. + /// The type of the data. + /// The result that may or may not have succeeded. + public async Task Update(TData data, CancellationToken ct = default) + { + var errors = new List(); + var toRemove = new List(); + 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(); + } + } + + /// + public IEnumerator GetEnumerator() + => _contracts.Select(x => x.contract).GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + + private record struct ContractInfo(IContract contract, DateTime addedAt); +} \ No newline at end of file diff --git a/Core/NosSmooth.Core/Contracts/DefaultContract.cs b/Core/NosSmooth.Core/Contracts/DefaultContract.cs new file mode 100644 index 0000000..a73dead --- /dev/null +++ b/Core/NosSmooth.Core/Contracts/DefaultContract.cs @@ -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; + +/// +/// A generic implementation of contract +/// supporting any data. +/// +/// The data type. +/// The states. +/// The errors that may be returned. +public class DefaultContract : IContract + where TState : struct, IComparable + where TData : notnull +{ + /// + /// An action to execute when a state is reached. + /// + /// The data that led to the state. + /// The cancellation token used for cancelling the operation. + /// The result that may or may not have succeeded. + public delegate Task> StateActionAsync(object? data, CancellationToken ct); + + /// + /// An action to execute when a state that may fill the data is reached. + /// Returns the data to fill. + /// + /// The data that led to the state. + /// The cancellation token used for cancelling the operation. + /// The result that may or may not have succeeded. + public delegate Task> FillDataAsync(object data, CancellationToken ct); + + private readonly IDictionary _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; + + /// + /// Initializes a new instance of the class. + /// + /// The contractor. + /// The default state. + /// The state to fill data at. + /// The function to fill the data. + /// The actions to execute at each state. + /// The timeout. + public DefaultContract + ( + Contractor contractor, + TState defaultState, + TState fillAtState, + FillDataAsync fillData, + IDictionary actions, + TimeSpan? timeout + ) + { + _timeout = timeout; + + _defaultState = defaultState; + _contractor = contractor; + CurrentState = defaultState; + + _actions = actions; + _fillData = fillData; + _fillAtState = fillAtState; + } + + /// + public TState CurrentState { get; private set; } + + /// + public TData? Data { get; private set; } + + /// + public bool IsRegistered { get; private set; } + + /// + public void Register() + { + if (!IsRegistered) + { + _contractor.Register(this); + IsRegistered = true; + } + } + + /// + public void Unregister() + { + if (IsRegistered) + { + _contractor.Unregister(this); + IsRegistered = false; + } + } + + /// + public async Task> Update(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.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.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; + } + + /// + public async Task 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(); + } + + /// + public async Task> 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.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.FromError(_resultError.Value); + } + + if (_error is not null) + { + return new ContractError(_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 diff --git a/Core/NosSmooth.Core/Contracts/IContract.cs b/Core/NosSmooth.Core/Contracts/IContract.cs new file mode 100644 index 0000000..d755f23 --- /dev/null +++ b/Core/NosSmooth.Core/Contracts/IContract.cs @@ -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; + +/// +/// A contract, used for executing an operation with feedback. +/// +/// +/// Do not use this type directly, use the generic one instead. +/// +public interface IContract +{ + /// + /// Gets whether this contract + /// is registered to the contractor. + /// + public bool IsRegistered { get; } + + /// + /// Register this contract into contractor. + /// + /// + /// The contract will receive data from the contractor, + /// using CheckDataAsync method. This way there may be a + /// feedback coming back to the contract. + /// + public void Register(); + + /// + /// Unregister this contract from contractor. + /// + public void Unregister(); + + /// + /// Update the contract with the given received data. + /// + /// + /// Called from + /// or similar. Used for updating the state. + /// The contract looks for actions that trigger updates + /// and in case it matches the , + /// the state is switched. + /// + /// The data that were received. + /// The type of the data. + /// The cancellation token used for cancelling the operation. + /// The result that may or may not have succeeded. + public Task> Update(TAny data, CancellationToken ct = default); + + /// + /// Executes the contract without registering it, + /// running only the initial operation. + /// + /// + /// 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 instead. + /// That will register the contract and wait for response. + /// + /// The cancellation token used for cancelling the operation. + /// The result that may or may not have succeeded. + public Task OnlyExecuteAsync(CancellationToken ct = default); +} + +/// +/// A contract, used for executing an operation with feedback. +/// +/// +/// Could be used for operations that may end successfully or fail +/// after some time, with response from the server. +/// +/// Look at for example usage. +/// +/// The data returned by the contract in case of success. +/// Type containing the states of the contract. +public interface IContract : IContract + where TData : notnull + where TState : IComparable +{ + /// + /// Gets the current state of the contract. + /// + /// + /// To wait for any state, see . + /// + public TState CurrentState { get; } + + /// + /// Gets the data of the contract obtained from packets/. + /// + /// + /// This won't be filled in case the contract + /// is not registered. + /// + public TData? Data { get; } + + /// + /// Register to contractor and wait for the given state. + /// Execute the initial action. + /// + /// The state to wait for. + /// Whether to unregister the contract from the contractor after the state is reached. The contract won't be updated anymore. + /// The cancellation token used for cancelling the operation. + /// The data of the contract or an error. + /// Thrown in case the given state cannot fill the data. + public Task> WaitForAsync(TState state, bool unregisterAfter = true, CancellationToken ct = default); +} \ No newline at end of file diff --git a/Core/NosSmooth.Core/Contracts/Responders/ContractPacketResponder.cs b/Core/NosSmooth.Core/Contracts/Responders/ContractPacketResponder.cs new file mode 100644 index 0000000..2063fe0 --- /dev/null +++ b/Core/NosSmooth.Core/Contracts/Responders/ContractPacketResponder.cs @@ -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; + +/// +/// A responder that calls Contractor update. +/// +public class ContractPacketResponder : IEveryPacketResponder +{ + private readonly Contractor _contractor; + + /// + /// Initializes a new instance of the class. + /// + /// The contractor. + public ContractPacketResponder(Contractor contractor) + { + _contractor = contractor; + } + + /// + public Task Respond(PacketEventArgs packetArgs, CancellationToken ct = default) + where TPacket : IPacket + => _contractor.Update(packetArgs.Packet, ct); +} \ No newline at end of file diff --git a/Core/NosSmooth.Core/Extensions/ServiceCollectionExtensions.cs b/Core/NosSmooth.Core/Extensions/ServiceCollectionExtensions.cs index 07d0753..ac8c6fa 100644 --- a/Core/NosSmooth.Core/Extensions/ServiceCollectionExtensions.cs +++ b/Core/NosSmooth.Core/Extensions/ServiceCollectionExtensions.cs @@ -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(); + serviceCollection + .AddSingleton() + .AddPacketResponder(); + return serviceCollection; } diff --git a/Core/NosSmooth.Game/Contracts/ContractEventResponder.cs b/Core/NosSmooth.Game/Contracts/ContractEventResponder.cs new file mode 100644 index 0000000..029dc6f --- /dev/null +++ b/Core/NosSmooth.Game/Contracts/ContractEventResponder.cs @@ -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; + +/// +/// A responder that calls Contractor update. +/// +public class ContractEventResponder : IEveryGameResponder +{ + private readonly Contractor _contractor; + + /// + /// Initializes a new instance of the class. + /// + /// The contractor. + public ContractEventResponder(Contractor contractor) + { + _contractor = contractor; + + } + + /// + public Task Respond(TEvent gameEvent, CancellationToken ct = default) + => _contractor.Update(gameEvent, ct); +} \ No newline at end of file diff --git a/Core/NosSmooth.Game/Extensions/ServiceCollectionExtensions.cs b/Core/NosSmooth.Game/Extensions/ServiceCollectionExtensions.cs index 2b44cde..09078a6 100644 --- a/Core/NosSmooth.Game/Extensions/ServiceCollectionExtensions.cs +++ b/Core/NosSmooth.Game/Extensions/ServiceCollectionExtensions.cs @@ -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() .AddTransient(); + serviceCollection + .AddScoped(); + return serviceCollection; } -- 2.48.1