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;
}