~ruther/NosSmooth

3a690ba9750370c19c9612c8ef29132ab416acb2 — Rutherther 2 years ago e8290bc + 48f8dac
Merge pull request #32 from Rutherther/combat

Add combat extension
48 files changed, 1916 insertions(+), 17 deletions(-)

M Core/NosSmooth.Core/NosSmooth.Core.csproj
M Core/NosSmooth.Game/Data/Entities/Npc.cs
M Core/NosSmooth.Game/NosSmooth.Game.csproj
M Core/NosSmooth.Game/PacketHandlers/Map/InResponder.cs
M Data/NosSmooth.Data.Abstractions/Infos/ISkillInfo.cs
M Data/NosSmooth.Data.NOSFiles/NosSmooth.Data.NOSFiles.csproj
A Extensions/NosSmooth.Extensions.Combat/CombatManager.cs
A Extensions/NosSmooth.Extensions.Combat/CombatState.cs
A Extensions/NosSmooth.Extensions.Combat/Errors/CharacterNotInitializedError.cs
A Extensions/NosSmooth.Extensions.Combat/Errors/EntityNotFoundError.cs
A Extensions/NosSmooth.Extensions.Combat/Errors/ItemNotFoundError.cs
A Extensions/NosSmooth.Extensions.Combat/Errors/MapNotInitializedError.cs
A Extensions/NosSmooth.Extensions.Combat/Errors/MissingInfoError.cs
A Extensions/NosSmooth.Extensions.Combat/Errors/SkillNotFoundError.cs
A Extensions/NosSmooth.Extensions.Combat/Errors/TargetNotSetError.cs
A Extensions/NosSmooth.Extensions.Combat/Errors/UnusableOperationError.cs
A Extensions/NosSmooth.Extensions.Combat/Extensions/CombatStateExtensions.cs
A Extensions/NosSmooth.Extensions.Combat/Extensions/ServiceCollectionExtensions.cs
A Extensions/NosSmooth.Extensions.Combat/ICombatState.cs
A Extensions/NosSmooth.Extensions.Combat/IsExternalInit.cs
M Extensions/NosSmooth.Extensions.Combat/NosSmooth.Extensions.Combat.csproj
A Extensions/NosSmooth.Extensions.Combat/Operations/CanBeUsedResponse.cs
A Extensions/NosSmooth.Extensions.Combat/Operations/ICombatOperation.cs
A Extensions/NosSmooth.Extensions.Combat/Operations/UseItemOperation.cs
A Extensions/NosSmooth.Extensions.Combat/Operations/UsePrimarySkillOperation.cs
A Extensions/NosSmooth.Extensions.Combat/Operations/UseSkillOperation.cs
A Extensions/NosSmooth.Extensions.Combat/Operations/WalkInRangeOperation.cs
A Extensions/NosSmooth.Extensions.Combat/Operations/WalkOperation.cs
A Extensions/NosSmooth.Extensions.Combat/Policies/EnemyPolicy.cs
A Extensions/NosSmooth.Extensions.Combat/Policies/UseItemPolicy.cs
A Extensions/NosSmooth.Extensions.Combat/Policies/UseSkillPolicy.cs
A Extensions/NosSmooth.Extensions.Combat/Responders/CancelResponder.cs
A Extensions/NosSmooth.Extensions.Combat/Responders/SuResponder.cs
A Extensions/NosSmooth.Extensions.Combat/Selectors/IEnemySelector.cs
A Extensions/NosSmooth.Extensions.Combat/Selectors/IItemSelector.cs
A Extensions/NosSmooth.Extensions.Combat/Selectors/ISkillSelector.cs
A Extensions/NosSmooth.Extensions.Combat/Techniques/ICombatTechnique.cs
A Extensions/NosSmooth.Extensions.Combat/Techniques/SimpleAttackTechnique.cs
A Extensions/NosSmooth.Extensions.Pathfinding/Errors/PathNotFoundError.cs
M Extensions/NosSmooth.Extensions.Pathfinding/NosSmooth.Extensions.Pathfinding.csproj
M Extensions/NosSmooth.Extensions.Pathfinding/Pathfinder.cs
M NosSmooth.sln
M Packets/NosSmooth.PacketSerializer/Converters/Packets/UpgradeRareSubPacketConverter.cs
M Packets/NosSmooth.PacketSerializer/NosSmooth.PacketSerializer.csproj
M Packets/NosSmooth.Packets/NosSmooth.Packets.csproj
M Packets/NosSmooth.Packets/Server/Skills/SkiPacket.cs
M Packets/NosSmooth.Packets/Server/Skills/SkiSubPacket.cs
A Tests/NosSmooth.Packets.Tests/Converters/Packets/SkiPacketConverterTests.cs
M Core/NosSmooth.Core/NosSmooth.Core.csproj => Core/NosSmooth.Core/NosSmooth.Core.csproj +3 -2
@@ 8,8 8,9 @@
        <Description>NosSmooth Core library allowing implementing nostale client, handling packets and commands.</Description>
        <RepositoryUrl>https://github.com/Rutherther/NosSmooth/</RepositoryUrl>
        <PackageLicenseExpression>MIT</PackageLicenseExpression>
        <PackageVersion>2.0.0</PackageVersion>
        <PackageReleaseNotes>Make internal classes that do not need to be exposed.</PackageReleaseNotes>
        <PackageVersion>3.0.0</PackageVersion>
        <PackageReleaseNotes>Change HandleReceivedPacket, HandleSentPacket to HandlePacket.
Fix bugs.</PackageReleaseNotes>
    </PropertyGroup>

    <ItemGroup>

M Core/NosSmooth.Game/Data/Entities/Npc.cs => Core/NosSmooth.Game/Data/Entities/Npc.cs +6 -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 NosSmooth.Data.Abstractions.Infos;
using NosSmooth.Game.Data.Info;
using NosSmooth.Packets.Enums;



@@ 15,6 16,11 @@ namespace NosSmooth.Game.Data.Entities;
public class Npc : ILivingEntity
{
    /// <summary>
    /// Gets or sets the monster info.
    /// </summary>
    public IMonsterInfo? NpcInfo { get; set; }

    /// <summary>
    /// Gets the VNum of the npc.
    /// </summary>
    public int VNum { get; internal set; }

M Core/NosSmooth.Game/NosSmooth.Game.csproj => Core/NosSmooth.Game/NosSmooth.Game.csproj +4 -2
@@ 8,8 8,10 @@
        <Description>NosSmooth Game library handling the current game state by responding to packets.</Description>
        <RepositoryUrl>https://github.com/Rutherther/NosSmooth/</RepositoryUrl>
        <PackageLicenseExpression>MIT</PackageLicenseExpression>
        <PackageVersion>1.2.1</PackageVersion>
        <PackageReleaseNotes>Release semaphore on exception.</PackageReleaseNotes>
        <PackageVersion>1.3.0</PackageVersion>
        <PackageReleaseNotes>Fix multiple packet/event handling problems.</PackageReleaseNotes>
        <AssemblyVersion>1.3.0</AssemblyVersion>
        <FileVersion>1.3.0</FileVersion>
    </PropertyGroup>

    <ItemGroup>

M Core/NosSmooth.Game/PacketHandlers/Map/InResponder.cs => Core/NosSmooth.Game/PacketHandlers/Map/InResponder.cs +42 -0
@@ 15,6 15,7 @@ using NosSmooth.Game.Data.Social;
using NosSmooth.Game.Events.Core;
using NosSmooth.Game.Events.Entities;
using NosSmooth.Game.Helpers;
using NosSmooth.Packets.Enums;
using NosSmooth.Packets.Server.Entities;
using NosSmooth.Packets.Server.Maps;
using Remora.Results;


@@ 83,6 84,11 @@ public class InResponder : IPacketResponder<InPacket>
        }
        if (packet.NonPlayerSubPacket is not null)
        {
            if (packet.EntityType == EntityType.Npc)
            {
                return await CreateNpc(packet, packet.NonPlayerSubPacket, ct);
            }

            return await CreateMonster(packet, packet.NonPlayerSubPacket, ct);
        }



@@ 163,6 169,42 @@ public class InResponder : IPacketResponder<InPacket>
        };
    }

    private async Task<Npc> CreateNpc
        (InPacket packet, InNonPlayerSubPacket nonPlayerSubPacket, CancellationToken ct)
    {
        if (packet.VNum is null)
        {
            throw new Exception("The vnum from the in packet cannot be null for monsters.");
        }

        var monsterInfoResult = await _infoService.GetMonsterInfoAsync(packet.VNum.Value, ct);
        if (!monsterInfoResult.IsDefined(out var monsterInfo))
        {
            _logger.LogWarning
            (
                "Could not obtain a monster info for vnum {vnum}: {error}",
                packet.VNum.Value,
                monsterInfoResult.ToFullString()
            );
        }

        return new Npc
        {
            VNum = packet.VNum.Value,
            NpcInfo = monsterInfo,
            Id = packet.EntityId,
            Direction = packet.Direction,
            Faction = nonPlayerSubPacket.Faction,
            Hp = new Health { Percentage = nonPlayerSubPacket.HpPercentage },
            Mp = new Health { Percentage = nonPlayerSubPacket.MpPercentage },
            Name = nonPlayerSubPacket.Name?.Name,
            Position = new Position(packet.PositionX, packet.PositionY),
            IsInvisible = nonPlayerSubPacket.IsInvisible,
            Level = monsterInfo?.Level ?? null,
            IsSitting = nonPlayerSubPacket.IsSitting
        };
    }

    private async Task<Monster> CreateMonster
        (InPacket packet, InNonPlayerSubPacket nonPlayerSubPacket, CancellationToken ct)
    {

M Data/NosSmooth.Data.Abstractions/Infos/ISkillInfo.cs => Data/NosSmooth.Data.Abstractions/Infos/ISkillInfo.cs +2 -2
@@ 30,12 30,12 @@ public interface ISkillInfo : IVNumInfo
    short ZoneRange { get; }

    /// <summary>
    /// Gets the time it takes to cast this skill. Units UNKNOWN TODO.
    /// Gets the time it takes to cast this skill. The unit is Tenth of a second. (10 means one second).
    /// </summary>
    int CastTime { get; }

    /// <summary>
    /// Gets the time of the cooldown. Units UNKNOWN TODO.
    /// Gets the time of the cooldown. The unit is Tenth of a second. (10 means one second).
    /// </summary>
    int Cooldown { get; }


M Data/NosSmooth.Data.NOSFiles/NosSmooth.Data.NOSFiles.csproj => Data/NosSmooth.Data.NOSFiles/NosSmooth.Data.NOSFiles.csproj +4 -2
@@ 7,9 7,11 @@
        <LangVersion>10</LangVersion>
        <RepositoryUrl>https://github.com/Rutherther/NosSmooth/</RepositoryUrl>
        <PackageLicenseExpression>MIT</PackageLicenseExpression>
        <PackageVersion>2.0.1</PackageVersion>
        <PackageReleaseNotes>Read map file correctly.</PackageReleaseNotes>
        <PackageVersion>2.0.2</PackageVersion>
        <PackageReleaseNotes>Correct skill mp cost position</PackageReleaseNotes>
        <TargetFramework>net7.0</TargetFramework>
        <AssemblyVersion>2.0.2</AssemblyVersion>
        <FileVersion>2.0.2</FileVersion>
    </PropertyGroup>

    <ItemGroup>

A Extensions/NosSmooth.Extensions.Combat/CombatManager.cs => Extensions/NosSmooth.Extensions.Combat/CombatManager.cs +215 -0
@@ 0,0 1,215 @@
//
//  CombatManager.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.Client;
using NosSmooth.Core.Commands.Attack;
using NosSmooth.Core.Stateful;
using NosSmooth.Extensions.Combat.Errors;
using NosSmooth.Extensions.Combat.Operations;
using NosSmooth.Extensions.Combat.Techniques;
using Remora.Results;

namespace NosSmooth.Extensions.Combat;

/// <summary>
/// The combat manager that uses techniques to attack enemies.
/// </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;

    /// <summary>
    /// Initializes a new instance of the <see cref="CombatManager"/> class.
    /// </summary>
    /// <param name="client">The NosTale client.</param>
    /// <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;
    }

    /// <summary>
    /// Enter into a combat state using the given technique.
    /// </summary>
    /// <param name="technique">The technique to use.</param>
    /// <param name="ct">The cancellation token for cancelling the operation.</param>
    /// <returns>A result that may or may not succeed.</returns>
    public async Task<Result> EnterCombatAsync(ICombatTechnique technique, CancellationToken ct = default)
    {
        var combatState = new CombatState(_client, _game, this);
        long? currentTarget = null;
        long? previousTarget = null;

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

                            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 (previousTarget != currentTarget)
                                {
                                    continue;
                                }

                                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)
                            {
                                return Result.FromError(responseResult);
                            }

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

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

                        return Result.FromSuccess();
                    }
                ),
                ct
            );

            if (!commandResult.IsSuccess)
            {
                return commandResult;
            }

            previousTarget = currentTarget;
        }
        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)
    {
        await _semaphore.WaitAsync(ct);
        try
        {
            _tokenSource.Add(tokenSource);
        }
        finally
        {
            _semaphore.Release();
        }
    }

    /// <summary>
    /// Unregister the given cancellation token registered using <see cref="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;
        }

        await _semaphore.WaitAsync(ct);
        try
        {
            _tokenSource.Remove(tokenSource);
        }
        finally
        {
            _semaphore.Release();
        }
    }

    /// <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)
    {
        await _semaphore.WaitAsync(ct);
        _cancelling = true;
        try
        {
            foreach (var tokenSource in _tokenSource)
            {
                try
                {
                    tokenSource.Cancel();
                }
                catch
                {
                    // ignored
                }
            }

            _tokenSource.Clear();
        }
        finally
        {
            _cancelling = false;
            _semaphore.Release();
        }
    }
}
\ No newline at end of file

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

    /// <summary>
    /// Initializes a new instance of the <see cref="CombatState"/> class.
    /// </summary>
    /// <param name="client">The NosTale client.</param>
    /// <param name="game">The game.</param>
    /// <param name="combatManager">The combat manager.</param>
    public CombatState(INostaleClient client, Game.Game game, CombatManager combatManager)
    {
        Client = client;
        Game = game;
        CombatManager = combatManager;
        _operations = new LinkedList<ICombatOperation>();
    }

    /// <summary>
    /// Gets whether the combat state should be quit.
    /// </summary>
    public bool ShouldQuit { get; private set; }

    /// <inheritdoc/>
    public CombatManager CombatManager { get; }

    /// <inheritdoc/>
    public Game.Game Game { get; }

    /// <inheritdoc/>
    public INostaleClient Client { get; }

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

    /// <summary>
    /// Make a step in the queue.
    /// </summary>
    /// <returns>The current operation, if any.</returns>
    public ICombatOperation? NextOperation()
    {
        var operation = _currentOperation = _operations.First?.Value;
        if (operation is not null)
        {
            _operations.RemoveFirst();
        }

        return operation;
    }

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

        if (emptyQueue)
        {
            _operations.Clear();
        }

        if (prependCurrentOperationToQueue && current is not null)
        {
            _operations.AddFirst(current);
        }
    }

    /// <inheritdoc/>
    public void EnqueueOperation(ICombatOperation operation)
    {
        _operations.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;
        }
    }
}
\ No newline at end of file

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

/// <summary>
/// The character is not initialized.
/// </summary>
public record CharacterNotInitializedError(string Field = "")
    : ResultError($"The character {Field} is not yet initialized.");
\ No newline at end of file

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

/// <summary>
/// Matching entity not found error.
/// </summary>
public record EntityNotFoundError() : ResultError("Could not find an entity that matches the conditions.");
\ No newline at end of file

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

/// <summary>
/// Matchin item not found error.
/// </summary>
public record ItemNotFoundError() : ResultError("Could not find an item that matches the conditions.");
\ No newline at end of file

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

/// <summary>
/// The map is not initialized.
/// </summary>
public record MapNotInitializedError()
    : ResultError("The map is not yet initialized.");
\ No newline at end of file

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

/// <summary>
/// Missing info of a specified type and id..
/// </summary>
/// <param name="Type">The type.</param>
/// <param name="Id">The id.</param>
public record MissingInfoError(string Type, long Id) : ResultError($"Cannot find info of {{Type}} with id {Id}.");
\ No newline at end of file

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

/// <summary>
/// Matchin skill not found.
/// </summary>
public record SkillNotFoundError() : ResultError("Could not find a skill that matches the conditions.");
\ No newline at end of file

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

public record TargetNotSetError() : ResultError("The current target is not set, the operation cannot complete.");
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/Errors/UnusableOperationError.cs => Extensions/NosSmooth.Extensions.Combat/Errors/UnusableOperationError.cs +17 -0
@@ 0,0 1,17 @@
//
//  UnusableOperationError.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.Operations;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Errors;

/// <summary>
/// An error that tells the operation was unusable.
/// </summary>
/// <param name="Operation">The operation.</param>
public record UnusableOperationError(ICombatOperation Operation)
    : ResultError("A given operation {Operation} responded that it won't be usable ever and thus there is an unrecoverable state.");
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/Extensions/CombatStateExtensions.cs => Extensions/NosSmooth.Extensions.Combat/Extensions/CombatStateExtensions.cs +90 -0
@@ 0,0 1,90 @@
//
//  CombatStateExtensions.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.Extensions.Combat.Operations;
using NosSmooth.Extensions.Combat.Policies;
using NosSmooth.Extensions.Pathfinding;
using NosSmooth.Game.Data.Characters;
using NosSmooth.Game.Data.Entities;
using NosSmooth.Game.Data.Items;
using OneOf.Types;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Extensions;

/// <summary>
/// Extension methods for <see cref="ICombatState"/>.
/// </summary>
public static class CombatStateExtensions
{
    /// <summary>
    /// Walk in the range of the given entity.
    /// </summary>
    /// <param name="state">The combat state.</param>
    /// <param name="walkManager">The walk manager.</param>
    /// <param name="entity">The entity.</param>
    /// <param name="range">The range distance to walk to.</param>
    public static void WalkInRange
    (
        this ICombatState state,
        WalkManager walkManager,
        IEntity entity,
        float range
    )
    {
        state.EnqueueOperation(new WalkInRangeOperation(walkManager, entity, range));
    }

    /// <summary>
    /// Walk to the given position.
    /// </summary>
    /// <param name="combatState">The combat state.</param>
    /// <param name="walkManager">The walk manager.</param>
    /// <param name="x">The target x coordinate.</param>
    /// <param name="y">The target y coordinate.</param>
    public static void WalkTo
    (
        this ICombatState combatState,
        WalkManager walkManager,
        short x,
        short y
    )
    {
        combatState.EnqueueOperation(new WalkOperation(walkManager, x, y));
    }

    /// <summary>
    /// Use the given skill.
    /// </summary>
    /// <param name="combatState">The combat state.</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)
    {
        combatState.EnqueueOperation(new UseSkillOperation(skill, 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));
    }

    /// <summary>
    /// Use the given item.
    /// </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)
    {
        combatState.EnqueueOperation(new UseItemOperation(item));
    }
}
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/Extensions/ServiceCollectionExtensions.cs => Extensions/NosSmooth.Extensions.Combat/Extensions/ServiceCollectionExtensions.cs +30 -0
@@ 0,0 1,30 @@
//
//  ServiceCollectionExtensions.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 Microsoft.Extensions.DependencyInjection;
using NosSmooth.Core.Extensions;
using NosSmooth.Extensions.Combat.Responders;

namespace NosSmooth.Extensions.Combat.Extensions;

/// <summary>
/// Extension methods for <see cref="IServiceCollection"/>.
/// </summary>
public static class ServiceCollectionExtensions
{
    /// <summary>
    /// Adds a NosTale combat extension. <see cref="CombatManager"/>.
    /// </summary>
    /// <param name="serviceCollection">The service collection.</param>
    /// <returns>The collection.</returns>
    public static IServiceCollection AddNostaleCombat(this IServiceCollection serviceCollection)
    {
        return serviceCollection
            .AddPacketResponder<CancelResponder>()
            .AddPacketResponder<SuResponder>()
            .AddSingleton<CombatManager>();
    }
}
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/ICombatState.cs => Extensions/NosSmooth.Extensions.Combat/ICombatState.cs +58 -0
@@ 0,0 1,58 @@
//
//  ICombatState.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.Client;
using NosSmooth.Extensions.Combat.Operations;
using NosSmooth.Game.Data.Entities;

namespace NosSmooth.Extensions.Combat;

/// <summary>
/// The combat technique state used for queuing operations and storing information.
/// </summary>
public interface ICombatState
{
    /// <summary>
    /// Gets the combat manager.
    /// </summary>
    public CombatManager CombatManager { get; }

    /// <summary>
    /// Gets the game.
    /// </summary>
    public Game.Game Game { get; }

    /// <summary>
    /// Gets the NosTale client.
    /// </summary>
    public INostaleClient Client { get; }

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

    /// <summary>
    /// Replace the current operation with this one.
    /// </summary>
    /// <param name="operation">The operation to use.</param>
    /// <param name="emptyQueue">Whether to empty the queue of the operations.</param>
    /// <param name="prependCurrentOperationToQueue">Whether to still use the current operation (true) after this one or discard it (false).</param>
    public void SetCurrentOperation
        (ICombatOperation operation, bool emptyQueue = false, bool prependCurrentOperationToQueue = false);

    /// <summary>
    /// Enqueue the operation at the end of the queue.
    /// </summary>
    /// <param name="operation">The operation to enqueue.</param>
    public void EnqueueOperation(ICombatOperation operation);

    /// <summary>
    /// Remove the operations by the given filter.
    /// </summary>
    /// <param name="filter">Called for each operation, should return true if it should be removed.</param>
    public void RemoveOperations(Func<ICombatOperation, bool> filter);
}
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/IsExternalInit.cs => Extensions/NosSmooth.Extensions.Combat/IsExternalInit.cs +15 -0
@@ 0,0 1,15 @@
//
//  IsExternalInit.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 System.Runtime.CompilerServices
{
    /// <summary>
    /// Dummy.
    /// </summary>
    public class IsExternalInit
    {
    }
}
\ No newline at end of file

M Extensions/NosSmooth.Extensions.Combat/NosSmooth.Extensions.Combat.csproj => Extensions/NosSmooth.Extensions.Combat/NosSmooth.Extensions.Combat.csproj +29 -0
@@ 0,0 1,29 @@
<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <LangVersion>10</LangVersion>
        <TargetFramework>net7.0</TargetFramework>
        <Version>0.0.1</Version>
        <Description>Manages NosSmooth combat state, currently exposing a simple technique to kill one enemy at a time.</Description>
        <AssemblyVersion>0.0.1</AssemblyVersion>
        <FileVersion>0.0.1</FileVersion>
        <RepositoryUrl>https://github.com/Rutherther/NosSmooth/</RepositoryUrl>
        <PackageLicenseExpression>MIT</PackageLicenseExpression>
    </PropertyGroup>
    
    <ItemGroup>
        <PackageReference Include="Remora.Results" Version="7.2.3" />
        <PackageReference Include="System.Text.Encoding.CodePages" Version="7.0.0" />
    </ItemGroup>

    <ItemGroup>
      <ProjectReference Include="..\..\Core\NosSmooth.Core\NosSmooth.Core.csproj" />
      <ProjectReference Include="..\..\Core\NosSmooth.Game\NosSmooth.Game.csproj" />
      <ProjectReference Include="..\..\Data\NosSmooth.Data.Abstractions\NosSmooth.Data.Abstractions.csproj" />
      <ProjectReference Include="..\..\Packets\NosSmooth.Packets\NosSmooth.Packets.csproj" />
      <ProjectReference Include="..\NosSmooth.Extensions.Pathfinding\NosSmooth.Extensions.Pathfinding.csproj" />
    </ItemGroup>

</Project>

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

/// <summary>
/// A response to <see cref="ICombatOperation"/> CanBeUsed method.
/// </summary>
public enum CanBeUsedResponse
{
    /// <summary>
    /// The operation may be used right awayt.
    /// </summary>
    CanBeUsed,

    /// <summary>
    /// The operation will be usable after some amount of time.
    /// </summary>
    MustWait,

    /// <summary>
    /// The operation won't be usable. (ie. missing arrows).
    /// </summary>
    WontBeUsable
}
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/Operations/ICombatOperation.cs => Extensions/NosSmooth.Extensions.Combat/Operations/ICombatOperation.cs +38 -0
@@ 0,0 1,38 @@
//
//  ICombatOperation.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.Techniques;
using Remora.Results;

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
{
    /// <summary>
    /// Checks whether the operation can currently be used.
    /// </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.
    /// </remarks>
    /// <param name="combatState">The combat state.</param>
    /// <returns>Whether the operation can be used right away.</returns>
    public Result<CanBeUsedResponse> CanBeUsed(ICombatState combatState);

    /// <summary>
    /// Use the operation, if possible.
    /// </summary>
    /// <remarks>
    /// Should block until the operation is finished.
    /// </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);
}
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/Operations/UseItemOperation.cs => Extensions/NosSmooth.Extensions.Combat/Operations/UseItemOperation.cs +26 -0
@@ 0,0 1,26 @@
//
//  UseItemOperation.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.Game.Data.Items;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Operations;

// TODO: first inventory has to be made
public record UseItemOperation(Item Item) : ICombatOperation
{
    /// <inheritdoc />
    public Result<CanBeUsedResponse> CanBeUsed(ICombatState combatState)
    {
        throw new NotImplementedException();
    }

    /// <inheritdoc />
    public Task<Result> UseAsync(ICombatState combatState, CancellationToken ct = default)
    {
        throw new NotImplementedException();
    }
}
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/Operations/UsePrimarySkillOperation.cs => Extensions/NosSmooth.Extensions.Combat/Operations/UsePrimarySkillOperation.cs +72 -0
@@ 0,0 1,72 @@
//
//  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);
            }

            _useSkillOperation = new UseSkillOperation(primarySkill, 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);
            }

            _useSkillOperation = new UseSkillOperation(primarySkill, Target);
        }

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

    private Result<Skill> GetPrimarySkill(ICombatState combatState)
    {
        var character = combatState.Game.Character;
        if (character is null)
        {
            return new CharacterNotInitializedError();
        }

        var skills = character.Skills;
        if (skills is null)
        {
            return new CharacterNotInitializedError("Skills");
        }

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

A Extensions/NosSmooth.Extensions.Combat/Operations/UseSkillOperation.cs => Extensions/NosSmooth.Extensions.Combat/Operations/UseSkillOperation.cs +87 -0
@@ 0,0 1,87 @@
//
//  UseSkillOperation.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.Xml.XPath;
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>
/// A combat operation to use a skill.
/// </summary>
/// <param name="Skill">The skill to use.</param>
/// <param name="Target">The target entity to use the skill at.</param>
public record UseSkillOperation(Skill Skill, ILivingEntity Target) : ICombatOperation
{
    /// <inheritdoc />
    public Result<CanBeUsedResponse> CanBeUsed(ICombatState combatState)
    {
        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 (character.Mp.Amount < Skill.Info.MpCost)
            { // The character is in combat, mp won't restore.
                return CanBeUsedResponse.WontBeUsable;
            }
        }

        return Skill.IsOnCooldown ? CanBeUsedResponse.MustWait : CanBeUsedResponse.CanBeUsed;
    }

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

        // TODO: support for area skills, support skills that use x, y coordinates (like dashes or teleports)
        using var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(ct);
        await combatState.CombatManager.RegisterSkillCancellationTokenAsync(linkedSource, ct);
        var sendResponse = await combatState.Client.SendPacketAsync
        (
            new UseSkillPacket
            (
                Skill.Info.CastId,
                Target.Type,
                Target.Id,
                null,
                null
            ),
            ct
        );

        if (!sendResponse.IsSuccess)
        {
            await combatState.CombatManager.UnregisterSkillCancellationTokenAsync(linkedSource, ct);
            return sendResponse;
        }

        try
        {
            // wait 10 times the cast delay in case su is not received.
            await Task.Delay(Skill.Info.CastTime * 1000, linkedSource.Token);
        }
        catch (TaskCanceledException)
        {
            // ignored
        }
        await combatState.CombatManager.UnregisterSkillCancellationTokenAsync(linkedSource, ct);
        await Task.Delay(1000, ct);

        return Result.FromSuccess();
    }
}
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/Operations/WalkInRangeOperation.cs => Extensions/NosSmooth.Extensions.Combat/Operations/WalkInRangeOperation.cs +131 -0
@@ 0,0 1,131 @@
//
//  WalkInRangeOperation.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.Extensions.Pathfinding;
using NosSmooth.Extensions.Pathfinding.Errors;
using NosSmooth.Game.Data.Entities;
using NosSmooth.Game.Data.Info;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Operations;

/// <summary>
/// A combat operation that walks into a given range of an entity.
/// </summary>
/// <param name="WalkManager">The walk manager.</param>
/// <param name="Entity">The entity to walk to.</param>
/// <param name="Distance">The maximal distance from the entity.</param>
public record WalkInRangeOperation
(
    WalkManager WalkManager,
    IEntity Entity,
    float Distance
) : ICombatOperation
{
    /// <inheritdoc />
    public Result<CanBeUsedResponse> CanBeUsed(ICombatState combatState)
    {
        var character = combatState.Game.Character;
        if (character is null)
        {
            return new CharacterNotInitializedError();
        }

        return character.CantMove ? CanBeUsedResponse.MustWait : CanBeUsedResponse.CanBeUsed;
    }

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

        var distance = Distance;
        while (distance >= 0)
        {
            var position = Entity.Position;
            if (position is null)
            {
                return new GenericError("Entity's position is not initialized.");
            }

            var currentPosition = character.Position;
            if (currentPosition is null)
            {
                return new CharacterNotInitializedError("Position");
            }

            if (Entity.Position?.DistanceSquared(currentPosition.Value) <= Distance * Distance)
            {
                return Result.FromSuccess();
            }

            var closePosition = GetClosePosition(currentPosition.Value, position.Value, distance);
            if (closePosition == position)
            {
                return Result.FromSuccess();
            }

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

            while (!walkResultTask.IsCompleted)
            {
                await Task.Delay(5, ct);
                if (Entity.Position != position)
                {
                    goToCancellationTokenSource.Cancel();
                    await walkResultTask;
                }
            }

            if (Entity.Position != position)
            {
                continue;
            }

            var walkResult = await walkResultTask;
            if ((character.Position - Entity.Position)?.DistanceSquared(Position.Zero) <= Distance * Distance)
            {
                return Result.FromSuccess();
            }

            if (!walkResult.IsSuccess && walkResult.Error is PathNotFoundError)
            {
                if (distance - 1 > 0)
                {
                    distance--;
                }
                else
                {
                    distance = 0;
                }

                continue;
            }

            return walkResult;
        }

        return Result.FromSuccess();
    }

    private Position GetClosePosition(Position start, Position target, double distance)
    {
        var diff = start - target;
        if (diff.DistanceSquared(Position.Zero) < distance * distance)
        {
            return start;
        }

        var diffLength = Math.Sqrt(diff.DistanceSquared(Position.Zero));
        return target + ((distance / diffLength) * diff);
    }
}
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/Operations/WalkOperation.cs => Extensions/NosSmooth.Extensions.Combat/Operations/WalkOperation.cs +38 -0
@@ 0,0 1,38 @@
//
//  WalkOperation.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.Extensions.Pathfinding;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Operations;

/// <summary>
/// A combat operation that walks to the target.
/// </summary>
/// <param name="WalkManager">The walk manager.</param>
/// <param name="X">The x coordinate to walk to.</param>
/// <param name="Y">The y coordinate to walk to.</param>
public record WalkOperation(WalkManager WalkManager, short X, short Y) : ICombatOperation
{
    /// <inheritdoc />
    public Result<CanBeUsedResponse> CanBeUsed(ICombatState combatState)
    {
        var character = combatState.Game.Character;
        if (character is null)
        {
            return new CharacterNotInitializedError();
        }

        return character.CantMove ? CanBeUsedResponse.MustWait : CanBeUsedResponse.CanBeUsed;
    }

    /// <inheritdoc />
    public async Task<Result> UseAsync(ICombatState combatState, CancellationToken ct = default)
    {
        return await WalkManager.GoToAsync(X, Y, true, ct);
    }
}
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/Policies/EnemyPolicy.cs => Extensions/NosSmooth.Extensions.Combat/Policies/EnemyPolicy.cs +162 -0
@@ 0,0 1,162 @@
//
//  EnemyPolicy.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.Data.SqlTypes;
using NosSmooth.Extensions.Combat.Errors;
using NosSmooth.Extensions.Combat.Selectors;
using NosSmooth.Game.Data.Characters;
using NosSmooth.Game.Data.Entities;
using NosSmooth.Game.Data.Info;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Policies;

/// <summary>
/// An enemy selector policy that selects the monsters based on their vnums.
/// </summary>
/// <param name="SelectPolicy">The policy to select an enemy.</param>
/// <param name="MonsterVNums">The vnums of the monsters to target.</param>
/// <param name="CombatArea">The area in which to get enemies from.</param>
public record EnemyPolicy
(
    EnemySelectPolicy SelectPolicy,
    int[]? MonsterVNums = default,
    CombatArea? CombatArea = default
) : IEnemySelector
{
    /// <inheritdoc />
    public Result<ILivingEntity> GetSelectedEntity(ICombatState combatState, ICollection<ILivingEntity> possibleTargets)
    {
        var targets = possibleTargets.OfType<Monster>();
        if (MonsterVNums is not null)
        {
            targets = targets.Where(x => MonsterVNums.Contains(x.VNum));
        }

        if (!targets.Any())
        {
            return new EntityNotFoundError();
        }

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

        var position = combatState.Game.Character.Position;
        if (position is null)
        {
            return new CharacterNotInitializedError();
        }

        var characterPosition = position.Value;
        ILivingEntity? target = null;
        switch (SelectPolicy)
        {
            case EnemySelectPolicy.Aggressive:
                throw new NotImplementedException(); // TODO: implement aggressive policy
            case EnemySelectPolicy.Closest:
                target = targets
                    .Where(x => x.Position is not null && (CombatArea?.IsInside(x.Position.Value) ?? true))
                    .MinBy(x => x.Position!.Value.DistanceSquared(characterPosition))!;
                break;
            case EnemySelectPolicy.LowestHealth:
                target = targets.MinBy
                (
                    x =>
                    {
                        if (x.Hp is null)
                        {
                            return int.MaxValue;
                        }

                        if (x.Hp.Amount is not null)
                        {
                            return x.Hp.Amount;
                        }

                        if (x.Hp.Percentage is not null && x.Level is not null)
                        {
                            return x.Hp.Percentage * 100 * x.Level;
                        }

                        if (x.Hp.Maximum is not null)
                        {
                            return x.Hp.Maximum; // Assume max health, best guess.
                        }

                        return int.MaxValue;
                    }
                );
                break;
        }

        if (target is null)
        {
            return new EntityNotFoundError();
        }

        return Result<ILivingEntity>.FromSuccess(target);
    }
}

/// <summary>
/// A policy enemy selector.
/// </summary>
public enum EnemySelectPolicy
{
    /// <summary>
    /// Select the enemy with the lowest health.
    /// </summary>
    LowestHealth,

    /// <summary>
    /// Selects the enemy that targets the user.
    /// </summary>
    Aggressive,

    /// <summary>
    /// Selects the enemy that is the closest to the character.
    /// </summary>
    Closest
}

/// <summary>
/// The combat area around which to find enemies.
/// </summary>
/// <param name="CenterX">The area center x coordinate.</param>
/// <param name="CenterY">The area center y coordinate.</param>
/// <param name="Range">The maximum range from the center.</param>
public record CombatArea(short CenterX, short CenterY, short Range)
{
    /// <summary>
    /// Create a combat area around a specified entity.
    /// </summary>
    /// <param name="entity">The entity.</param>
    /// <param name="range">The range.</param>
    /// <returns>The combat area.</returns>
    /// <exception cref="ArgumentException">If the entity does not have a position.</exception>
    public static CombatArea CreateAroundEntity(IEntity entity, short range)
    {
        var position = entity.Position;
        if (position is null)
        {
            throw new ArgumentException(nameof(entity));
        }

        return new CombatArea(position.Value.X, position.Value.Y, range);
    }

    /// <summary>
    /// Gets whether the position is inside of the combat area.
    /// </summary>
    /// <param name="position">The position.</param>
    /// <returns>Whether the position is inside.</returns>
    public bool IsInside(Position position)
    {
        return position.DistanceSquared(new Position(CenterX, CenterY)) < Range * Range;
    }
}
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/Policies/UseItemPolicy.cs => Extensions/NosSmooth.Extensions.Combat/Policies/UseItemPolicy.cs +90 -0
@@ 0,0 1,90 @@
//
//  UseItemPolicy.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.Extensions.Combat.Selectors;
using NosSmooth.Game.Data.Characters;
using NosSmooth.Game.Data.Items;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Policies;

/// <summary>
/// The policy to use an item.
/// </summary>
/// <param name="UseItems">Whether to use items.</param>
/// <param name="UseBelowHealthPercentage">Use items below the given character's health percentage.</param>
/// <param name="UseBelowManaPercentage">Use items below the given character's mana percentage.</param>
/// <param name="UseHealthItemsVNums">The vnums of the items to use as health items.</param>
/// <param name="UseManaItemsVNums">The vnums of the items to use as mana items.</param>
public record UseItemPolicy
(
    bool UseItems,
    int UseBelowHealthPercentage,
    int UseBelowManaPercentage,
    int[] UseHealthItemsVNums,
    int[] UseManaItemsVNums
) : IItemSelector
{
    /// <inheritdoc />
    public Result<Item> GetSelectedItem(ICombatState combatState, ICollection<Item> possibleItems)
    {
        var character = combatState.Game.Character;
        if (character is null)
        {
            return new ItemNotFoundError();
        }

        if (ShouldUseHpItem(character))
        {
            var item = possibleItems.FirstOrDefault(x => UseHealthItemsVNums.Contains(x.ItemVNum));
            if (item is not null)
            {
                return item;
            }
        }

        if (ShouldUseMpItem(character))
        {
            var item = possibleItems.FirstOrDefault(x => UseManaItemsVNums.Contains(x.ItemVNum));
            if (item is not null)
            {
                return item;
            }
        }

        return new ItemNotFoundError();
    }

    /// <inheritdoc />
    public Result<bool> ShouldUseItem(ICombatState combatState)
    {
        if (!UseItems)
        {
            return false;
        }

        var character = combatState.Game.Character;
        if (character is null)
        {
            return false;
        }

        return ShouldUseHpItem(character) || ShouldUseMpItem(character);
    }

    private bool ShouldUseHpItem(Character character)
    {
        return character.Hp is not null && character.Hp.Percentage is not null
            && character.Hp.Percentage < UseBelowHealthPercentage;
    }

    private bool ShouldUseMpItem(Character character)
    {
        return character.Mp is not null && character.Mp.Percentage is not null
            && character.Mp.Percentage < UseBelowManaPercentage;
    }
}
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/Policies/UseSkillPolicy.cs => Extensions/NosSmooth.Extensions.Combat/Policies/UseSkillPolicy.cs +58 -0
@@ 0,0 1,58 @@
//
//  UseSkillPolicy.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.Data.Abstractions.Enums;
using NosSmooth.Extensions.Combat.Errors;
using NosSmooth.Extensions.Combat.Selectors;
using NosSmooth.Game.Data.Characters;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Policies;

/// <summary>
/// The policy to use a skill.
/// </summary>
/// <param name="PreferTargetedSkills">Whether to prefer targeted skills (true) or area skills (false).</param>
/// <param name="AllowedSkillVNums">The vnums of the skills that are allowed to be used.</param>
public record UseSkillPolicy(bool PreferTargetedSkills, int[]? AllowedSkillVNums)
    : ISkillSelector
{
    /// <inheritdoc />
    public Result<Skill> GetSelectedSkill(IEnumerable<Skill> usableSkills)
    {
        var skills = usableSkills.Where(x => CanBeUsed(x))
            .Reverse();

        if (PreferTargetedSkills)
        {
            skills = skills.OrderBy(x => x.Info!.HitType == HitType.EnemiesInZone ? 1 : 0);
        }

        var skill = skills.FirstOrDefault();
        if (skill is null)
        {
            return new SkillNotFoundError();
        }

        return skill;
    }

    private bool CanBeUsed(Skill skill)
    {
        if (AllowedSkillVNums is not null && !AllowedSkillVNums.Contains(skill.SkillVNum))
        {
            return false;
        }

        if (skill.Info is null)
        {
            return false;
        }

        return skill.Info.HitType is HitType.EnemiesInZone or HitType.TargetOnly
            && skill.Info.TargetType is TargetType.Target or TargetType.NoTarget;
    }
}
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/Responders/CancelResponder.cs => Extensions/NosSmooth.Extensions.Combat/Responders/CancelResponder.cs +35 -0
@@ 0,0 1,35 @@
//
//  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)
    {
        _combatManager.CancelSkillTokensAsync(ct);
        return Task.FromResult(Result.FromSuccess());
    }
}
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/Responders/SuResponder.cs => Extensions/NosSmooth.Extensions.Combat/Responders/SuResponder.cs +35 -0
@@ 0,0 1,35 @@
//
//  SuResponder.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.Battle;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Responders;

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

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

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

A Extensions/NosSmooth.Extensions.Combat/Selectors/IEnemySelector.cs => Extensions/NosSmooth.Extensions.Combat/Selectors/IEnemySelector.cs +24 -0
@@ 0,0 1,24 @@
//
//  IEnemySelector.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.Game.Data.Entities;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Selectors;

/// <summary>
/// Selects an enemy from the possible enemies.
/// </summary>
public interface IEnemySelector
{
    /// <summary>
    /// Gets the entity to be currently selected.
    /// </summary>
    /// <param name="combatState">The combat state.</param>
    /// <param name="possibleTargets">The collection of possible targets.</param>
    /// <returns>The selected entity, or an error.</returns>
    public Result<ILivingEntity> GetSelectedEntity(ICombatState combatState, ICollection<ILivingEntity> possibleTargets);
}
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/Selectors/IItemSelector.cs => Extensions/NosSmooth.Extensions.Combat/Selectors/IItemSelector.cs +31 -0
@@ 0,0 1,31 @@
//
//  IItemSelector.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.Game.Data.Items;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Selectors;

/// <summary>
/// Selects an item to be used.
/// </summary>
public interface IItemSelector
{
    /// <summary>
    /// Gets the entity to be currently selected.
    /// </summary>
    /// <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);

    /// <summary>
    /// Gets whether currently an item should be used.
    /// </summary>
    /// <param name="combatState">The combat state.</param>
    /// <returns>Whether to use an item or an error.</returns>
    public Result<bool> ShouldUseItem(ICombatState combatState);
}
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/Selectors/ISkillSelector.cs => Extensions/NosSmooth.Extensions.Combat/Selectors/ISkillSelector.cs +23 -0
@@ 0,0 1,23 @@
//
//  ISkillSelector.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.Game.Data.Characters;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Selectors;

/// <summary>
/// Selects a skill to use from a possible skills.
/// </summary>
public interface ISkillSelector
{
    /// <summary>
    /// Gets the skill to use.
    /// </summary>
    /// <param name="usableSkills">The skills that may be used. Won't contain skills the user doesn't have mana for and that are on cooldown.</param>
    /// <returns>The skill to use, or an error.</returns>
    public Result<Skill> GetSelectedSkill(IEnumerable<Skill> usableSkills);
}
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/Techniques/ICombatTechnique.cs => Extensions/NosSmooth.Extensions.Combat/Techniques/ICombatTechnique.cs +48 -0
@@ 0,0 1,48 @@
//
//  ICombatTechnique.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.Operations;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Techniques;

/// <summary>
/// A combat technique that allows to handle the whole combat situations using step callbacks.
/// </summary>
/// <remarks>
/// The callback methods decide the next steps, used in <see cref="CombatManager"/>.
/// </remarks>
public interface ICombatTechnique
{
    /// <summary>
    /// Should check whether the technique should process more steps or quit the combat.
    /// </summary>
    /// <param name="state">The combat state.</param>
    /// <returns>Whether to continue with steps.</returns>
    public bool ShouldContinue(ICombatState state);

    /// <summary>
    /// Handle one step that should enqueue an operation.
    /// </summary>
    /// <remarks>
    /// If error is returned, the combat will be cancelled.
    /// </remarks>
    /// <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);

    /// <summary>
    /// Handles an error from <see cref="ICombatOperation.UseAsync"/>.
    /// </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);
}
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Combat/Techniques/SimpleAttackTechnique.cs => Extensions/NosSmooth.Extensions.Combat/Techniques/SimpleAttackTechnique.cs +149 -0
@@ 0,0 1,149 @@
//
//  SimpleAttackTechnique.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.Extensions.Combat.Extensions;
using NosSmooth.Extensions.Combat.Operations;
using NosSmooth.Extensions.Combat.Selectors;
using NosSmooth.Extensions.Pathfinding;
using NosSmooth.Game.Data.Characters;
using NosSmooth.Game.Data.Entities;
using Remora.Results;

namespace NosSmooth.Extensions.Combat.Techniques;

/// <summary>
/// A combat technique that will attack on the specified enemy, walk within range and use skill until the enemy is dead.
/// </summary>
public class SimpleAttackTechnique : ICombatTechnique
{
    private readonly long _targetId;
    private readonly WalkManager _walkManager;
    private readonly ISkillSelector _skillSelector;

    private Skill? _currentSkill;
    private ILivingEntity? _target;

    /// <summary>
    /// Initializes a new instance of the <see cref="SimpleAttackTechnique"/> class.
    /// </summary>
    /// <param name="targetId">The target entity id.</param>
    /// <param name="walkManager">The walk manager.</param>
    /// <param name="skillSelector">The skill selector.</param>
    public SimpleAttackTechnique
    (
        long targetId,
        WalkManager walkManager,
        ISkillSelector skillSelector
    )
    {
        _targetId = targetId;
        _walkManager = walkManager;
        _skillSelector = skillSelector;
    }

    /// <inheritdoc />
    public bool ShouldContinue(ICombatState state)
    {
        var map = state.Game.CurrentMap;
        if (map is null)
        {
            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)));
    }

    /// <inheritdoc />
    public Result<long?> HandleCombatStep(ICombatState state)
    {
        var map = state.Game.CurrentMap;
        if (map is null)
        {
            return new MapNotInitializedError();
        }

        if (_target is null)
        {
            var entity = map.Entities.GetEntity<ILivingEntity>(_targetId);
            if (entity is null)
            {
                return new EntityNotFoundError();
            }

            _target = entity;
        }

        var character = state.Game.Character;
        if (character is null)
        {
            return new CharacterNotInitializedError();
        }

        if (_currentSkill is null)
        {
            var skills = character.Skills;
            if (skills is null)
            {
                return new CharacterNotInitializedError("Skills");
            }

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

            var skillResult = _skillSelector.GetSelectedSkill(usableSkills);
            if (!skillResult.IsSuccess)
            {
                if (skillResult.Error is SkillNotFoundError)
                {
                    return _target.Id;
                }

                return Result<long?>.FromError(skillResult);
            }

            _currentSkill = skillResult.Entity;
        }

        if (_currentSkill.Info is null)
        {
            var currentSkill = _currentSkill;
            _currentSkill = null;
            return new MissingInfoError("skill", currentSkill.SkillVNum);
        }

        if (character.Position is null)
        {
            return new CharacterNotInitializedError("Position");
        }

        if (_target.Position is null)
        {
            return new EntityNotFoundError();
        }

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

        return _target.Id;
    }

    /// <inheritdoc />
    public Result HandleError(ICombatState state, ICombatOperation operation, Result result)
    {
        return result;
    }
}
\ No newline at end of file

A Extensions/NosSmooth.Extensions.Pathfinding/Errors/PathNotFoundError.cs => Extensions/NosSmooth.Extensions.Pathfinding/Errors/PathNotFoundError.cs +16 -0
@@ 0,0 1,16 @@
//
//  PathNotFoundError.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.Extensions.Pathfinding.Errors;

/// <summary>
/// Could not find path to the given target.
/// </summary>
/// <param name="TargetX">The target x coordinate.</param>
/// <param name="TargetY">The target y coordinate.</param>
public record PathNotFoundError(short TargetX, short TargetY) : ResultError($"Path to {TargetX}, {TargetY} not found.");
\ No newline at end of file

M Extensions/NosSmooth.Extensions.Pathfinding/NosSmooth.Extensions.Pathfinding.csproj => Extensions/NosSmooth.Extensions.Pathfinding/NosSmooth.Extensions.Pathfinding.csproj +4 -0
@@ 8,6 8,10 @@
        <RepositoryUrl>https://github.com/Rutherther/NosSmooth/</RepositoryUrl>
        <PackageLicenseExpression>MIT</PackageLicenseExpression>
        <Description>NosSmooth extension allowing for finding paths on maps.</Description>
        <Version>1.1.0</Version>
        <PackageReleaseNotes>Fix bugs to stop returning non-existing errors.</PackageReleaseNotes>
        <AssemblyVersion>1.1.0</AssemblyVersion>
        <FileVersion>1.1.0</FileVersion>
    </PropertyGroup>

    <ItemGroup>

M Extensions/NosSmooth.Extensions.Pathfinding/Pathfinder.cs => Extensions/NosSmooth.Extensions.Pathfinding/Pathfinder.cs +2 -2
@@ 92,7 92,7 @@ public class Pathfinder
    {
        if (!mapInfo.IsWalkable(targetX, targetY))
        {
            return new NotFoundError("The requested target is not walkable, path cannot be found.");
            return new PathNotFoundError(targetX, targetY);
        }

        if (x == targetX && y == targetY)


@@ 159,7 159,7 @@ public class Pathfinder
            }
        }

        return new NotFoundError("Could not find path to the given position.");
        return new PathNotFoundError(targetX, targetY);
    }

    private Path ReconstructPath

M NosSmooth.sln => NosSmooth.sln +15 -0
@@ 48,6 48,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileClient", "Samples\FileC
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{3A6D13E3-4BBA-4B3D-AE99-BAC8B375F7DF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NosSmooth.Extensions.Combat", "Extensions\NosSmooth.Extensions.Combat\NosSmooth.Extensions.Combat.csproj", "{21F7EA0B-5E3C-4016-8ADD-28AF37C00782}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NosSmooth.Extensions.Pathfinding", "Extensions\NosSmooth.Extensions.Pathfinding\NosSmooth.Extensions.Pathfinding.csproj", "{564CAD6F-09B1-450B-83ED-9BCDE106B646}"
EndProject
Global


@@ 228,6 230,18 @@ Global
		{D33E1AC5-8946-4D6F-A6D3-D81F98E4F86B}.Release|x64.Build.0 = Release|Any CPU
		{D33E1AC5-8946-4D6F-A6D3-D81F98E4F86B}.Release|x86.ActiveCfg = Release|Any CPU
		{D33E1AC5-8946-4D6F-A6D3-D81F98E4F86B}.Release|x86.Build.0 = Release|Any CPU
		{21F7EA0B-5E3C-4016-8ADD-28AF37C00782}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
		{21F7EA0B-5E3C-4016-8ADD-28AF37C00782}.Debug|Any CPU.Build.0 = Debug|Any CPU
		{21F7EA0B-5E3C-4016-8ADD-28AF37C00782}.Debug|x64.ActiveCfg = Debug|Any CPU
		{21F7EA0B-5E3C-4016-8ADD-28AF37C00782}.Debug|x64.Build.0 = Debug|Any CPU
		{21F7EA0B-5E3C-4016-8ADD-28AF37C00782}.Debug|x86.ActiveCfg = Debug|Any CPU
		{21F7EA0B-5E3C-4016-8ADD-28AF37C00782}.Debug|x86.Build.0 = Debug|Any CPU
		{21F7EA0B-5E3C-4016-8ADD-28AF37C00782}.Release|Any CPU.ActiveCfg = Release|Any CPU
		{21F7EA0B-5E3C-4016-8ADD-28AF37C00782}.Release|Any CPU.Build.0 = Release|Any CPU
		{21F7EA0B-5E3C-4016-8ADD-28AF37C00782}.Release|x64.ActiveCfg = Release|Any CPU
		{21F7EA0B-5E3C-4016-8ADD-28AF37C00782}.Release|x64.Build.0 = Release|Any CPU
		{21F7EA0B-5E3C-4016-8ADD-28AF37C00782}.Release|x86.ActiveCfg = Release|Any CPU
		{21F7EA0B-5E3C-4016-8ADD-28AF37C00782}.Release|x86.Build.0 = Release|Any CPU
		{564CAD6F-09B1-450B-83ED-9BCDE106B646}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
		{564CAD6F-09B1-450B-83ED-9BCDE106B646}.Debug|Any CPU.Build.0 = Debug|Any CPU
		{564CAD6F-09B1-450B-83ED-9BCDE106B646}.Debug|x64.ActiveCfg = Debug|Any CPU


@@ 259,6 273,7 @@ Global
		{7C9C7375-6FC0-4704-9332-1F74CDF41D11} = {01B5E872-271F-4D30-A1AA-AD48D81840C5}
		{055C66A7-640C-49BB-81A7-28E630F51C37} = {99E72557-BCE9-496A-B49C-79537B0E6063}
		{D33E1AC5-8946-4D6F-A6D3-D81F98E4F86B} = {99E72557-BCE9-496A-B49C-79537B0E6063}
		{21F7EA0B-5E3C-4016-8ADD-28AF37C00782} = {3A6D13E3-4BBA-4B3D-AE99-BAC8B375F7DF}
		{564CAD6F-09B1-450B-83ED-9BCDE106B646} = {3A6D13E3-4BBA-4B3D-AE99-BAC8B375F7DF}
	EndGlobalSection
	GlobalSection(ExtensibilityGlobals) = postSolution

M Packets/NosSmooth.PacketSerializer/Converters/Packets/UpgradeRareSubPacketConverter.cs => Packets/NosSmooth.PacketSerializer/Converters/Packets/UpgradeRareSubPacketConverter.cs +5 -0
@@ 44,6 44,11 @@ public class UpgradeRareSubPacketConverter : BaseStringConverter<UpgradeRareSubP
            return new CouldNotConvertError(this, token.ToString(), "The string is not two/three characters long.");
        }

        if (token == "0")
        {
            return Result<UpgradeRareSubPacket?>.FromSuccess(new UpgradeRareSubPacket(0, 0));
        }

        var upgradeString = token.Slice(0, token.Length - 1);
        var rareString = token.Slice(token.Length - 1);


M Packets/NosSmooth.PacketSerializer/NosSmooth.PacketSerializer.csproj => Packets/NosSmooth.PacketSerializer/NosSmooth.PacketSerializer.csproj +3 -1
@@ 9,7 9,9 @@
        <RepositoryUrl>https://github.com/Rutherther/NosSmooth/</RepositoryUrl>
        <PackageLicenseExpression>MIT</PackageLicenseExpression>
        <PackageVersion>1.0.2</PackageVersion>
        <PackageReleaseNotes>Seek through the enumerator when null value type encountered.</PackageReleaseNotes>
        <PackageReleaseNotes>Fix UpgradeRareSubPacket to work with "0".</PackageReleaseNotes>
        <AssemblyVersion>1.0.3</AssemblyVersion>
        <FileVersion>1.0.3</FileVersion>
    </PropertyGroup>

    <ItemGroup>

M Packets/NosSmooth.Packets/NosSmooth.Packets.csproj => Packets/NosSmooth.Packets/NosSmooth.Packets.csproj +2 -2
@@ 7,9 7,9 @@
        <Description>Contains default NosTale packets.</Description>
        <RepositoryUrl>https://github.com/Rutherther/NosSmooth/</RepositoryUrl>
        <PackageLicenseExpression>MIT</PackageLicenseExpression>
        <PackageVersion>2.2.1</PackageVersion>
        <PackageVersion>2.3.1</PackageVersion>
        <TargetFrameworks>net7.0;netstandard2.1</TargetFrameworks>
        <PackageReleaseNotes>Fix cinfo, eq, cancel packets.</PackageReleaseNotes>
        <PackageReleaseNotes>Update packets to 2023 versions</PackageReleaseNotes>
    </PropertyGroup>

    <ItemGroup>

M Packets/NosSmooth.Packets/Server/Skills/SkiPacket.cs => Packets/NosSmooth.Packets/Server/Skills/SkiPacket.cs +4 -2
@@ 19,9 19,11 @@ namespace NosSmooth.Packets.Server.Skills;
public record SkiPacket
(
    [PacketIndex(0)]
    int PrimarySkillVNum,
    int? Unknown,
    [PacketIndex(1)]
    int PrimarySkillVNum,
    [PacketIndex(2)]
    int SecondarySkillVNum,
    [PacketListIndex(2, InnerSeparator = '|', ListSeparator = ' ')]
    [PacketListIndex(3, InnerSeparator = '|', ListSeparator = ' ')]
    IReadOnlyList<SkiSubPacket> SkillSubPackets
) : IPacket;
\ No newline at end of file

M Packets/NosSmooth.Packets/Server/Skills/SkiSubPacket.cs => Packets/NosSmooth.Packets/Server/Skills/SkiSubPacket.cs +2 -2
@@ 19,6 19,6 @@ public record SkiSubPacket
(
    [PacketIndex(0)]
    int SkillVNum,
    [PacketIndex(1)]
    byte Rank
    [PacketIndex(1, IsOptional = true)]
    byte? Rank
) : IPacket;
\ No newline at end of file

A Tests/NosSmooth.Packets.Tests/Converters/Packets/SkiPacketConverterTests.cs => Tests/NosSmooth.Packets.Tests/Converters/Packets/SkiPacketConverterTests.cs +67 -0
@@ 0,0 1,67 @@
//
//  SkiPacketConverterTests.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 Microsoft.Extensions.DependencyInjection;
using NosSmooth.Packets.Enums;
using NosSmooth.Packets.Extensions;
using NosSmooth.Packets.Packets;
using NosSmooth.Packets.Server.Groups;
using NosSmooth.Packets.Server.Skills;
using NosSmooth.PacketSerializer.Abstractions.Attributes;
using Xunit;

namespace NosSmooth.Packets.Tests.Converters.Packets;

/// <summary>
/// Tests ski packet serializer.
/// </summary>
public class SkiPacketConverterTests
{
    private readonly IPacketSerializer _packetSerializer;

    /// <summary>
    /// Initializes a new instance of the <see cref="SkiPacketConverterTests"/> class.
    /// </summary>
    public SkiPacketConverterTests()
    {
        var provider = new ServiceCollection()
            .AddPacketSerialization()
            .BuildServiceProvider();

        _packetSerializer = provider.GetRequiredService<IPacketSerializer>();
        provider.GetRequiredService<IPacketTypesRepository>().AddDefaultPackets();
    }

    /// <summary>
    /// Tests that ski packet is deserialized correctly.
    /// </summary>
    [Fact]
    public void Converter_Deserialization_DeserializesCorrectly()
    {
        var deserialized = _packetSerializer.Deserialize
        (
            "ski 0 220 221 220 221 697|4 706|0 310 311",
            PacketSource.Server
        );

        Assert.True(deserialized.IsSuccess);
        var expected = new SkiPacket
        (
            0,
            220,
            221,
            new[]
            {
                new SkiSubPacket(220, null), new SkiSubPacket(221, null), new SkiSubPacket(697, 4),
                new SkiSubPacket(706, 0), new SkiSubPacket(310, null), new SkiSubPacket(311, null)
            }
        );
        var skiPacket = (SkiPacket)deserialized.Entity;
        Assert.Equal(expected.PrimarySkillVNum, skiPacket.PrimarySkillVNum);
        Assert.Equal(expected.SecondarySkillVNum, skiPacket.SecondarySkillVNum);
        Assert.Equal(expected.SkillSubPackets, skiPacket.SkillSubPackets);
    }
}
\ No newline at end of file

Do not follow this link