commit 3845f916012d0a11eaca3c20cb6156355bf145a9 Author: Deukhoofd Date: Sat Jul 20 13:51:52 2024 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8bd8bd1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ +.idea +*.DotSettings.user \ No newline at end of file diff --git a/PkmnLib.NET.sln b/PkmnLib.NET.sln new file mode 100644 index 0000000..96c5e55 --- /dev/null +++ b/PkmnLib.NET.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PkmnLib.Static", "PkmnLib.Static\PkmnLib.Static.csproj", "{312782DA-1066-4490-BD0E-DF4DF8713B4A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PkmnLib.Tests", "PkmnLib.Tests\PkmnLib.Tests.csproj", "{42DE3095-0468-4827-AF5C-691C94BA7F92}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {312782DA-1066-4490-BD0E-DF4DF8713B4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {312782DA-1066-4490-BD0E-DF4DF8713B4A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {312782DA-1066-4490-BD0E-DF4DF8713B4A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {312782DA-1066-4490-BD0E-DF4DF8713B4A}.Release|Any CPU.Build.0 = Release|Any CPU + {42DE3095-0468-4827-AF5C-691C94BA7F92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {42DE3095-0468-4827-AF5C-691C94BA7F92}.Debug|Any CPU.Build.0 = Debug|Any CPU + {42DE3095-0468-4827-AF5C-691C94BA7F92}.Release|Any CPU.ActiveCfg = Release|Any CPU + {42DE3095-0468-4827-AF5C-691C94BA7F92}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/PkmnLib.Static/GlobalUsings.cs b/PkmnLib.Static/GlobalUsings.cs new file mode 100644 index 0000000..0926736 --- /dev/null +++ b/PkmnLib.Static/GlobalUsings.cs @@ -0,0 +1 @@ +global using LevelInt = byte; \ No newline at end of file diff --git a/PkmnLib.Static/GrowthRate.cs b/PkmnLib.Static/GrowthRate.cs new file mode 100644 index 0000000..4050b7d --- /dev/null +++ b/PkmnLib.Static/GrowthRate.cs @@ -0,0 +1,81 @@ +using PkmnLib.Static.Utils; + +namespace PkmnLib.Static; + +/// +/// A growth rate defines how much experience is required per level. +/// +public interface IGrowthRate +{ + /// + /// The name of the growth rate. + /// + public StringKey Name { get; } + + /// + /// Calculate the level something with this growth rate would have at a certain experience. + /// + /// The experience to calculate the level for. + /// The level at the given experience. + LevelInt CalculateLevel(uint experience); + + /// + /// Calculate the experience something with this growth rate would have at a certain level. + /// + /// The level to calculate the experience for. + /// The starting experience at the given level. + uint CalculateExperience(LevelInt level); +} + +/// +/// An implementation of the growth rate that uses a lookup table for experience. +/// +public class LookupGrowthRate : IGrowthRate +{ + /// + public StringKey Name { get; } + + private readonly uint[] _experienceTable; + + /// + public LookupGrowthRate(StringKey name, IEnumerable experienceTable) + { + Name = name; + _experienceTable = experienceTable.ToArray(); + if (_experienceTable.Length < 1) + { + throw new ArgumentException("Experience table must have at least one entry."); + } + + if (_experienceTable[0] != 0) + { + throw new ArgumentException("Experience table must start at 0."); + } + + if (_experienceTable.Length > LevelInt.MaxValue) + { + throw new ArgumentException($"Experience table may have at most {LevelInt.MaxValue} entries."); + } + } + + /// + public LevelInt CalculateLevel(uint experience) + { + for (LevelInt level = 0; level < _experienceTable.Length; level++) + { + if (_experienceTable[level] > experience) + { + return level; + } + } + + return (LevelInt)(_experienceTable.Length); + } + + /// + public uint CalculateExperience(LevelInt level) + { + if (level < 1) level = 1; + return level >= _experienceTable.Length ? _experienceTable[^1] : _experienceTable[level - 1]; + } +} \ No newline at end of file diff --git a/PkmnLib.Static/Item.cs b/PkmnLib.Static/Item.cs new file mode 100644 index 0000000..1e3548c --- /dev/null +++ b/PkmnLib.Static/Item.cs @@ -0,0 +1,155 @@ +using System.Collections.Immutable; +using PkmnLib.Static.Utils; + +namespace PkmnLib.Static; + +/// +/// An item category defines which bag slot items are stored in. +/// +public enum ItemCategory +{ + /// + /// This is where most items should go. + /// + MiscItem, + + /// + /// Pokeballs are used for capturing Pokemons. + /// + Pokeball, + + /// + /// Medicine is used for healing HP, PP, and status effects. + /// + Medicine, + + /// + /// Berry is used for all berries. + /// + Berry, + + /// + /// TMHM is used for Technical and Hidden Machines. + /// + TmHm, + + /// + /// Form Changer is used for items that change forms, such as mega stones. + /// + FormChanger, + + /// + /// Key Items are single stored items, generally used for story progression. + /// + KeyItem, + + /// + /// Mail is used for mail items. + /// + Mail, +} + +/// +/// A battle item category defines how the item is categorized when in battle. +/// +public enum BattleItemCategory +{ + /// + /// This item can't be used in battle. + /// + None, + + /// + /// This item is used for healing Pokemon. + /// + Healing, + + /// + /// This item is used for healing Pokemon from a status. + /// + StatusHealing, + + /// + /// This item is used for capturing Pokemon. + /// + Pokeball, + + /// + /// This item does not belong in above categories, but is still a battle item. + /// + MiscBattleItem, +} + +/// +/// An item is an object which the player can pick up, keep in their Bag, and use in some manner. +/// +public interface IItem +{ + /// + /// The name of the item. + /// + StringKey Name { get; } + + /// + /// Which bag slot items are stored in. + /// + ItemCategory Category { get; } + + /// + /// How the item is categorized when in battle. + /// + BattleItemCategory BattleCategory { get; } + + /// + /// The buying value of the item. + /// + int Price { get; } + + /// + /// A set of arbitrary flags that can be set on the item. + /// + ImmutableHashSet Flags { get; } + + /// + /// Checks whether the item has a specific flag. + /// + /// The flag to check for. + /// True if the item has the flag, false otherwise. + bool HasFlag(string key); +} + +/// +public class ItemImpl : IItem +{ + /// + public ItemImpl(StringKey name, ItemCategory category, BattleItemCategory battleCategory, int price, + IEnumerable flags) + { + Name = name; + Category = category; + BattleCategory = battleCategory; + Price = price; + Flags = [..flags]; + } + + /// + public StringKey Name { get; } + + /// + public ItemCategory Category { get; } + + /// + public BattleItemCategory BattleCategory { get; } + + /// + public int Price { get; } + + /// + public ImmutableHashSet Flags { get; } + + /// + public bool HasFlag(string key) + { + return Flags.Contains(key); + } +} \ No newline at end of file diff --git a/PkmnLib.Static/Moves/MoveData.cs b/PkmnLib.Static/Moves/MoveData.cs new file mode 100644 index 0000000..b3d67aa --- /dev/null +++ b/PkmnLib.Static/Moves/MoveData.cs @@ -0,0 +1,204 @@ +using System.Collections.Immutable; +using PkmnLib.Static.Utils; + +namespace PkmnLib.Static.Moves; + +/// +/// The move category defines what global kind of move this move is. +/// +public enum MoveCategory +{ + /// + /// A physical move uses the physical attack stats and physical defense stats to calculate damage. + /// + Physical = 0, + + /// + /// A special move uses the special attack stats and special defense stats to calculate damage. + /// + Special = 1, + + /// + /// A status move does not do damage, and only runs a secondary effect. + /// + Status = 2, +} + +/// +/// The move target defines what kind of targets the move can touch. +/// +public enum MoveTarget +{ + /// + /// Adjacent allows a move to target any Pokemon that is either directly to the left or right of + /// the user, opposed to the user, or left or right of the slot that is opposing the user. + /// + Adjacent = 0, + + /// + /// AdjacentAlly allows a move to target any Pokemon that is directly to the left or right of + /// the user. + /// + AdjacentAlly, + + /// + /// AdjacentAllySelf allows a move to target any Pokemon that is either directly to the left or + /// right of the user, or the user itself. + /// + AdjacentAllySelf, + + /// + /// AdjacentOpponent allows a move to target any Pokemon that is either the opponent, or directly + /// to the left or right of it. + /// + AdjacentOpponent, + + /// + /// All makes the move target everything on the field. + /// + All, + + /// + /// AllAdjacent makes the move target everything adjacent on the field. + /// + AllAdjacent, + + /// + /// AllAdjacentOpponent makes the move target everything adjacent to the opponent, and the opponent. + /// + AllAdjacentOpponent, + + /// + /// AllAlly targets all Pokemon on the same side as the user. + /// + AllAlly, + + /// + /// AllOpponent targets all Pokemon on an opposing side from the user. + /// + AllOpponent, + + /// + /// Any allows a move to target a single Pokemon, in any position. + /// + Any, + + /// + /// RandomOpponent allows a move to target a single Pokemon, in a random position. + /// + RandomOpponent, + + /// + /// SelfUse makes the move target the user itself. + /// + SelfUse, +} + +/// +/// A move is the skill Pokémon primarily use in battle. This is the data related to that. +/// +public interface IMoveData +{ + /// + /// The name of the move. + /// + string Name { get; } + + /// + /// The attacking type of the move. + /// + TypeIdentifier MoveType { get; } + + /// + /// The category of the move. + /// + MoveCategory Category { get; } + + /// + /// The base power, not considering any modifiers, the move has. + /// + byte BasePower { get; } + + /// + /// The accuracy of the move in percentage. Should be 255 for moves that always hit. + /// + byte Accuracy { get; } + + /// + /// The number of times the move can be used. This can be modified on actually learned moves using PP-Ups + /// + byte BaseUsages { get; } + + /// + /// How the move handles targets. + /// + MoveTarget Target { get; } + + /// + /// The priority of the move. A higher priority means the move should go before other moves. + /// + sbyte Priority { get; } + + /// + /// The optional secondary effect the move has. + /// + ISecondaryEffect? SecondaryEffect { get; } + + /// + /// Arbitrary flags that can be applied to the move. + /// + bool HasFlag(string key); +} + +/// +public class MoveDataImpl : IMoveData +{ + /// + public MoveDataImpl(string name, TypeIdentifier moveType, MoveCategory category, byte basePower, byte accuracy, + byte baseUsages, MoveTarget target, sbyte priority, ISecondaryEffect? secondaryEffect, + IEnumerable flags) + { + Name = name; + MoveType = moveType; + Category = category; + BasePower = basePower; + Accuracy = accuracy; + BaseUsages = baseUsages; + Target = target; + Priority = priority; + SecondaryEffect = secondaryEffect; + _flags = [..flags]; + } + + /// + public string Name { get; } + + /// + public TypeIdentifier MoveType { get; } + + /// + public MoveCategory Category { get; } + + /// + public byte BasePower { get; } + + /// + public byte Accuracy { get; } + + /// + public byte BaseUsages { get; } + + /// + public MoveTarget Target { get; } + + /// + public sbyte Priority { get; } + + /// + public ISecondaryEffect? SecondaryEffect { get; } + + private readonly ImmutableHashSet _flags; + + /// + public bool HasFlag(string key) => _flags.Contains(key); +} \ No newline at end of file diff --git a/PkmnLib.Static/Moves/SecondaryEffect.cs b/PkmnLib.Static/Moves/SecondaryEffect.cs new file mode 100644 index 0000000..c030cc8 --- /dev/null +++ b/PkmnLib.Static/Moves/SecondaryEffect.cs @@ -0,0 +1,45 @@ +using PkmnLib.Static.Utils; + +namespace PkmnLib.Static.Moves; + +/// +/// A secondary effect is an effect on a move that happens after it hits. +/// +public interface ISecondaryEffect +{ + /// + /// The chance in percentages that the effect triggers. When less than 0, the effect is always active. + /// + public float Chance { get; } + + /// + /// The name of the effect. + /// + public StringKey Name { get; } + + /// + /// Parameters for the effect. + /// + public IReadOnlyDictionary Parameters { get; } +} + +/// +public class SecondaryEffectImpl : ISecondaryEffect +{ + /// + public SecondaryEffectImpl(float chance, StringKey name, IReadOnlyDictionary parameters) + { + Chance = chance; + Name = name; + Parameters = parameters; + } + + /// + public float Chance { get; } + + /// + public StringKey Name { get; } + + /// + public IReadOnlyDictionary Parameters { get; } +} \ No newline at end of file diff --git a/PkmnLib.Static/Nature.cs b/PkmnLib.Static/Nature.cs new file mode 100644 index 0000000..c69b6b8 --- /dev/null +++ b/PkmnLib.Static/Nature.cs @@ -0,0 +1,91 @@ +using System; + +namespace PkmnLib.Static; + +/// +/// A nature is an attribute on a Pokemon that modifies the effective base stats on a Pokemon. They +/// can have an increased statistic and a decreased statistic, or be neutral. +/// +public interface INature +{ + /// + /// The name of the nature. + /// + string Name { get; } + + /// + /// The stat that should receive the increased modifier. + /// + Statistic IncreasedStat { get; } + + /// + /// The stat that should receive the decreased modifier. + /// + Statistic DecreasedStat { get; } + + /// + /// The amount that the increased stat gets modified by. + /// + float IncreasedModifier { get; } + + /// + /// The amount that the decreased stat gets modified by. + /// + float DecreasedModifier { get; } + + /// + /// Calculates the modifier for a given stat. If it's the increased stat, returns the increased + /// modifier, if it's the decreased stat, returns the decreased modifier. Otherwise returns 1.0. + /// + /// The stat to calculate the modifier for. + /// The calculated modifier. + float GetStatModifier(Statistic stat); + + /// + /// Checks if two natures are equal. + /// + /// The other nature to compare to. + /// True if the natures are equal, false otherwise. + bool Equals(INature other); +} + +/// +public class Nature( + string name, + Statistic increaseStat, + Statistic decreaseStat, + float increaseModifier, + float decreaseModifier) + : INature +{ + /// + public string Name { get; } = name; + + /// + public Statistic IncreasedStat { get; } = increaseStat; + + /// + public Statistic DecreasedStat { get; } = decreaseStat; + + /// + public float IncreasedModifier { get; } = increaseModifier; + + /// + public float DecreasedModifier { get; } = decreaseModifier; + + /// + public float GetStatModifier(Statistic stat) + { + if (stat == IncreasedStat && stat != DecreasedStat) + return IncreasedModifier; + if (stat == DecreasedStat && stat != IncreasedStat) + return DecreasedModifier; + return 1.0f; + } + + /// + public bool Equals(INature? other) + { + return other is not null && StringComparer.InvariantCultureIgnoreCase.Equals(Name, other.Name); + } +} \ No newline at end of file diff --git a/PkmnLib.Static/PkmnLib.Static.csproj b/PkmnLib.Static/PkmnLib.Static.csproj new file mode 100644 index 0000000..30993ec --- /dev/null +++ b/PkmnLib.Static/PkmnLib.Static.csproj @@ -0,0 +1,32 @@ + + + + netstandard2.1 + 12 + enable + nullable + enable + + + + bin\Debug\netstandard2.1\PkmnLib.Static.xml + + + + bin\Release\netstandard2.1\PkmnLib.Static.xml + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/PkmnLib.Static/Species/Ability.cs b/PkmnLib.Static/Species/Ability.cs new file mode 100644 index 0000000..b85dcf2 --- /dev/null +++ b/PkmnLib.Static/Species/Ability.cs @@ -0,0 +1,63 @@ +using PkmnLib.Static.Utils; + +namespace PkmnLib.Static.Species; + +/// +/// An ability is a passive effect in battle that is attached to a Pokemon. +/// +public interface IAbility +{ + /// + /// The name of the ability. + /// + StringKey Name { get; } + + /// + /// The name of the script effect of the ability. This should refer to the name of the script that will be executed + /// when the ability is triggered. + /// + StringKey Effect { get; } + + /// + /// The parameters for the script effect of the ability. + /// + IReadOnlyDictionary Parameters { get; } +} + +/// +public class AbilityImpl : IAbility +{ + /// + public AbilityImpl(StringKey name, StringKey effect, IReadOnlyDictionary parameters) + { + Name = name; + Effect = effect; + Parameters = parameters; + } + + /// + public StringKey Name { get; } + + /// + public StringKey Effect { get; } + + /// + public IReadOnlyDictionary Parameters { get; } +} + +/// +/// An ability index allows us to find an ability on a form. It combines a bool for whether the +/// ability is hidden or not, and then an index of the ability. +/// +public readonly record struct AbilityIndex +{ + /// + /// Whether the ability we're referring to is a hidden ability. + /// + public required bool IsHidden { get; init; } + + /// + /// The index of the ability. + /// + public required byte Index { get; init; } +} \ No newline at end of file diff --git a/PkmnLib.Static/Species/Evolution.cs b/PkmnLib.Static/Species/Evolution.cs new file mode 100644 index 0000000..87ca939 --- /dev/null +++ b/PkmnLib.Static/Species/Evolution.cs @@ -0,0 +1,234 @@ +using PkmnLib.Static.Utils; + +namespace PkmnLib.Static.Species; + +/// +/// Data about how and into which Pokemon a species can evolve. +/// +public interface IEvolution +{ + /// + /// The species that the Pokemon evolves into. + /// + StringKey ToSpecies { get; } +} + +/// +/// Evolves when a certain level is reached. +/// +public record LevelEvolution : IEvolution +{ + /// + /// The level at which the Pokemon evolves. + /// + public required uint Level { get; init; } + + /// + public required StringKey ToSpecies { get; init; } +} + +/// +/// Evolves when a certain level is reached, and the Pokemon is a specific gender +/// +public record LevelGenderEvolution : IEvolution +{ + /// + /// The level at which the Pokemon evolves. + /// + public required uint Level { get; init; } + + /// + /// The gender the Pokemon needs to have to evolve + /// + public required Gender Gender { get; init; } + + /// + public required StringKey ToSpecies { get; init; } +} + +/// +/// Evolves when an item is used on the Pokemon. +/// +public record ItemUseEvolution : IEvolution +{ + /// + /// The item that needs to be used. + /// + public required StringKey Item { get; init; } + + /// + public required StringKey ToSpecies { get; init; } +} + +/// +/// Evolves when an item is used on the Pokemon, and the Pokemon is a specific gender +/// +public record ItemGenderEvolution : IEvolution +{ + /// + /// The item that needs to be used. + /// + public required StringKey Item { get; init; } + + /// + /// The gender the Pokemon needs to have to evolve + /// + public Gender Gender { get; init; } + + /// + public required StringKey ToSpecies { get; init; } +} + +/// +/// Evolves when an item is held by the Pokemon, and the Pokemon levels up. +/// +public record HoldItemEvolution : IEvolution +{ + /// + /// The item that needs to be held. + /// + public required StringKey Item { get; init; } + + /// + public required StringKey ToSpecies { get; init; } +} + +/// +/// Evolves when an item is held by the Pokemon, and the Pokemon levels up, and it's day. +/// +public record DayHoldItemEvolution : IEvolution +{ + /// + /// The item that needs to be held. + /// + public required StringKey Item { get; init; } + + /// < inheritdoc /> + public required StringKey ToSpecies { get; init; } +} + +/// +/// Evolves when an item is held by the Pokemon, and the Pokemon levels up, and it's night. +/// +public record NightHoldItemEvolution : IEvolution +{ + /// + /// The item that needs to be held. + /// + public required StringKey Item { get; init; } + + /// < inheritdoc /> + public required StringKey ToSpecies { get; init; } +} + +/// +/// Evolves when the Pokemon knows a certain move, and the Pokemon levels up. +/// +public record HasMoveEvolution : IEvolution +{ + /// + /// The name of the move that needs to be known. + /// + public required StringKey MoveName { get; init; } + + /// < inheritdoc /> + public required StringKey ToSpecies { get; init; } +} + +/// +/// Evolves when above a certain happiness level, and the Pokemon levels up. +/// +public record HappinessEvolution : IEvolution +{ + /// + /// The happiness level that needs to be reached. + /// + public required byte Happiness { get; init; } + + /// < inheritdoc /> + public required StringKey ToSpecies { get; init; } +} + +/// +/// Evolves when above a certain happiness level, and the Pokemon levels up, and it's day. +/// +public record HappinessDayEvolution : IEvolution +{ + /// + /// The happiness level that needs to be reached. + /// + public required byte Happiness { get; init; } + + /// < inheritdoc /> + public required StringKey ToSpecies { get; init; } +} + +/// +/// Evolves when above a certain happiness level, and the Pokemon levels up, and it's night. +/// +public record HappinessNightEvolution : IEvolution +{ + /// + /// The happiness level that needs to be reached. + /// + public required byte Happiness { get; init; } + + /// < inheritdoc /> + public required StringKey ToSpecies { get; init; } +} + +/// +/// Evolves when traded. +/// +public record TradeEvolution : IEvolution +{ + /// < inheritdoc /> + public required StringKey ToSpecies { get; init; } +} + +/// +/// Evolves when traded with a certain species. +/// +public record TradeSpeciesEvolution : IEvolution +{ + /// + /// The species that needs to be traded with. + /// + public required StringKey WithSpecies { get; init; } + + /// < inheritdoc /> + public required StringKey ToSpecies { get; init; } +} + +/// +/// Evolves when traded while it's holding a certain item. +/// +public record TradeItemEvolution : IEvolution +{ + /// + /// The item that needs to be held. + /// + public required StringKey Item { get; init; } + + /// < inheritdoc /> + public required StringKey ToSpecies { get; init; } +} + +/// +/// Custom evolution method, implemented by the user. +/// +public record CustomEvolution : IEvolution +{ + /// + /// The name of the custom evolution method. This should refer to the name of the script that will be executed. + /// + public required StringKey Name { get; init; } + + /// + /// The parameters of the custom evolution method. + /// + public required IReadOnlyDictionary Parameters { get; init; } + + /// < inheritdoc /> + public required StringKey ToSpecies { get; init; } +} \ No newline at end of file diff --git a/PkmnLib.Static/Species/Form.cs b/PkmnLib.Static/Species/Form.cs new file mode 100644 index 0000000..92b8dd6 --- /dev/null +++ b/PkmnLib.Static/Species/Form.cs @@ -0,0 +1,218 @@ +using System.Collections.Immutable; +using FluentResults; +using PkmnLib.Static.Utils; +using PkmnLib.Static.Utils.Errors; + +namespace PkmnLib.Static.Species; + +/// +/// A form is a variant of a specific species. A species always has at least one form, but can have +/// many more. +/// +public interface IForm +{ + /// + /// The name of the form. + /// + StringKey Name { get; } + + /// + /// The height of the form in meters. + /// + float Height { get; } + + /// + /// The weight of the form in kilograms. + /// + float Weight { get; } + + /// + /// The base amount of experience that is gained when beating a Pokemon with this form. + /// + uint BaseExperience { get; } + + /// + /// The normal types a Pokemon with this form has. + /// + IReadOnlyList Types { get; } + + /// + /// The inherent values of a form of species that are used for the stats of a Pokemon. + /// + StaticStatisticSet BaseStats { get; } + + /// + /// The possible abilities a Pokemon with this form can have. + /// + IReadOnlyList Abilities { get; } + + /// + /// The possible hidden abilities a Pokemon with this form can have. + /// + IReadOnlyList HiddenAbilities { get; } + + /// + /// The moves a Pokemon with this form can learn. + /// + ILearnableMoves Moves { get; } + + /// + /// Arbitrary flags can be set on a form for scripting use. + /// + ImmutableHashSet Flags { get; } + + /// + /// Get a type of the form at a certain index. + /// + Result GetType(int index); + + /// + /// Gets a single base stat value. + /// + ushort GetBaseStat(Statistic stat); + + /// + /// Find the index of an ability that can be on this form. + /// + AbilityIndex? FindAbilityIndex(IAbility ability); + + /// + /// Gets an ability from the form. + /// + Result GetAbility(AbilityIndex index); + + /// + /// Gets a random ability from the form. + /// + StringKey GetRandomAbility(IRandom rand); + + /// + /// Gets a random hidden ability from the form. + /// + StringKey GetRandomHiddenAbility(IRandom rand); + + /// + /// Check if the form has a specific flag set. + /// + bool HasFlag(string key); +} + +/// +public class FormImpl : IForm +{ + /// + public FormImpl(StringKey name, float height, float weight, uint baseExperience, + IEnumerable types, StaticStatisticSet baseStats, IEnumerable abilities, + IEnumerable hiddenAbilities, ILearnableMoves moves, ImmutableHashSet flags) + { + Name = name; + Height = height; + Weight = weight; + BaseExperience = baseExperience; + Types = [..types]; + BaseStats = baseStats; + Abilities = [..abilities]; + HiddenAbilities = [..hiddenAbilities]; + Moves = moves; + Flags = flags; + + if (Types.Count == 0) + throw new ArgumentException("A form must have at least one type."); + if (Abilities.Count == 0) + throw new ArgumentException("A form must have at least one ability."); + if (HiddenAbilities.Count == 0) + throw new ArgumentException("A form must have at least one hidden ability."); + } + + /// + public StringKey Name { get; } + + /// + public float Height { get; } + + /// + public float Weight { get; } + + /// + public uint BaseExperience { get; } + + /// + public IReadOnlyList Types { get; } + + /// + public StaticStatisticSet BaseStats { get; } + + /// + public IReadOnlyList Abilities { get; } + + /// + public IReadOnlyList HiddenAbilities { get; } + + /// + public ILearnableMoves Moves { get; } + + /// + public ImmutableHashSet Flags { get; } + + /// + public Result GetType(int index) + { + if (index < 0 || index >= Types.Count) + return Result.Fail(new OutOfRange("Type", index, Types.Count)); + return Types[index]; + } + + /// + public ushort GetBaseStat(Statistic stat) => BaseStats.GetStatistic(stat); + + /// + public AbilityIndex? FindAbilityIndex(IAbility ability) + { + for (var i = 0; i < Abilities.Count && i < 255; i++) + { + if (Abilities[i] == ability.Name) + return new AbilityIndex + { + IsHidden = false, + Index = (byte)i + }; + } + for (var i = 0; i < HiddenAbilities.Count && i < 255; i++) + { + if (HiddenAbilities[i] == ability.Name) + return new AbilityIndex + { + IsHidden = true, + Index = (byte)i + }; + } + return null; + } + + /// + public Result GetAbility(AbilityIndex index) + { + var array = index.IsHidden ? HiddenAbilities : Abilities; + if (index.Index >= array.Count) + return Result.Fail(new OutOfRange("Ability", index.Index, array.Count)); + return array[index.Index]; + } + + /// + public StringKey GetRandomAbility(IRandom rand) + { + return Abilities[rand.GetInt(Abilities.Count)]; + } + + /// + public StringKey GetRandomHiddenAbility(IRandom rand) + { + return HiddenAbilities[rand.GetInt(HiddenAbilities.Count)]; + } + + /// + public bool HasFlag(string key) + { + return Flags.Contains(key); + } +} \ No newline at end of file diff --git a/PkmnLib.Static/Species/Gender.cs b/PkmnLib.Static/Species/Gender.cs new file mode 100644 index 0000000..6dbb619 --- /dev/null +++ b/PkmnLib.Static/Species/Gender.cs @@ -0,0 +1,21 @@ +namespace PkmnLib.Static; + +/// +/// Gender is a Pokemon characteristic. +/// +/// Required for standard pokemon functions, but somewhat controversial nowadays. Consider adding a feature +/// that allows for a more progressive gender system for those that want it? +/// +public enum Gender : byte +{ + /// The Pokemon has no gender. + Genderless, + /// + /// The Pokemon is male. + /// + Male, + /// + /// The Pokemon is female. + /// + Female +} \ No newline at end of file diff --git a/PkmnLib.Static/Species/LearnableMoves.cs b/PkmnLib.Static/Species/LearnableMoves.cs new file mode 100644 index 0000000..6a63fd8 --- /dev/null +++ b/PkmnLib.Static/Species/LearnableMoves.cs @@ -0,0 +1,58 @@ +using PkmnLib.Static.Utils; + +namespace PkmnLib.Static.Species; + +/// +/// The storage of the moves a Pokemon can learn. +/// +public interface ILearnableMoves +{ + /// + /// Adds a new level move the Pokemon can learn. + /// + /// The level the Pokemon learns the move at. + /// The move the Pokemon learns. + /// Whether the move was added successfully. + void AddLevelMove(LevelInt level, StringKey move); + + /// + /// Gets all moves a Pokemon can learn when leveling up to a specific level. + /// + /// The level the Pokemon is learning moves at. + /// The moves the Pokemon learns at that level. + IReadOnlyList GetLearnedByLevel(LevelInt level); + + /// + /// Gets the distinct moves a Pokemon can learn through leveling up. + /// + /// The moves the Pokemon can learn through leveling up. + IReadOnlyList GetDistinctLevelMoves(); +} + +public class LearnableMovesImpl : ILearnableMoves +{ + private readonly Dictionary> _learnedByLevel = new(); + private readonly HashSet _distinctLevelMoves = new(); + + + public void AddLevelMove(LevelInt level, StringKey move) + { + if (!_learnedByLevel.TryGetValue(level, out var value)) + _learnedByLevel[level] = [move]; + else + value.Add(move); + _distinctLevelMoves.Add(move); + } + + public IReadOnlyList GetLearnedByLevel(LevelInt level) + { + if (!_learnedByLevel.TryGetValue(level, out var value)) + return Array.Empty(); + return value; + } + + public IReadOnlyList GetDistinctLevelMoves() + { + return _distinctLevelMoves.ToList(); + } +} diff --git a/PkmnLib.Static/Species/Species.cs b/PkmnLib.Static/Species/Species.cs new file mode 100644 index 0000000..3d255b0 --- /dev/null +++ b/PkmnLib.Static/Species/Species.cs @@ -0,0 +1,148 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using PkmnLib.Static.Utils; + +namespace PkmnLib.Static.Species; + +/// +/// The data belonging to a Pokemon with certain characteristics. +/// +public interface ISpecies +{ + /// + /// The national dex identifier of the Pokemon. + /// + ushort Id { get; } + + /// + /// The name of the Pokemon. + /// + StringKey Name { get; } + + /// + /// The chance between 0.0 and 1.0 that a Pokemon is female. 0.0 means always male, 1.0 means always female. + /// If less than 0, the Pokemon is genderless. + /// + float GenderRate { get; } + + /// + /// How much experience is required for a level. + /// + StringKey GrowthRate { get; } + + /// + /// How hard it is to capture a Pokemon. 255 means this will be always caught, 0 means this is + /// uncatchable. + /// + byte CaptureRate { get; } + + /// + /// The base happiness of the Pokemon. + /// + byte BaseHappiness { get; } + + /// + /// The forms that belong to this Pokemon. + /// + IReadOnlyDictionary Forms { get; } + + /// + /// The arbitrary flags that can be set on a Pokemon for script use. + /// + ImmutableHashSet Flags { get; } + + /// + /// Gets a form by name. + /// + bool TryGetForm(StringKey id, [MaybeNullWhen(false)] out IForm form); + + /// + /// Gets the form the Pokemon will have by default, if no other form is specified. + /// + IForm GetDefaultForm(); + + /// + /// Gets a random gender. + /// + Gender GetRandomGender(IRandom rand); + + /// + /// Check whether the Pokemon has a specific flag set. + /// + bool HasFlag(string key); + + /// + /// The data regarding into which Pokemon this species can evolve, and how. + /// + IReadOnlyList EvolutionData { get; } +} + +/// +public class SpeciesImpl : ISpecies +{ + /// + public SpeciesImpl(ushort id, StringKey name, float genderRate, StringKey growthRate, byte captureRate, + byte baseHappiness, IReadOnlyDictionary forms, ImmutableHashSet flags, + IReadOnlyList evolutionData) + { + Id = id; + Name = name; + GenderRate = genderRate; + GrowthRate = growthRate; + CaptureRate = captureRate; + BaseHappiness = baseHappiness; + Forms = forms; + Flags = flags; + EvolutionData = evolutionData; + if (Forms.Count == 0) + throw new ArgumentException("Species must have at least one form."); + if (!Forms.ContainsKey("default")) + throw new ArgumentException("Species must have a default form."); + } + + /// + public ushort Id { get; } + + /// + public StringKey Name { get; } + + /// + public float GenderRate { get; } + + /// + public StringKey GrowthRate { get; } + + /// + public byte CaptureRate { get; } + + /// + public byte BaseHappiness { get; } + + /// + public IReadOnlyDictionary Forms { get; } + + /// + public ImmutableHashSet Flags { get; } + + /// + public IReadOnlyList EvolutionData { get; } + + + /// + public bool TryGetForm(StringKey id, [MaybeNullWhen(false)] out IForm form) => Forms.TryGetValue(id, out form); + + /// + public IForm GetDefaultForm() => Forms["default"]; + + /// + public Gender GetRandomGender(IRandom rand) + { + if (GenderRate < 0.0f) + return Gender.Genderless; + var v = rand.GetFloat(); + return v < GenderRate ? Gender.Female : Gender.Male; + } + + /// + public bool HasFlag(string key) => Flags.Contains(key); +} \ No newline at end of file diff --git a/PkmnLib.Static/Statistic.cs b/PkmnLib.Static/Statistic.cs new file mode 100644 index 0000000..ad42c26 --- /dev/null +++ b/PkmnLib.Static/Statistic.cs @@ -0,0 +1,32 @@ +namespace PkmnLib.Static; + +/// +/// Stats are numerical values on Pokemon that are used in battle. +/// +public enum Statistic : byte +{ + /// + /// Health Points determine how much damage a Pokemon can receive before fainting. + /// + Hp, + /// + /// Attack determines how much damage a Pokemon deals when using a physical attack. + /// + Attack, + /// + /// Defense determines how much damage a Pokemon receives when it is hit by a physical attack. + /// + Defense, + /// + /// Special Attack determines how much damage a Pokemon deals when using a special attack. + /// + SpecialAttack, + /// + /// Special Defense determines how much damage a Pokemon receives when it is hit by a special attack. + /// + SpecialDefense, + /// + /// Speed determines the order that a Pokemon can act in battle. + /// + Speed +} \ No newline at end of file diff --git a/PkmnLib.Static/StatisticSet.cs b/PkmnLib.Static/StatisticSet.cs new file mode 100644 index 0000000..e1caf97 --- /dev/null +++ b/PkmnLib.Static/StatisticSet.cs @@ -0,0 +1,155 @@ +using System; + +namespace PkmnLib.Static; + +public record StaticStatisticSet + where T : struct +{ + /// + /// The health points stat value. + /// + public T Hp { get; protected set; } + + /// + /// The physical attack stat value. + /// + public T Attack { get; protected set; } + + /// + /// The physical defense stat value. + /// + public T Defense { get; protected set; } + + /// + /// The special attack stat value. + /// + public T SpecialAttack { get; protected set; } + + /// + /// The special defense stat value. + /// + public T SpecialDefense { get; protected set; } + + /// + /// The speed stat value. + /// + public T Speed { get; protected set; } + + public StaticStatisticSet(T hp, T attack, T defense, T specialAttack, T specialDefense, T speed) + { + Hp = hp; + Attack = attack; + Defense = defense; + SpecialAttack = specialAttack; + SpecialDefense = specialDefense; + Speed = speed; + } + + public T GetStatistic(Statistic stat) + { + return stat switch + { + Statistic.Hp => Hp, + Statistic.Attack => Attack, + Statistic.Defense => Defense, + Statistic.SpecialAttack => SpecialAttack, + Statistic.SpecialDefense => SpecialDefense, + Statistic.Speed => Speed, + _ => throw new ArgumentException("Invalid statistic.") + }; + } +} + +public record StatisticSet : StaticStatisticSet + where T : struct +{ + public StatisticSet(T hp, T attack, T defense, T specialAttack, T specialDefense, T speed) : base(hp, attack, + defense, specialAttack, specialDefense, speed) + { + } + + public void SetStatistic(Statistic stat, T value) + { + switch (stat) + { + case Statistic.Hp: + Hp = value; + break; + case Statistic.Attack: + Attack = value; + break; + case Statistic.Defense: + Defense = value; + break; + case Statistic.SpecialAttack: + SpecialAttack = value; + break; + case Statistic.SpecialDefense: + SpecialDefense = value; + break; + case Statistic.Speed: + Speed = value; + break; + default: + throw new ArgumentException("Invalid statistic."); + } + } + + public void IncreaseStatistic(Statistic stat, T value) + { + switch (stat) + { + case Statistic.Hp: + Hp = (T)Convert.ChangeType(Convert.ToInt32(Hp) + Convert.ToInt32(value), typeof(T)); + break; + case Statistic.Attack: + Attack = (T)Convert.ChangeType(Convert.ToInt32(Attack) + Convert.ToInt32(value), typeof(T)); + break; + case Statistic.Defense: + Defense = (T)Convert.ChangeType(Convert.ToInt32(Defense) + Convert.ToInt32(value), typeof(T)); + break; + case Statistic.SpecialAttack: + SpecialAttack = + (T)Convert.ChangeType(Convert.ToInt32(SpecialAttack) + Convert.ToInt32(value), typeof(T)); + break; + case Statistic.SpecialDefense: + SpecialDefense = + (T)Convert.ChangeType(Convert.ToInt32(SpecialDefense) + Convert.ToInt32(value), typeof(T)); + break; + case Statistic.Speed: + Speed = (T)Convert.ChangeType(Convert.ToInt32(Speed) + Convert.ToInt32(value), typeof(T)); + break; + default: + throw new ArgumentException("Invalid statistic."); + } + } + + public void DecreaseStatistic(Statistic stat, T value) + { + switch (stat) + { + case Statistic.Hp: + Hp = (T)Convert.ChangeType(Convert.ToInt32(Hp) - Convert.ToInt32(value), typeof(T)); + break; + case Statistic.Attack: + Attack = (T)Convert.ChangeType(Convert.ToInt32(Attack) - Convert.ToInt32(value), typeof(T)); + break; + case Statistic.Defense: + Defense = (T)Convert.ChangeType(Convert.ToInt32(Defense) - Convert.ToInt32(value), typeof(T)); + break; + case Statistic.SpecialAttack: + SpecialAttack = + (T)Convert.ChangeType(Convert.ToInt32(SpecialAttack) - Convert.ToInt32(value), typeof(T)); + break; + case Statistic.SpecialDefense: + SpecialDefense = + (T)Convert.ChangeType(Convert.ToInt32(SpecialDefense) - Convert.ToInt32(value), typeof(T)); + break; + case Statistic.Speed: + Speed = (T)Convert.ChangeType(Convert.ToInt32(Speed) - Convert.ToInt32(value), typeof(T)); + break; + default: + throw new ArgumentException("Invalid statistic."); + } + } +} \ No newline at end of file diff --git a/PkmnLib.Static/TimeOfDay.cs b/PkmnLib.Static/TimeOfDay.cs new file mode 100644 index 0000000..6e93316 --- /dev/null +++ b/PkmnLib.Static/TimeOfDay.cs @@ -0,0 +1,28 @@ +namespace PkmnLib.Static; + +/// +/// The time of day. These values are the 4 different groups of time of day in Pokemon games since +/// gen 5. The exact times these correspond to differ between games. +/// +public enum TimeOfDay : byte +{ + /// + /// The morning. + /// + Morning = 0, + + /// + /// The day. + /// + Day = 1, + + /// + /// The evening. + /// + Evening = 2, + + /// + /// The night. + /// + Night = 3, +} \ No newline at end of file diff --git a/PkmnLib.Static/TypeIdentifier.cs b/PkmnLib.Static/TypeIdentifier.cs new file mode 100644 index 0000000..34f79ff --- /dev/null +++ b/PkmnLib.Static/TypeIdentifier.cs @@ -0,0 +1,15 @@ +namespace PkmnLib.Static; + +public readonly record struct TypeIdentifier +{ + private byte Value { get; init; } + + public TypeIdentifier(byte value) + { + Value = value; + } + + public static implicit operator TypeIdentifier(byte value) => new(value); + + public override int GetHashCode() => Value.GetHashCode(); +} \ No newline at end of file diff --git a/PkmnLib.Static/Utils/Errors/OutOfRange.cs b/PkmnLib.Static/Utils/Errors/OutOfRange.cs new file mode 100644 index 0000000..a6bd856 --- /dev/null +++ b/PkmnLib.Static/Utils/Errors/OutOfRange.cs @@ -0,0 +1,11 @@ +using FluentResults; + +namespace PkmnLib.Static.Utils.Errors; + +public class OutOfRange : Error +{ + public OutOfRange(string hint, int index, int max) + : base($"{hint} index {index} is out of range. Must be between 0 and {max - 1}.") + { + } +} \ No newline at end of file diff --git a/PkmnLib.Static/Utils/Random.cs b/PkmnLib.Static/Utils/Random.cs new file mode 100644 index 0000000..a8f7b9e --- /dev/null +++ b/PkmnLib.Static/Utils/Random.cs @@ -0,0 +1,43 @@ +namespace PkmnLib.Static.Utils; + +public interface IRandom +{ + public int GetInt(int min, int max); + public int GetInt(int max); + public int GetInt(); + public float GetFloat(); + public float GetFloat(float min, float max); + public bool GetBool(); +} + +public class RandomImpl : IRandom +{ + private Random _random; + + public RandomImpl(Random random) + { + _random = random; + } + + public RandomImpl(int seed) + { + _random = new Random(seed); + } + + public RandomImpl() + { + _random = new Random(); + } + + public int GetInt(int min, int max) => _random.Next(min, max); + + public int GetInt(int max) => _random.Next(max); + + public int GetInt() => _random.Next(); + + public float GetFloat() => (float)_random.NextDouble(); + + public float GetFloat(float min, float max) => (float)(_random.NextDouble() * (max - min) + min); + + public bool GetBool() => _random.Next(2) == 1; +} \ No newline at end of file diff --git a/PkmnLib.Static/Utils/StringKey.cs b/PkmnLib.Static/Utils/StringKey.cs new file mode 100644 index 0000000..32860a3 --- /dev/null +++ b/PkmnLib.Static/Utils/StringKey.cs @@ -0,0 +1,35 @@ +using System; + +namespace PkmnLib.Static.Utils; + +/// +/// A case-insensitive string key. We use this class for things like looking up data from dictionaries, etc. +/// +/// +/// This is a struct, as it's effectively just a wrapper around a single reference object. Heap allocation would be silly. +/// +public readonly record struct StringKey +{ + private readonly string _key; + + public StringKey(string key) + { + _key = key; + } + + public static implicit operator string(StringKey key) => key._key; + public static implicit operator StringKey(string key) => new(key); + + public override string ToString() => _key.ToLowerInvariant(); + + public bool Equals(StringKey other) + { + return string.Equals(_key, other._key, StringComparison.InvariantCultureIgnoreCase); + } + + public override int GetHashCode() + { + return StringComparer.InvariantCultureIgnoreCase.GetHashCode(_key); + } + +} \ No newline at end of file diff --git a/PkmnLib.Tests/GlobalUsings.cs b/PkmnLib.Tests/GlobalUsings.cs new file mode 100644 index 0000000..6d25dcd --- /dev/null +++ b/PkmnLib.Tests/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using NUnit.Framework; +global using FluentAssertions; + +global using LevelInt = byte; \ No newline at end of file diff --git a/PkmnLib.Tests/PkmnLib.Tests.csproj b/PkmnLib.Tests/PkmnLib.Tests.csproj new file mode 100644 index 0000000..d9bd85c --- /dev/null +++ b/PkmnLib.Tests/PkmnLib.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + diff --git a/PkmnLib.Tests/Static/GrowthRateTests.cs b/PkmnLib.Tests/Static/GrowthRateTests.cs new file mode 100644 index 0000000..ac48530 --- /dev/null +++ b/PkmnLib.Tests/Static/GrowthRateTests.cs @@ -0,0 +1,46 @@ +using PkmnLib.Static; + +namespace PkmnLib.Tests.Static; + +public class GrowthRateTests +{ + [Test] + public void EmptyLookupGrowthRateTestShouldThrowArgumentException() + { + Action act = () => _ = new LookupGrowthRate("Test", []); + act.Should().Throw().WithMessage("Experience table must have at least one entry."); + } + + [Test] + public void NonZeroLookupGrowthRateTestShouldThrowArgumentException() + { + Action act = () => _ = new LookupGrowthRate("Test", [1]); + act.Should().Throw().WithMessage("Experience table must start at 0."); + } + + [Test] + public void TooLargeLookupGrowthRateTestShouldThrowArgumentException() + { + Action act = () => _ = new LookupGrowthRate("Test", Enumerable.Range(0, LevelInt.MaxValue + 1).Select(i => (uint)i)); + act.Should().Throw().WithMessage($"Experience table may have at most {LevelInt.MaxValue} entries."); + } + + [Test] + public void LookupGrowthRateTest() + { + var growthRate = new LookupGrowthRate("Test", [0, 1, 2, 3, 4, 5]); + growthRate.CalculateLevel(0).Should().Be(1); + growthRate.CalculateLevel(1).Should().Be(2); + growthRate.CalculateLevel(2).Should().Be(3); + growthRate.CalculateLevel(3).Should().Be(4); + growthRate.CalculateLevel(4).Should().Be(5); + growthRate.CalculateLevel(5).Should().Be(6); + + growthRate.CalculateExperience(1).Should().Be(0); + growthRate.CalculateExperience(2).Should().Be(1); + growthRate.CalculateExperience(3).Should().Be(2); + growthRate.CalculateExperience(4).Should().Be(3); + growthRate.CalculateExperience(5).Should().Be(4); + growthRate.CalculateExperience(6).Should().Be(5); + } +} \ No newline at end of file diff --git a/PkmnLib.Tests/Static/StringKeyTests.cs b/PkmnLib.Tests/Static/StringKeyTests.cs new file mode 100644 index 0000000..79c707c --- /dev/null +++ b/PkmnLib.Tests/Static/StringKeyTests.cs @@ -0,0 +1,48 @@ +using System.Collections; +using PkmnLib.Static.Utils; + +namespace PkmnLib.Tests.Static; + +public class StringKeyTests +{ + private static IEnumerable StringKeyEqualityTestCases + { + get + { + yield return new TestCaseData("test", "test").Returns(true); + yield return new TestCaseData("test", "test2").Returns(false); + yield return new TestCaseData("test2", "test2").Returns(true); + yield return new TestCaseData("Test", "test").Returns(true); + yield return new TestCaseData("TeSt", "tesT").Returns(true); + yield return new TestCaseData("TeSt", "tesv").Returns(false); + } + } + + [Test] + [TestCaseSource(nameof(StringKeyEqualityTestCases))] + public bool StringKeyEqualityTest(string k1, string k2) + { + var sk1 = new StringKey(k1); + var sk2 = new StringKey(k2); + return sk1 == sk2; + } + + [Test] + [TestCaseSource(nameof(StringKeyEqualityTestCases))] + public bool HashCodeEqualityTest(string k1, string k2) + { + var sk1 = new StringKey(k1); + var sk2 = new StringKey(k2); + return sk1.GetHashCode() == sk2.GetHashCode(); + } + + [Test] + [TestCaseSource(nameof(StringKeyEqualityTestCases))] + public bool HashSetEqualityTest(string k1, string k2) + { + var sk1 = new StringKey(k1); + var sk2 = new StringKey(k2); + var hs = new HashSet { sk1 }; + return hs.Contains(sk2); + } +} \ No newline at end of file