diff --git a/PKHeX.Core/Editing/BattleTemplate/BattleTemplateConfig.cs b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateConfig.cs new file mode 100644 index 000000000..428598e4d --- /dev/null +++ b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateConfig.cs @@ -0,0 +1,281 @@ +using System; +using System.Text; + +namespace PKHeX.Core; + +/// +/// Grammar and prefix/suffix tokens for localization. +/// +public sealed record BattleTemplateConfig +{ + public sealed record BattleTemplateTuple(BattleTemplateToken Token, string Text); + + /// Prefix tokens - e.g. Friendship: {100} + public required BattleTemplateTuple[] Left { get; init; } + + /// Suffix tokens - e.g. {Timid} Nature + public required BattleTemplateTuple[] Right { get; init; } + + /// Tokens that always display the same text, with no value - e.g. Shiny: Yes + public required BattleTemplateTuple[] Center { get; init; } + + /// + /// Stat names, ordered with speed in the middle (not last). + /// + public required StatDisplayConfig StatNames { get; init; } + + /// + /// Stat names, ordered with speed in the middle (not last). + /// + public required StatDisplayConfig StatNamesFull { get; init; } + + public required string Male { get; init; } + public required string Female { get; init; } + + /// + /// Gets the stat names in the requested format. + /// + /// + public StatDisplayConfig GetStatDisplay(StatDisplayStyle style = StatDisplayStyle.Abbreviated) => style switch + { + StatDisplayStyle.Abbreviated => StatNames, + StatDisplayStyle.Full => StatNamesFull, + StatDisplayStyle.HABCDS => StatDisplayConfig.HABCDS, + StatDisplayStyle.Raw => StatDisplayConfig.Raw, + StatDisplayStyle.Raw00 => StatDisplayConfig.Raw00, + _ => throw new ArgumentOutOfRangeException(nameof(style), style, null), + }; + + public static ReadOnlySpan GetMoveDisplay(MoveDisplayStyle style = MoveDisplayStyle.Fill) => style switch + { + MoveDisplayStyle.Fill => "----", + MoveDisplayStyle.Directional => "↑←↓→", + _ => throw new ArgumentOutOfRangeException(nameof(style), style, null), + }; + + public static bool IsMovePrefix(char c) => c is '-' or '–' or '↑' or '←' or '↓' or '→'; + + public static ReadOnlySpan CommunityStandard => + [ + BattleTemplateToken.FirstLine, + BattleTemplateToken.Ability, + BattleTemplateToken.Level, + BattleTemplateToken.Shiny, + BattleTemplateToken.Friendship, + BattleTemplateToken.DynamaxLevel, + BattleTemplateToken.Gigantamax, + BattleTemplateToken.TeraType, + BattleTemplateToken.EVs, + BattleTemplateToken.Nature, + BattleTemplateToken.IVs, + BattleTemplateToken.Moves, + ]; + + public static ReadOnlySpan Showdown => CommunityStandard; + + public static ReadOnlySpan ShowdownNew => + [ + BattleTemplateToken.FirstLine, + BattleTemplateToken.AbilityHeldItem, + BattleTemplateToken.Moves, + BattleTemplateToken.EVsAppendNature, + BattleTemplateToken.IVs, + BattleTemplateToken.Level, + BattleTemplateToken.Shiny, + BattleTemplateToken.Friendship, + BattleTemplateToken.DynamaxLevel, + BattleTemplateToken.Gigantamax, + BattleTemplateToken.TeraType, + ]; + + public static ReadOnlySpan DefaultHover => + [ + // First line is handled manually. + BattleTemplateToken.HeldItem, + BattleTemplateToken.Ability, + BattleTemplateToken.Level, + BattleTemplateToken.Shiny, + BattleTemplateToken.DynamaxLevel, + BattleTemplateToken.Gigantamax, + BattleTemplateToken.TeraType, + BattleTemplateToken.EVs, + BattleTemplateToken.IVs, + BattleTemplateToken.Nature, + BattleTemplateToken.Moves, + + // Other tokens are handled manually (Ganbaru, Awakening) as they are not stored by the battle template interface, only entity objects. + ]; + + /// + /// Tries to parse the line for a token and value, if applicable. + /// + /// Line to parse + /// Value for the token, if applicable + /// Token type that was found + public BattleTemplateToken TryParse(ReadOnlySpan line, out ReadOnlySpan value) + { + value = default; + if (line.Length == 0) + return BattleTemplateToken.None; + foreach (var tuple in Left) + { + if (!line.StartsWith(tuple.Text, StringComparison.OrdinalIgnoreCase)) + continue; + value = line[tuple.Text.Length..]; + return tuple.Token; + } + foreach (var tuple in Right) + { + if (!line.EndsWith(tuple.Text, StringComparison.OrdinalIgnoreCase)) + continue; + value = line[..^tuple.Text.Length]; + return tuple.Token; + } + foreach (var tuple in Center) + { + if (!line.Equals(tuple.Text, StringComparison.OrdinalIgnoreCase)) + continue; + return tuple.Token; + } + return BattleTemplateToken.None; + } + + private string GetToken(BattleTemplateToken token, out bool isLeft) + { + foreach (var tuple in Left) + { + if (tuple.Token != token) + continue; + isLeft = true; + return tuple.Text; + } + foreach (var tuple in Right) + { + if (tuple.Token != token) + continue; + isLeft = false; + return tuple.Text; + } + foreach (var tuple in Center) + { + if (tuple.Token != token) + continue; + isLeft = false; + return tuple.Text; + } + throw new ArgumentException($"Token {token} not found in config"); + } + + /// + /// Gets the string representation of the token. No value is combined with it. + /// + public string Push(BattleTemplateToken token) => GetToken(token, out _); + + /// + /// Gets the string representation of the token, and combines the value with it. + /// + public string Push(BattleTemplateToken token, T value) + { + var str = GetToken(token, out var isLeft); + if (isLeft) + return $"{str}{value}"; + return $"{value}{str}"; + } + + /// + public void Push(BattleTemplateToken token, T value, StringBuilder sb) + { + var str = GetToken(token, out var isLeft); + if (isLeft) + sb.Append(str).Append(value); + else + sb.Append(value).Append(str); + } + + /// + /// Checks all representations of the stat name for a match. + /// + /// Stat name + /// -1 if not found, otherwise the index of the stat + public int GetStatIndex(ReadOnlySpan stat) + { + var index = StatNames.GetStatIndex(stat); + if (index != -1) + return index; + index = StatNamesFull.GetStatIndex(stat); + if (index != -1) + return index; + + foreach (var set in StatDisplayConfig.Custom) + { + index = set.GetStatIndex(stat); + if (index != -1) + return index; + } + return -1; + } + + public StatParseResult TryParseStats(ReadOnlySpan message, Span bestResult) + { + var result = ParseInternal(message, bestResult); + ReorderSpeedNotLast(bestResult); + result.TreatAmpsAsSpeedNotLast(); + return result; + } + + private StatParseResult ParseInternal(ReadOnlySpan message, Span bestResult) + { + Span original = stackalloc int[bestResult.Length]; + bestResult.CopyTo(original); + + var result = StatNames.TryParse(message, bestResult); + if (result.IsParseClean) + return result; + + // Check if the others get a better result + int bestCount = result.CountParsed; + Span tmp = stackalloc int[bestResult.Length]; + // Check Long Stat names + { + original.CopyTo(tmp); // restore original defaults + var other = StatNamesFull.TryParse(message, tmp); + if (other.IsParseClean) + { + tmp.CopyTo(bestResult); + return other; + } + if (other.CountParsed > bestCount) + { + bestCount = other.CountParsed; + tmp.CopyTo(bestResult); + } + } + // Check custom parsers + foreach (var set in StatDisplayConfig.Custom) + { + original.CopyTo(tmp); // restore original defaults + var other = set.TryParse(message, tmp); + if (other.IsParseClean) + { + tmp.CopyTo(bestResult); + return other; + } + if (other.CountParsed > bestCount) + { + bestCount = other.CountParsed; + tmp.CopyTo(bestResult); + } + } + + return result; + } + + private static void ReorderSpeedNotLast(Span arr) + { + ArgumentOutOfRangeException.ThrowIfLessThan(arr.Length, 6); + var speed = arr[5]; + arr[5] = arr[4]; + arr[4] = arr[3]; + arr[3] = speed; + } +} diff --git a/PKHeX.Core/Editing/BattleTemplate/BattleTemplateDisplayStyle.cs b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateDisplayStyle.cs new file mode 100644 index 000000000..e1691898b --- /dev/null +++ b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateDisplayStyle.cs @@ -0,0 +1,12 @@ +namespace PKHeX.Core; + +/// +/// Token order for displaying the battle template. +/// +public enum BattleTemplateDisplayStyle : sbyte +{ + Custom = -1, + Showdown = 0, // default + Legacy, + Brief, // default preview hover style +} diff --git a/PKHeX.Core/Editing/BattleTemplate/BattleTemplateExportSettings.cs b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateExportSettings.cs new file mode 100644 index 000000000..e83b2d259 --- /dev/null +++ b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateExportSettings.cs @@ -0,0 +1,96 @@ +using System; + +namespace PKHeX.Core; + +/// +/// Settings for exporting a battle template. +/// +public readonly ref struct BattleTemplateExportSettings +{ + /// + /// Order of the tokens in the export. + /// + public ReadOnlySpan Order { get; init; } + + /// + /// Localization for the battle template. + /// + public BattleTemplateLocalization Localization { get; } + + /// + /// Display style for the EVs. + /// + public StatDisplayStyle StatsEVs { get; init; } + + /// + /// Display style for the IVs. + /// + public StatDisplayStyle StatsIVs { get; init; } + + public StatDisplayStyle StatsOther { get; init; } + + /// + /// Display style for the moves. + /// + public MoveDisplayStyle Moves { get; init; } + + public static BattleTemplateExportSettings Showdown => new(BattleTemplateConfig.Showdown); + public static BattleTemplateExportSettings CommunityStandard => new(BattleTemplateConfig.CommunityStandard); + + public BattleTemplateExportSettings(string language) : this(BattleTemplateConfig.Showdown, language) { } + public BattleTemplateExportSettings(LanguageID language) : this(BattleTemplateConfig.Showdown, language) { } + + public BattleTemplateExportSettings(ReadOnlySpan order, string language = BattleTemplateLocalization.DefaultLanguage) + { + Localization = BattleTemplateLocalization.GetLocalization(language); + Order = order; + } + + public BattleTemplateExportSettings(ReadOnlySpan order, LanguageID language) + { + Localization = BattleTemplateLocalization.GetLocalization(language); + Order = order; + } + + /// + /// Checks if the token is in the export. + /// + public bool IsTokenInExport(BattleTemplateToken token) + { + foreach (var t in Order) + { + if (t == token) + return true; + } + return false; + } + + /// + /// Gets the index of the token in the export. + /// + public int GetTokenIndex(BattleTemplateToken token) + { + for (int i = 0; i < Order.Length; i++) + { + if (Order[i] == token) + return i; + } + return -1; + } + + /// + /// Checks if the token is in the export. + /// + /// Should be a static method, but is not because it feels better this way. + /// Token to check + /// Tokens to check against + public bool IsTokenInExport(BattleTemplateToken token, ReadOnlySpan tokens) + { + foreach (var t in tokens) + { + if (t == token) + return true; + } + return false; + } +} diff --git a/PKHeX.Core/Editing/BattleTemplate/BattleTemplateLocalization.cs b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateLocalization.cs new file mode 100644 index 000000000..42d581fcd --- /dev/null +++ b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateLocalization.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PKHeX.Core; + +/// +/// Provides information for localizing sets. +/// +/// In-game strings +/// Grammar and prefix/suffix tokens +public sealed record BattleTemplateLocalization(GameStrings Strings, BattleTemplateConfig Config) +{ + public const string DefaultLanguage = GameLanguage.DefaultLanguage; // English + + private static readonly Dictionary Cache = new(); + public static readonly BattleTemplateLocalization Default = GetLocalization(DefaultLanguage); + + /// index + /// + public static BattleTemplateLocalization GetLocalization(LanguageID language) => + GetLocalization(language.GetLanguageCode()); + + /// + /// Gets the localization for the requested language. + /// + /// Language code + public static BattleTemplateLocalization GetLocalization(string language) + { + if (Cache.TryGetValue(language, out var result)) + return result; + + var strings = GameInfo.GetStrings(language); + var cfg = GetConfig(language); + result = new BattleTemplateLocalization(strings, cfg); + Cache[language] = result; + return result; + } + + private static string GetJson(string language) => Util.GetStringResource($"battle_{language}.json"); + private static BattleTemplateConfigContext GetContext() => new(); + + private static BattleTemplateConfig GetConfig(string language) + { + var text = GetJson(language); + var result = JsonSerializer.Deserialize(text, GetContext().BattleTemplateConfig) + ?? throw new JsonException($"Failed to deserialize {nameof(BattleTemplateConfig)} for {language}"); + return result; + } + + /// + /// Force loads all localizations. + /// + public static bool ForceLoadAll() + { + bool anyLoaded = false; + foreach (var lang in GameLanguage.AllSupportedLanguages) + { + if (Cache.ContainsKey(lang)) + continue; + _ = GetLocalization(lang); + anyLoaded = true; + } + return anyLoaded; + } + + /// + /// Gets all localizations. + /// + public static IReadOnlyDictionary GetAll() + { + _ = ForceLoadAll(); + return Cache; + } +} + +[JsonSerializable(typeof(BattleTemplateConfig))] +public sealed partial class BattleTemplateConfigContext : JsonSerializerContext; diff --git a/PKHeX.Core/Editing/BattleTemplate/BattleTemplateToken.cs b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateToken.cs new file mode 100644 index 000000000..e3e8d4fc6 --- /dev/null +++ b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateToken.cs @@ -0,0 +1,49 @@ +using System.Text.Json.Serialization; + +namespace PKHeX.Core; + +/// +/// Enum for the different tokens used in battle templates. +/// +/// +/// Each token represents a specific aspect of a Pokémon's battle template. +/// One token per line. Each token can have specific grammar rules depending on the language. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum BattleTemplateToken : byte +{ + None = 0, // invalid, used as a magic value to signal that a token is not recognized + + // Standard tokens + Shiny, + Ability, + Nature, + Friendship, + EVs, + IVs, + Level, + DynamaxLevel, + Gigantamax, + TeraType, + + // Tokens that can appear multiple times + Moves, + + // When present, first line will not contain values for these tokens (instead outputting on separate token line) + // Not part of the standard export format, but can be recognized/optionally used in the program + HeldItem, + Nickname, + Gender, + + // Manually appended, not stored or recognized on import + AVs, + GVs, + + // Future Showdown propositions + AbilityHeldItem, // [Ability] Item + EVsWithNature, // +/- + EVsAppendNature, // +/- and .. (Nature) + + // Omitting the first line (species) shouldn't be done unless it is manually added in the presentation/export. + FirstLine = byte.MaxValue, +} diff --git a/PKHeX.Core/Editing/BattleTemplate/BattleTemplateTypeSetting.cs b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateTypeSetting.cs new file mode 100644 index 000000000..b884293fe --- /dev/null +++ b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateTypeSetting.cs @@ -0,0 +1,77 @@ +using System; +using System.ComponentModel; + +namespace PKHeX.Core; + +public sealed class BattleTemplateSettings +{ + [LocalizedDescription("Settings for showing details when hovering a slot.")] + public BattleTemplateTypeSetting Hover { get; set; } = new(BattleTemplateDisplayStyle.Brief, LanguageID.None, MoveDisplayStyle.Directional); + + [LocalizedDescription("Settings for showing details when exporting a slot.")] + public BattleTemplateTypeSetting Export { get; set; } = new(BattleTemplateDisplayStyle.Showdown, LanguageID.English); +} + +[TypeConverter(typeof(ExpandableObjectConverter))] +public sealed class BattleTemplateTypeSetting +{ + + [LocalizedDescription("Language to use when exporting a battle template. If not specified in settings, will use current language.")] + public LanguageID Language { get; set; } + public StatDisplayStyle StyleStatEVs { get; set; } + public StatDisplayStyle StyleStatIVs { get; set; } + public StatDisplayStyle StyleStatOther { get; set; } + public MoveDisplayStyle StyleMove { get; set; } + + [LocalizedDescription("Custom stat labels and grammar.")] + public StatDisplayConfig StatsCustom { get; set; } = StatDisplayConfig.HABCDS; + + [LocalizedDescription("Display format to use when exporting a battle template from the program.")] + public BattleTemplateDisplayStyle TokenOrder { get; set; } + + [LocalizedDescription("Custom ordering for exporting a set, if chosen via export display style.")] + public BattleTemplateToken[] TokenOrderCustom { get; set; } = BattleTemplateConfig.Showdown.ToArray(); + + public BattleTemplateTypeSetting() { } + public BattleTemplateTypeSetting(BattleTemplateDisplayStyle style, LanguageID lang, MoveDisplayStyle move = MoveDisplayStyle.Fill) + { + TokenOrder = style; + Language = lang; + StyleMove = move; + } + + public override string ToString() => $"{TokenOrder} {Language}"; + + private LanguageID GetLanguageExport(LanguageID program) => GetLanguage(Language, program); + + public BattleTemplateExportSettings GetSettings(LanguageID programLanguage, EntityContext context) => new(GetOrder(TokenOrder, TokenOrderCustom), GetLanguageExport(programLanguage)) + { + StatsEVs = StyleStatEVs, + StatsIVs = StyleStatIVs, + StatsOther = StyleStatOther, + Moves = GetMoveDisplayStyle(StyleMove, context), + }; + + private static LanguageID GetLanguage(LanguageID choice, LanguageID program) + { + if (choice != LanguageID.None) + return choice; + if (program == LanguageID.None) + return LanguageID.English; + return program; + } + + private static ReadOnlySpan GetOrder(BattleTemplateDisplayStyle style, ReadOnlySpan custom) => style switch + { + BattleTemplateDisplayStyle.Legacy => BattleTemplateConfig.CommunityStandard, + BattleTemplateDisplayStyle.Brief => BattleTemplateConfig.DefaultHover, + BattleTemplateDisplayStyle.Custom => custom, + _ => BattleTemplateConfig.Showdown, + }; + + private static MoveDisplayStyle GetMoveDisplayStyle(MoveDisplayStyle style, EntityContext context) => style switch + { + //MoveDisplayStyle.Directional when context is EntityContext.Gen9a => MoveDisplayStyle.Directional, TODO ZA + _ => MoveDisplayStyle.Fill, + }; +} diff --git a/PKHeX.Core/Editing/IBattleTemplate.cs b/PKHeX.Core/Editing/BattleTemplate/IBattleTemplate.cs similarity index 95% rename from PKHeX.Core/Editing/IBattleTemplate.cs rename to PKHeX.Core/Editing/BattleTemplate/IBattleTemplate.cs index c91c5a80e..35f8d6723 100644 --- a/PKHeX.Core/Editing/IBattleTemplate.cs +++ b/PKHeX.Core/Editing/BattleTemplate/IBattleTemplate.cs @@ -23,6 +23,7 @@ public interface IBattleTemplate : ISpeciesForm, IGigantamaxReadOnly, IDynamaxLe /// /// of the Set entity. /// + /// Depends on for context-specific item lists. int HeldItem { get; } /// diff --git a/PKHeX.Core/Editing/BattleTemplate/MoveDisplayStyle.cs b/PKHeX.Core/Editing/BattleTemplate/MoveDisplayStyle.cs new file mode 100644 index 000000000..7e449098f --- /dev/null +++ b/PKHeX.Core/Editing/BattleTemplate/MoveDisplayStyle.cs @@ -0,0 +1,17 @@ +namespace PKHeX.Core; + +/// +/// Style to display moves. +/// +public enum MoveDisplayStyle : byte +{ + /// + /// Moves are slots 1-4, with no empty slots, and correspond to the rectangular grid without empty spaces. + /// + Fill, + + /// + /// Move slots are assigned to the directional pad, and unused directional slots are not displayed. + /// + Directional, +} diff --git a/PKHeX.Core/Editing/BattleTemplate/Showdown/BattleTemplateTeams.cs b/PKHeX.Core/Editing/BattleTemplate/Showdown/BattleTemplateTeams.cs new file mode 100644 index 000000000..8f1c6aae7 --- /dev/null +++ b/PKHeX.Core/Editing/BattleTemplate/Showdown/BattleTemplateTeams.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace PKHeX.Core; + +/// +/// Logic for retrieving teams from URLs. +/// +public static class BattleTemplateTeams +{ + /// + /// Tries to check if the input text is a valid URL for a team, and if so, retrieves the team data. + /// + /// The input text to check. + /// When the method returns, contains the retrieved team data if the text is a valid URL; otherwise, null. + /// true if the text is a valid URL and the team data was successfully retrieved; otherwise, false. + public static bool TryGetSetLines(string text, [NotNullWhen(true)] out string? content) + { + if (ShowdownTeam.IsURL(text, out var url)) + return ShowdownTeam.TryGetSets(url, out content); + if (PokepasteTeam.IsURL(text, out url)) + return PokepasteTeam.TryGetSets(url, out content); + content = text; + return false; + } + + /// + /// Attempts to retrieve sets from the provided text. If the text is a valid URL, it retrieves the team data from the URL. + /// + /// The input text to check. + /// An enumerable collection of objects representing the sets. + public static IEnumerable TryGetSets(string text) + { + var ingest = TryGetSetLines(text, out var many) ? many : text; + return ShowdownParsing.GetShowdownSets(ingest); + } +} diff --git a/PKHeX.Core/Editing/Showdown/PokepasteTeam.cs b/PKHeX.Core/Editing/BattleTemplate/Showdown/PokepasteTeam.cs similarity index 80% rename from PKHeX.Core/Editing/Showdown/PokepasteTeam.cs rename to PKHeX.Core/Editing/BattleTemplate/Showdown/PokepasteTeam.cs index 9c8d53ceb..474a827a6 100644 --- a/PKHeX.Core/Editing/Showdown/PokepasteTeam.cs +++ b/PKHeX.Core/Editing/BattleTemplate/Showdown/PokepasteTeam.cs @@ -4,6 +4,12 @@ namespace PKHeX.Core; +/// +/// Logic for retrieving Showdown teams from URLs. +/// +/// +/// +/// public static class PokepasteTeam { /// @@ -12,8 +18,17 @@ public static class PokepasteTeam /// The numeric identifier of the team. /// A string containing the full URL to access the team data. public static string GetURL(ulong team) => $"https://pokepast.es/{team:x16}/raw"; + + /// + /// For legacy team indexes (first 255 or so), shouldn't ever be triggered non-test team indexes. public static string GetURLOld(int team) => $"https://pokepast.es/{team}/raw"; + /// + /// Attempts to retrieve the Showdown team data from a specified URL, and reformats it. + /// + /// The URL to retrieve the team data from. + /// When the method returns, contains the processed team data if retrieval and formatting succeed; otherwise, null. + /// true if the team data is successfully retrieved and reformatted; otherwise, false. public static bool TryGetSets(string url, [NotNullWhen(true)] out string? content) { content = null; @@ -29,7 +44,6 @@ public static bool TryGetSets(string url, [NotNullWhen(true)] out string? conten /// /// The text to evaluate. /// When the method returns, contains the normalized API URL if the text represents a valid Showdown team URL; otherwise, null. - /// /// true if the text is a valid Showdown team URL; otherwise, false. public static bool IsURL(ReadOnlySpan text, [NotNullWhen(true)] out string? url) { diff --git a/PKHeX.Core/Editing/Showdown/ShowdownParsing.cs b/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownParsing.cs similarity index 55% rename from PKHeX.Core/Editing/Showdown/ShowdownParsing.cs rename to PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownParsing.cs index c54ebebf2..2b654930d 100644 --- a/PKHeX.Core/Editing/Showdown/ShowdownParsing.cs +++ b/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownParsing.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using static PKHeX.Core.Species; namespace PKHeX.Core; @@ -11,6 +12,9 @@ public static class ShowdownParsing { private static readonly string[] genderForms = ["", "F", ""]; + /// + private const int DefaultListAllocation = ShowdownSet.DefaultListAllocation; + /// /// Gets the Form ID from the input . /// @@ -147,12 +151,13 @@ public static string SetShowdownFormName(ushort species, string form, int abilit /// Fetches data from the input . /// /// Raw lines containing numerous multi-line set data. + /// Localization data for the set. /// objects until is consumed. - public static IEnumerable GetShowdownSets(IEnumerable lines) + public static IEnumerable GetShowdownSets(IEnumerable lines, BattleTemplateLocalization localization) { // exported sets always have >4 moves; new List will always require 1 resizing, allocate 2x to save 1 reallocation. // intro, nature, ability, (ivs, evs, shiny, level) 4*moves - var setLines = new List(8); + var setLines = new List(DefaultListAllocation); foreach (var line in lines) { if (!string.IsNullOrWhiteSpace(line)) @@ -162,14 +167,54 @@ public static IEnumerable GetShowdownSets(IEnumerable lines } if (setLines.Count == 0) continue; - yield return new ShowdownSet(setLines); + yield return new ShowdownSet(setLines, localization); setLines.Clear(); } if (setLines.Count != 0) - yield return new ShowdownSet(setLines); + yield return new ShowdownSet(setLines, localization); } - /// + /// + public static IEnumerable GetShowdownSets(IEnumerable lines) + { + var setLines = new List(DefaultListAllocation); + foreach (var line in lines) + { + if (!string.IsNullOrWhiteSpace(line)) + { + setLines.Add(line); + continue; + } + if (setLines.Count == 0) + continue; + yield return TryParseAnyLanguage(setLines, out var set) ? set : new ShowdownSet(setLines); + setLines.Clear(); + } + if (setLines.Count != 0) + yield return TryParseAnyLanguage(setLines, out var set) ? set : new ShowdownSet(setLines); + } + + /// + public static IEnumerable GetShowdownSets(ReadOnlyMemory text, BattleTemplateLocalization localization) + { + int start = 0; + do + { + var span = text.Span; + var slice = span[start..]; + var set = GetShowdownSet(slice, localization, out int length); + if (set.Species == 0) + break; + yield return set; + start += length; + } + while (start < text.Length); + } + + /// + /// + /// Language-unknown version of . + /// public static IEnumerable GetShowdownSets(ReadOnlyMemory text) { int start = 0; @@ -186,17 +231,15 @@ public static IEnumerable GetShowdownSets(ReadOnlyMemory text while (start < text.Length); } - /// - public static IEnumerable GetShowdownSets(string text) => GetShowdownSets(text.AsMemory()); + /// + public static IEnumerable GetShowdownSets(string text, BattleTemplateLocalization localization) => GetShowdownSets(text.AsMemory(), localization); private static int GetLength(ReadOnlySpan text) { // Find the end of the Showdown Set lines. // The end is implied when: - // - we see a complete whitespace or empty line, or - // - we witness four 'move' definition lines. + // - we see a complete whitespace or empty line int length = 0; - int moveCount = 4; while (true) { @@ -208,47 +251,75 @@ private static int GetLength(ReadOnlySpan text) var used = newline + 1; length += used; - if (slice.IsEmpty || slice.IsWhiteSpace()) - return length; - if (slice.TrimStart()[0] is '-' or '–' && --moveCount == 0) + if (slice.IsWhiteSpace()) return length; text = text[used..]; } } - public static ShowdownSet GetShowdownSet(ReadOnlySpan text, out int length) + /// + /// Attempts to parse the input into a object. + /// + /// Input string to parse. + /// Input localization to use. + /// Amount of characters consumed from the input string. + /// Parsed object if successful, otherwise might be a best-match with some/all unparsed lines. + public static ShowdownSet GetShowdownSet(ReadOnlySpan text, BattleTemplateLocalization localization, out int length) { length = GetLength(text); var slice = text[..length]; - var set = new ShowdownSet(slice); + var set = new ShowdownSet(slice, localization); while (length < text.Length && text[length] is '\r' or '\n' or ' ') length++; return set; } + /// + public static ShowdownSet GetShowdownSet(ReadOnlySpan text, out int length) + { + length = GetLength(text); + var slice = text[..length]; + if (!TryParseAnyLanguage(slice, out var set)) + set = new ShowdownSet(slice); // should never fall back + while (length < text.Length && text[length] is '\r' or '\n' or ' ') + length++; + return set; + } + + /// + public static IEnumerable GetShowdownSets(string text) => GetShowdownSets(text.AsMemory()); + + /// + public static string GetShowdownText(PKM pk) => GetShowdownText(pk, BattleTemplateExportSettings.Showdown); + /// /// Converts the data into an importable set format for Pokémon Showdown. /// /// PKM to convert to string + /// Import localization/style setting /// Multi line set data - public static string GetShowdownText(PKM pk) + public static string GetShowdownText(PKM pk, in BattleTemplateExportSettings settings) { if (pk.Species == 0) return string.Empty; - return new ShowdownSet(pk).Text; + var set = new ShowdownSet(pk); + set.InterpretAsPreview(pk); + return set.GetText(settings); } /// /// Fetches ShowdownSet lines from the input data. /// /// Pokémon data to summarize. - /// Localization setting + /// Export localization/style setting /// Consumable list of lines. - public static IEnumerable GetShowdownText(IEnumerable data, string lang = ShowdownSet.DefaultLanguage) + public static IEnumerable GetShowdownText(IEnumerable data, in BattleTemplateExportSettings settings) { + List result = new(); var sets = GetShowdownSets(data); foreach (var set in sets) - yield return set.LocalizedText(lang); + result.Add(set.GetText(settings)); + return result; } /// @@ -266,24 +337,123 @@ public static IEnumerable GetShowdownSets(IEnumerable data) } } + /// + public static string GetShowdownSets(IEnumerable data, string separator) => string.Join(separator, GetShowdownText(data, BattleTemplateExportSettings.Showdown)); + /// /// Fetches ShowdownSet lines from the input data, and combines it into one string. /// /// Pokémon data to summarize. /// Splitter between each set. + /// Import localization/style setting /// Single string containing all lines. - public static string GetShowdownSets(IEnumerable data, string separator) => string.Join(separator, GetShowdownText(data)); + public static string GetShowdownSets(IEnumerable data, string separator, in BattleTemplateExportSettings settings) => string.Join(separator, GetShowdownText(data, settings)); /// /// Gets a localized string preview of the provided . /// /// Pokémon data - /// Language code + /// Export settings /// Multi-line string - public static string GetLocalizedPreviewText(PKM pk, string language) + public static string GetLocalizedPreviewText(PKM pk, in BattleTemplateExportSettings settings) { var set = new ShowdownSet(pk); set.InterpretAsPreview(pk); - return set.LocalizedText(language); + return set.GetText(settings); + } + + /// + /// Tries to parse the input string into a object. + /// + /// Input string to parse. + /// Parsed object if successful, otherwise might be a best-match with some unparsed lines. + /// True if the input was parsed successfully, false otherwise. + public static bool TryParseAnyLanguage(ReadOnlySpan message, [NotNullWhen(true)] out ShowdownSet? set) + { + set = null; + if (message.Length == 0) + return false; + + var invalid = int.MaxValue; + var all = BattleTemplateLocalization.GetAll(); + foreach (var lang in all) + { + var local = lang.Value; + var tmp = new ShowdownSet(message, local); + var bad = tmp.InvalidLines.Count; + if (bad == 0) + { + set = tmp; + return true; + } + + // Check for invalid lines + if (bad >= invalid) + continue; + + // Best so far. + invalid = bad; + set = tmp; + } + if (set is null) + return false; + return set.Species != 0; + } + + /// + public static bool TryParseAnyLanguage(IReadOnlyList setLines, [NotNullWhen(true)] out ShowdownSet? set) + { + set = null; + if (setLines.Count == 0) + return false; + + var invalid = int.MaxValue; + var all = BattleTemplateLocalization.GetAll(); + foreach (var lang in all) + { + var local = lang.Value; + var tmp = new ShowdownSet(setLines, local); + var bad = tmp.InvalidLines.Count; + if (bad == 0) + { + set = tmp; + return true; + } + + // Check for invalid lines + if (bad >= invalid) + continue; + + // Best so far. + invalid = bad; + set = tmp; + } + return false; + } + + /// + /// Tries to translate the input battle template into a localized string. + /// + /// Input string to parse. + /// Export settings + /// Translated string if successful. + /// true if the input was translated successfully, false otherwise. + public static bool TryTranslate(ReadOnlySpan message, BattleTemplateExportSettings outputSettings, [NotNullWhen(true)] out string? translated) + { + translated = null; + if (!TryParseAnyLanguage(message, out var set)) + return false; + translated = set.GetText(outputSettings); + return true; + } + + /// + public static bool TryTranslate(IReadOnlyList message, BattleTemplateExportSettings outputSettings, [NotNullWhen(true)] out string? translated) + { + translated = null; + if (!TryParseAnyLanguage(message, out var set)) + return false; + translated = set.GetText(outputSettings); + return true; } } diff --git a/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownSet.cs b/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownSet.cs new file mode 100644 index 000000000..ec8f02d79 --- /dev/null +++ b/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownSet.cs @@ -0,0 +1,1050 @@ +using System; +using System.Collections.Generic; +using System.Text; +using static PKHeX.Core.Species; + +namespace PKHeX.Core; + +/// +/// Logic for exporting and importing data in Pokémon Showdown's text format. +/// +public sealed class ShowdownSet : IBattleTemplate +{ + private const char ItemSplit = '@'; + private const int MAX_SPECIES = (int)MAX_COUNT - 1; + private const int MaxMoveCount = 4; + private const string DefaultLanguage = BattleTemplateLocalization.DefaultLanguage; // English + private static BattleTemplateLocalization DefaultStrings => BattleTemplateLocalization.Default; + + private static ReadOnlySpan DashedSpecies => + [ + (int)NidoranF, (int)NidoranM, + (int)HoOh, + (int)Jangmoo, (int)Hakamoo, (int)Kommoo, + (int)TingLu, (int)ChienPao, (int)WoChien, (int)ChiYu, + ]; + + public ushort Species { get; private set; } + public EntityContext Context { get; private set; } = RecentTrainerCache.Context; + public string Nickname { get; private set; } = string.Empty; + public byte? Gender { get; private set; } + public int HeldItem { get; private set; } + public int Ability { get; private set; } = -1; + public byte Level { get; private set; } = 100; + public bool Shiny { get; private set; } + public byte Friendship { get; private set; } = 255; + public Nature Nature { get; private set; } = Nature.Random; + public string FormName { get; private set; } = string.Empty; + public byte Form { get; private set; } + public int[] EVs { get; } = [00, 00, 00, 00, 00, 00]; + public int[] IVs { get; } = [31, 31, 31, 31, 31, 31]; + public sbyte HiddenPowerType { get; private set; } = -1; + public MoveType TeraType { get; private set; } = MoveType.Any; + public ushort[] Moves { get; } = [0, 0, 0, 0]; + public bool CanGigantamax { get; private set; } + public byte DynamaxLevel { get; private set; } = 10; + + /// + /// Any lines that failed to be parsed. + /// + public readonly List InvalidLines = new(0); + + /// + /// Loads a new from the input string. + /// + /// Single-line string which will be split before loading. + /// Localization to parse the lines with. + public ShowdownSet(ReadOnlySpan input, BattleTemplateLocalization? localization = null) => LoadLines(input.EnumerateLines(), localization ?? DefaultStrings); + + /// + /// Loads a new from the input string. + /// + /// Enumerable list of lines. + /// Localization to parse the lines with. + public ShowdownSet(IEnumerable lines, BattleTemplateLocalization? localization = null) => LoadLines(lines, localization ?? DefaultStrings); + + private void LoadLines(SpanLineEnumerator lines, BattleTemplateLocalization localization) + { + ParseLines(lines, localization); + SanitizeResult(localization); + } + + private void LoadLines(IEnumerable lines, BattleTemplateLocalization localization) + { + ParseLines(lines, localization); + SanitizeResult(localization); + } + + private void SanitizeResult(BattleTemplateLocalization localization) + { + FormName = ShowdownParsing.SetShowdownFormName(Species, FormName, Ability); + Form = ShowdownParsing.GetFormFromString(FormName, localization.Strings, Species, Context); + + // Handle edge case with fixed-gender forms. + if (Species is (int)Meowstic or (int)Indeedee or (int)Basculegion or (int)Oinkologne) + ReviseGenderedForms(); + } + + private void ReviseGenderedForms() + { + if (Gender == 1) // Recognized with (F) + { + FormName = "F"; + Form = 1; + } + else + { + FormName = Form == 1 ? "F" : "M"; + Gender = Form; + } + } + + // Skip lines that are too short or too long. + // Longest line is ~74 (Gen2 EVs) + // Length permitted: 3-80 + // The shortest Pokémon name in Japanese is "ニ" (Ni) which is the name for the Pokémon, Nidoran♂ (male Nidoran). It has only one letter. + // We will handle this 1-2 letter edge case only if the line is the first line of the set, in the rare chance we are importing for a non-English language? + private const int MinLength = 3; + private const int MaxLength = 80; + private static bool IsLengthOutOfRange(ReadOnlySpan trim) => IsLengthOutOfRange(trim.Length); + private static bool IsLengthOutOfRange(int length) => (uint)(length - MinLength) > MaxLength - MinLength; + + private void ParseLines(SpanLineEnumerator lines, BattleTemplateLocalization localization) + { + int countMoves = 0; + bool first = true; + foreach (var line in lines) + { + ReadOnlySpan trim = line.Trim(); + if (IsLengthOutOfRange(trim)) + { + // Try for other languages just in case. + if (first && trim.Length != 0) + { + ParseFirstLine(trim, localization.Strings); + first = false; + continue; + } + InvalidLines.Add(line.ToString()); + continue; + } + + if (first) + { + ParseFirstLine(trim, localization.Strings); + first = false; + continue; + } + + ParseLine(trim, ref countMoves, localization); + } + } + + private void ParseLines(IEnumerable lines, BattleTemplateLocalization localization) + { + int countMoves = 0; + bool first = true; + foreach (var line in lines) + { + ReadOnlySpan trim = line.Trim(); + if (IsLengthOutOfRange(trim)) + { + // Try for other languages just in case. + if (first && trim.Length != 0) + { + ParseFirstLine(trim, localization.Strings); + first = false; + continue; + } + InvalidLines.Add(line); + continue; + } + + if (first) + { + ParseFirstLine(trim, localization.Strings); + first = false; + continue; + } + + ParseLine(trim, ref countMoves, localization); + } + } + + private void ParseLine(ReadOnlySpan line, ref int movectr, BattleTemplateLocalization localization) + { + var moves = Moves.AsSpan(); + var firstChar = line[0]; + if (firstChar is '-' or '–') + { + if (movectr >= MaxMoveCount) + { + InvalidLines.Add($"Too many moves: {line}"); + return; + } + var moveString = ParseLineMove(line, localization.Strings); + int move = StringUtil.FindIndexIgnoreCase(localization.Strings.movelist, moveString); + if (move < 0) + InvalidLines.Add($"Unknown Move: {moveString}"); + else if (moves.Contains((ushort)move)) + InvalidLines.Add($"Duplicate Move: {moveString}"); + else + moves[movectr++] = (ushort)move; + return; + } + + var dirMove = BattleTemplateConfig.GetMoveDisplay(MoveDisplayStyle.Directional); + var dirMoveIndex = dirMove.IndexOf(firstChar); + if (dirMoveIndex != -1) + { + if (moves[dirMoveIndex] != 0) + { + InvalidLines.Add($"Move slot already specified: {line}"); + return; + } + var moveString = ParseLineMove(line, localization.Strings); + int move = StringUtil.FindIndexIgnoreCase(localization.Strings.movelist, moveString); + if (move < 0) + InvalidLines.Add($"Unknown Move: {moveString}"); + else if (moves.Contains((ushort)move)) + InvalidLines.Add($"Duplicate Move: {moveString}"); + else + moves[dirMoveIndex] = (ushort)move; + movectr++; + return; + } + + if (firstChar is '[' or '@') // Ability + { + ParseLineAbilityBracket(line, localization.Strings); + return; + } + + var token = localization.Config.TryParse(line, out var value); + if (token == BattleTemplateToken.None) + { + InvalidLines.Add($"Unknown Token: {line}"); + return; + } + var valid = ParseEntry(token, value, localization); + if (!valid) + InvalidLines.Add(line.ToString()); + } + + private void ParseLineAbilityBracket(ReadOnlySpan line, GameStrings localizationStrings) + { + // Try to peel off Held Item if it is specified. + var itemStart = line.IndexOf(ItemSplit); + if (itemStart != -1) + { + var itemName = line[(itemStart + 1)..].TrimStart(); + if (!ParseItemName(itemName, localizationStrings)) + InvalidLines.Add($"Unknown Item: {itemName}"); + line = line[..itemStart]; + } + + // Remainder should be [Ability] + var abilityEnd = line.IndexOf(']'); + if (abilityEnd == -1 || line.Length == 1) // '[' should be present if ']' is; length check. + { + InvalidLines.Add($"Invalid Ability declaration: {line}"); + return; // invalid line + } + + var abilityName = line[1..abilityEnd].Trim(); + var abilityIndex = StringUtil.FindIndexIgnoreCase(localizationStrings.abilitylist, abilityName); + if (abilityIndex < 0) + { + InvalidLines.Add($"Unknown Ability: {abilityName}"); + return; // invalid line + } + Ability = abilityIndex; + } + + private bool ParseEntry(BattleTemplateToken token, ReadOnlySpan value, BattleTemplateLocalization localization) => token switch + { + BattleTemplateToken.Ability => (Ability = StringUtil.FindIndexIgnoreCase(localization.Strings.abilitylist, value)) >= 0, + BattleTemplateToken.Nature => (Nature = (Nature)StringUtil.FindIndexIgnoreCase(localization.Strings.natures, value)).IsFixed(), + BattleTemplateToken.Shiny => Shiny = true, + BattleTemplateToken.Gigantamax => CanGigantamax = true, + BattleTemplateToken.HeldItem => ParseItemName(value, localization.Strings), + BattleTemplateToken.Nickname => ParseNickname(value), + BattleTemplateToken.Gender => ParseGender(value, localization.Config), + BattleTemplateToken.Friendship => ParseFriendship(value), + BattleTemplateToken.EVs => ParseLineEVs(value, localization), + BattleTemplateToken.IVs => ParseLineIVs(value, localization.Config), + BattleTemplateToken.Level => ParseLevel(value), + BattleTemplateToken.DynamaxLevel => ParseDynamax(value), + BattleTemplateToken.TeraType => ParseTeraType(value, localization.Strings.types), + _ => false, + }; + + private bool ParseNickname(ReadOnlySpan value) + { + if (value.Length == 0) + return false; + // ignore length, but generally should be <= the Context's max length + Nickname = value.ToString(); + return true; + } + + private bool ParseGender(ReadOnlySpan value, BattleTemplateConfig cfg) + { + if (value.Equals(cfg.Male, StringComparison.OrdinalIgnoreCase)) + { + Gender = EntityGender.Male; + return true; + } + if (value.Equals(cfg.Female, StringComparison.OrdinalIgnoreCase)) + { + Gender = EntityGender.Female; + return true; + } + return false; + } + + private bool ParseLevel(ReadOnlySpan value) + { + if (!byte.TryParse(value.Trim(), out var val)) + return false; + if ((uint)val is 0 or > 100) + return false; + Level = val; + return true; + } + + private bool ParseFriendship(ReadOnlySpan value) + { + if (!byte.TryParse(value.Trim(), out var val)) + return false; + Friendship = val; + return true; + } + + private bool ParseDynamax(ReadOnlySpan value) + { + Context = EntityContext.Gen8; + var val = Util.ToInt32(value); + if ((uint)val > 10) + return false; + DynamaxLevel = (byte)val; + return true; + } + + private bool ParseTeraType(ReadOnlySpan value, ReadOnlySpan types) + { + Context = EntityContext.Gen9; + var val = StringUtil.FindIndexIgnoreCase(types, value); + if (val < 0) + return false; + if (val == TeraTypeUtil.StellarTypeDisplayStringIndex) + val = TeraTypeUtil.Stellar; + TeraType = (MoveType)val; + return true; + } + + /// + /// Gets the standard Text representation of the set details. + /// + public string Text => GetText(BattleTemplateExportSettings.Showdown); + + /// + /// Language code + public string GetText(string language = DefaultLanguage) => GetText(new BattleTemplateExportSettings(language)); + + /// + /// Language ID + public string GetText(LanguageID language) => GetText(new BattleTemplateExportSettings(language)); + + /// + /// Export settings + public string GetText(in BattleTemplateExportSettings settings) + { + if (Species is 0 or > MAX_SPECIES) + return string.Empty; + + var result = GetSetLines(settings); + return string.Join(Environment.NewLine, result); + } + + /// + public List GetSetLines(string language = DefaultLanguage) => GetSetLines(new BattleTemplateExportSettings(language)); + + /// + /// Gets all lines comprising the exported set details. + /// + /// Export settings + /// List of lines comprising the set + public List GetSetLines(in BattleTemplateExportSettings settings) + { + var result = new List(DefaultListAllocation); + if (settings.Order.Length == 0) + return result; + GetSetLines(result, settings); + return result; + } + + /// + public void GetSetLines(List result, in BattleTemplateExportSettings settings) + { + var tokens = settings.Order; + foreach (var token in tokens) + PushToken(token, result, settings); + } + + private string GetStringFirstLine(string form, in BattleTemplateExportSettings settings) + { + var strings = settings.Localization.Strings; + var speciesList = strings.specieslist; + if (Species >= speciesList.Length) + return string.Empty; // invalid species + + string specForm = speciesList[Species]; + var gender = Gender; + if (form.Length != 0) + { + specForm += $"-{form.Replace("Mega ", "Mega-")}"; + } + else if (Species == (int)NidoranM) + { + specForm = specForm.Replace("♂", "-M"); + if (gender != EntityGender.Female) + gender = null; + } + else if (Species == (int)NidoranF) + { + specForm = specForm.Replace("♀", "-F"); + if (gender != EntityGender.Male) + gender = null; + } + + var nickname = Nickname; + if (settings.IsTokenInExport(BattleTemplateToken.Nickname)) + nickname = string.Empty; // omit nickname if not in export + + var result = GetSpeciesNickname(specForm, nickname, Species, Context); + + // Append Gender if not default/random. + if (gender < EntityGender.Genderless && !settings.IsTokenInExport(BattleTemplateToken.Gender)) + { + if (gender is 0) + result += $" {FirstLineMale}"; + else if (gender is 1) + result += $" {FirstLineFemale}"; + } + + // Append item if specified. + if (HeldItem > 0 && !settings.IsTokenInExport(BattleTemplateToken.HeldItem) && !settings.IsTokenInExport(BattleTemplateToken.AbilityHeldItem)) + { + var items = strings.GetItemStrings(Context); + if ((uint)HeldItem < items.Length) + result += $" {ItemSplit} {items[HeldItem]}"; + } + return result; + } + + private static string GetSpeciesNickname(string specForm, string nickname, ushort species, EntityContext context) + { + if (nickname.Length == 0 || nickname == specForm) + return specForm; + bool isNicknamed = SpeciesName.IsNicknamedAnyLanguage(species, nickname, context.Generation()); + if (!isNicknamed) + return specForm; + return $"{nickname} ({specForm})"; + } + + private void PushToken(BattleTemplateToken token, List result, in BattleTemplateExportSettings settings) + { + var cfg = settings.Localization.Config; + var strings = settings.Localization.Strings; + + switch (token) + { + // Core + case BattleTemplateToken.FirstLine: + var form = ShowdownParsing.GetShowdownFormName(Species, FormName); + result.Add(GetStringFirstLine(form, settings)); + break; + case BattleTemplateToken.Ability when (uint)Ability < strings.Ability.Count: + result.Add(cfg.Push(BattleTemplateToken.Ability, strings.Ability[Ability])); + break; + case BattleTemplateToken.Nature when (uint)Nature < strings.Natures.Count: + result.Add(cfg.Push(token, strings.Natures[(byte)Nature])); + break; + + case BattleTemplateToken.Moves: + GetStringMoves(result, settings); + break; + + // Stats + case BattleTemplateToken.Level when Level != 100: + result.Add(cfg.Push(token, Level)); + break; + case BattleTemplateToken.Friendship when Friendship != 255: + result.Add(cfg.Push(token, Friendship)); + break; + case BattleTemplateToken.IVs: + var maxIV = Context.Generation() < 3 ? 15 : 31; + if (!IVs.AsSpan().ContainsAnyExcept(maxIV)) + break; // skip if all IVs are maxed + var nameIVs = cfg.GetStatDisplay(settings.StatsIVs); + var ivs = GetStringStats(IVs, maxIV, nameIVs); + if (ivs.Length != 0) + result.Add(cfg.Push(BattleTemplateToken.IVs, ivs)); + break; + + // EVs + case BattleTemplateToken.EVsWithNature: + case BattleTemplateToken.EVsAppendNature: + case BattleTemplateToken.EVs when EVs.AsSpan().ContainsAnyExcept(0): + AddEVs(result, settings, token); + break; + + // Boolean + case BattleTemplateToken.Shiny when Shiny: + result.Add(cfg.Push(token)); + break; + + // Gen8 + case BattleTemplateToken.DynamaxLevel when Context == EntityContext.Gen8 && DynamaxLevel != 10: + result.Add(cfg.Push(token, DynamaxLevel)); + break; + case BattleTemplateToken.Gigantamax when Context == EntityContext.Gen8 && CanGigantamax: + result.Add(cfg.Push(token)); + break; + + // Gen9 + case BattleTemplateToken.TeraType when Context == EntityContext.Gen9 && TeraType != MoveType.Any: + if ((uint)TeraType <= TeraTypeUtil.MaxType) // Fairy + result.Add(cfg.Push(BattleTemplateToken.TeraType, strings.Types[(int)TeraType])); + else if ((uint)TeraType == TeraTypeUtil.Stellar) + result.Add(cfg.Push(BattleTemplateToken.TeraType, strings.Types[TeraTypeUtil.StellarTypeDisplayStringIndex])); + break; + + // Edge Cases + case BattleTemplateToken.HeldItem when HeldItem > 0: + var itemNames = strings.GetItemStrings(Context); + if ((uint)HeldItem < itemNames.Length) + result.Add(cfg.Push(token, itemNames[HeldItem])); + break; + case BattleTemplateToken.Nickname when !string.IsNullOrWhiteSpace(Nickname): + result.Add(cfg.Push(token, Nickname)); + break; + case BattleTemplateToken.Gender when Gender != EntityGender.Genderless: + result.Add(cfg.Push(token, Gender == 0 ? cfg.Male : cfg.Female)); + break; + + case BattleTemplateToken.AbilityHeldItem when Ability >= 0 || HeldItem > 0: + result.Add(GetAbilityHeldItem(strings, Ability, HeldItem, Context)); + break; + } + } + + private void AddEVs(List result, in BattleTemplateExportSettings settings, BattleTemplateToken token) + { + var cfg = settings.Localization.Config; + var nameEVs = cfg.GetStatDisplay(settings.StatsEVs); + var line = token switch + { + BattleTemplateToken.EVsWithNature => GetStringStatsNatureAmp(EVs, 0, nameEVs, Nature), + BattleTemplateToken.EVsAppendNature => GetStringStatsNatureAmp(EVs, 0, nameEVs, Nature), + _ => GetStringStats(EVs, 0, nameEVs), + }; + if (token is BattleTemplateToken.EVsAppendNature && Nature.IsFixed()) + line += $" ({settings.Localization.Strings.natures[(int)Nature]})"; + result.Add(cfg.Push(BattleTemplateToken.EVs, line)); + } + + private static string GetAbilityHeldItem(GameStrings strings, int ability, int item, EntityContext context) + { + var abilityNames = strings.abilitylist; + + if ((uint)ability >= abilityNames.Length) + ability = 0; // invalid ability + var abilityName = abilityNames[ability]; + + var itemNames = strings.GetItemStrings(context); + if ((uint)item >= itemNames.Length) + item = 0; // invalid item + var itemName = itemNames[item]; + + if (ability <= 0) + return $"{ItemSplit} {itemName}"; + if (item <= 0) + return $"[{abilityName}]"; + return $"[{abilityName}] {ItemSplit} {itemName}"; + } + + /// + /// Appends the nature amplification to the stat values, if not a neutral nature. + public static string GetStringStatsNatureAmp(ReadOnlySpan stats, T ignoreValue, StatDisplayConfig statNames, Nature nature) where T : IEquatable + { + var (plus, minus) = NatureAmp.GetNatureModification(nature); + if (plus == minus) + return GetStringStats(stats, ignoreValue, statNames); // neutral nature won't appear any different + + // Shift as HP is not affected by nature. + plus++; + minus++; + + var count = stats.Length; + if (!statNames.AlwaysShow) + { + for (int i = 0; i < stats.Length; i++) + { + if (stats[i].Equals(ignoreValue) && i != plus && i != minus) + count--; // ignore unused stats + } + } + if (count == 0) + return string.Empty; + + var result = new StringBuilder(); + int ctr = 0; + for (int i = 0; i < stats.Length; i++) + { + var statIndex = GetStatIndexStored(i); + var statValue = stats[statIndex]; + + var hideValue = statValue.Equals(ignoreValue) && !statNames.AlwaysShow; + if (hideValue && statIndex != plus && statIndex != minus) + continue; // ignore unused stats + var amp = statIndex == plus ? "+" : statIndex == minus ? "-" : string.Empty; + if (ctr++ != 0) + result.Append(statNames.Separator); + statNames.Format(result, i, statValue, amp, hideValue); + } + return result.ToString(); + } + + /// + /// Gets the string representation of the stats. + /// + /// Stats to display + /// Value to ignore + /// Stat names to use + public static string GetStringStats(ReadOnlySpan stats, T ignoreValue, StatDisplayConfig statNames) where T : IEquatable + { + var count = stats.Length; + if (!statNames.AlwaysShow) + { + foreach (var stat in stats) + { + if (stat.Equals(ignoreValue)) + count--; // ignore unused stats + } + } + if (count == 0) + return ""; + + var result = new StringBuilder(); + int ctr = 0; + for (int i = 0; i < stats.Length; i++) + { + var statIndex = GetStatIndexStored(i); + var statValue = stats[statIndex]; + if (statValue.Equals(ignoreValue) && !statNames.AlwaysShow) + continue; + if (ctr++ != 0) + result.Append(statNames.Separator); + statNames.Format(result, i, statValue); + } + return result.ToString(); + } + + private void GetStringMoves(List result, in BattleTemplateExportSettings settings) + { + var strings = settings.Localization.Strings; + var moveNames = strings.movelist; + var style = settings.Moves; + var prefixes = BattleTemplateConfig.GetMoveDisplay(style); + + var added = 0; + for (var i = 0; i < Moves.Length; i++) + { + var move = Moves[i]; + if (move == 0 && !(style is MoveDisplayStyle.Directional && added != 0)) + continue; + if (move >= moveNames.Length) + continue; + var moveName = moveNames[move]; + + string line; + if (move != (int)Move.HiddenPower || HiddenPowerType == -1) + { + line = $"{prefixes[i]} {moveName}"; + } + else + { + var type = 1 + HiddenPowerType; // skip Normal + var typeName = strings.Types[type]; + line = $"{prefixes[i]} {moveName} [{typeName}]"; + } + + result.Add(line); + added++; + } + } + + private static int GetStatIndexStored(int displayIndex) => displayIndex switch + { + 3 => 4, + 4 => 5, + 5 => 3, + _ => displayIndex, + }; + + /// + /// Forces some properties to indicate the set for future display values. + /// + /// PKM to convert to string + public void InterpretAsPreview(PKM pk) + { + if (pk.Format <= 2) // Nature preview from IVs + Nature = Experience.GetNatureVC(pk.EXP); + } + + /// + /// Converts the data into an importable set format for Pokémon Showdown. + /// + /// PKM to convert to string + /// Localization to parse the lines with. + /// New ShowdownSet object representing the input + public ShowdownSet(PKM pk, BattleTemplateLocalization? localization = null) + { + localization ??= DefaultStrings; + if (pk.Species == 0) + return; + + Context = pk.Context; + + Nickname = pk.Nickname; + Species = pk.Species; + HeldItem = pk.HeldItem; + Ability = pk.Ability; + pk.GetEVs(EVs); + pk.GetIVs(IVs); + + var moves = Moves.AsSpan(); + pk.GetMoves(moves); + if (moves.Contains((ushort)Move.HiddenPower)) + HiddenPowerType = (sbyte)HiddenPower.GetType(IVs, Context); + + Nature = pk.StatNature; + Gender = pk.Gender < 2 ? pk.Gender : (byte)2; + Friendship = pk.CurrentFriendship; + Level = pk.CurrentLevel; + Shiny = pk.IsShiny; + + if (pk is PK8 g) // Only set Gigantamax if it is a PK8 + { + CanGigantamax = g.CanGigantamax; + DynamaxLevel = g.DynamaxLevel; + } + + if (pk is ITeraType t) + TeraType = t.TeraType; + if (pk is IHyperTrain h) + { + for (int i = 0; i < 6; i++) + { + if (h.IsHyperTrained(i)) + IVs[i] = pk.MaxIV; + } + } + + FormName = ShowdownParsing.GetStringFromForm(Form = pk.Form, localization.Strings, Species, Context); + } + + private void ParseFirstLine(ReadOnlySpan first, GameStrings strings) + { + int itemSplit = first.IndexOf(ItemSplit); + if (itemSplit != -1) + { + var itemName = first[(itemSplit + 1)..].TrimStart(); + var speciesName = first[..itemSplit].TrimEnd(); + + if (!ParseItemName(itemName, strings)) + InvalidLines.Add($"Unknown Item: {itemName}"); + ParseFirstLineNoItem(speciesName, strings); + } + else + { + ParseFirstLineNoItem(first, strings); + } + } + + private bool ParseItemName(ReadOnlySpan itemName, GameStrings strings) + { + if (TryGetItem(itemName, strings, Context)) + return true; + if (TryGetItem(itemName, strings, EntityContext.Gen3)) + return true; + if (TryGetItem(itemName, strings, EntityContext.Gen2)) + return true; + return false; + } + + private bool TryGetItem(ReadOnlySpan itemName, GameStrings strings, EntityContext context) + { + var items = strings.GetItemStrings(context); + var item = StringUtil.FindIndexIgnoreCase(items, itemName); + if (item < 0) + return false; + Context = context; + HeldItem = item; + return true; + } + + private const string FirstLineMale = "(M)"; + private const string FirstLineFemale = "(F)"; + + private void ParseFirstLineNoItem(ReadOnlySpan line, GameStrings strings) + { + // Gender Detection + if (line.EndsWith(FirstLineMale, StringComparison.Ordinal)) + { + line = line[..^3].TrimEnd(); + Gender = 0; + } + else if (line.EndsWith(FirstLineFemale, StringComparison.Ordinal)) + { + line = line[..^3].TrimEnd(); + Gender = 1; + } + + // Nickname Detection + if (line.IndexOf('(') != -1 && line.IndexOf(')') != -1) + ParseSpeciesNickname(line, strings); + else + ParseSpeciesForm(line, strings); + } + + private const string Gmax = "-Gmax"; + + /// + /// Average count of lines in a Showdown set. + /// + /// Optimization to skip 1 size update allocation (from 4). Usually first-line, ability, (ivs, evs, shiny, level) 4*moves + public const int DefaultListAllocation = 8; + + private bool ParseSpeciesForm(ReadOnlySpan speciesLine, GameStrings strings) + { + speciesLine = speciesLine.Trim(); + if (speciesLine.Length == 0) + return false; + + if (speciesLine.EndsWith(Gmax, StringComparison.Ordinal)) + { + CanGigantamax = true; + speciesLine = speciesLine[..^Gmax.Length]; + } + + var speciesIndex = StringUtil.FindIndexIgnoreCase(strings.specieslist, speciesLine); + if (speciesIndex > 0) + { + // success, nothing else ! + Species = (ushort)speciesIndex; + return true; + } + + // Form string present. + int end = speciesLine.IndexOf('-'); + if (end < 0) + return false; + + speciesIndex = StringUtil.FindIndexIgnoreCase(strings.specieslist, speciesLine[..end]); + if (speciesIndex > 0) + { + Species = (ushort)speciesIndex; + FormName = speciesLine[(end + 1)..].ToString(); + return true; + } + + // failure to parse, check edge cases + foreach (var e in DashedSpecies) + { + var sn = strings.Species[e]; + if (!speciesLine.StartsWith(sn.Replace("♂", "-M").Replace("♀", "-F"), StringComparison.Ordinal)) + continue; + Species = e; + FormName = speciesLine[sn.Length..].ToString(); + return true; + } + + // Version Megas + end = speciesLine[Math.Max(0, end - 1)..].LastIndexOf('-'); + if (end < 0) + return false; + + speciesIndex = StringUtil.FindIndexIgnoreCase(strings.specieslist, speciesLine[..end]); + if (speciesIndex > 0) + { + Species = (ushort)speciesIndex; + FormName = speciesLine[(end + 1)..].ToString(); + return true; + } + return false; + } + + private void ParseSpeciesNickname(ReadOnlySpan line, GameStrings strings) + { + // Entering into this method requires both ( and ) to be present within the input line. + int index = line.LastIndexOf('('); + ReadOnlySpan species; + ReadOnlySpan nickname; + if (index > 1) // parenthesis value after: Nickname (Species), correct. + { + nickname = line[..index].TrimEnd(); + species = line[(index + 1)..]; + if (species.Length != 0 && species[^1] == ')') + species = species[..^1]; + } + else // parenthesis value before: (Species) Nickname, incorrect + { + int start = index + 1; + int end = line.LastIndexOf(')'); + var tmp = line[start..end]; + if (end < line.Length - 2) + { + nickname = line[(end + 2)..]; + species = tmp; + } + else // (Species), or garbage + { + species = tmp; + nickname = []; + } + } + + if (ParseSpeciesForm(species, strings)) + Nickname = nickname.ToString(); + else if (ParseSpeciesForm(nickname, strings)) + Nickname = species.ToString(); + } + + private ReadOnlySpan ParseLineMove(ReadOnlySpan line, GameStrings strings) + { + line = line[1..].TrimStart(); + + // Discard any multi-move options; keep only first. + var option = line.IndexOf('/'); + if (option != -1) + line = line[..option].TrimEnd(); + + var moveString = line; + + var hiddenPowerName = strings.Move[(int)Move.HiddenPower]; + if (!moveString.StartsWith(hiddenPowerName, StringComparison.OrdinalIgnoreCase)) + return moveString; // regular move + + if (moveString.Length == hiddenPowerName.Length) + return hiddenPowerName; + + // Defined Hidden Power + var type = GetHiddenPowerType(moveString[(hiddenPowerName.Length + 1)..]); + var types = strings.types.AsSpan(1, HiddenPower.TypeCount); + int hpVal = StringUtil.FindIndexIgnoreCase(types, type); // Get HP Type + if (hpVal == -1) + return hiddenPowerName; + + HiddenPowerType = (sbyte)hpVal; + var maxIV = Context.Generation() < 3 ? 15 : 31; + if (IVs.AsSpan().ContainsAnyExcept(maxIV)) + { + if (!HiddenPower.SetIVsForType(hpVal, IVs, Context)) + InvalidLines.Add($"Invalid IVs for Hidden Power Type: {type}"); + } + else if (hpVal >= 0) + { + HiddenPower.SetIVs(hpVal, IVs, Context); // Alter IVs + } + else + { + InvalidLines.Add($"Invalid Hidden Power Type: {type}"); + } + return hiddenPowerName; + } + + private static ReadOnlySpan GetHiddenPowerType(ReadOnlySpan line) + { + var type = line.Trim(); + if (type.Length == 0) + return type; + + // Allow for both (Type) and [Type] + if (type[0] == '(' && type[^1] == ')') + return type[1..^1].Trim(); + if (type[0] == '[' && type[^1] == ']') + return type[1..^1].Trim(); + + return type; + } + + private bool ParseLineEVs(ReadOnlySpan line, BattleTemplateLocalization localization) + { + // If nature is present, parse it first. + var nature = line.IndexOf('('); + if (nature != -1) + { + var natureName = line[(nature + 1)..]; + var end = natureName.IndexOf(')'); + if (end == -1) + { + InvalidLines.Add($"Invalid EV nature: {natureName}"); + return false; // invalid line + } + natureName = natureName[..end].Trim(); + var natureIndex = StringUtil.FindIndexIgnoreCase(localization.Strings.natures, natureName); + if (natureIndex == -1) + { + InvalidLines.Add($"Invalid EV nature: {natureName}"); + return false; // invalid line + } + + if (Nature != Nature.Random) + InvalidLines.Add($"EV nature ignored, specified previously: {natureName}"); + else + Nature = (Nature)natureIndex; + + line = line[..nature].TrimEnd(); + } + + var result = localization.Config.TryParseStats(line, EVs); + var success = result.IsParsedAllStats; + if (!result.IsParseClean) + InvalidLines.Add($"Invalid EVs: {line}"); + + if (result is { HasAmps: false }) + return success; + if (Nature != Nature.Random) + { + InvalidLines.Add($"EV nature +/- ignored, specified previously: {line}"); + return false; + } + + success &= AdjustNature(result.Plus, result.Minus); + return success; + } + + private bool ParseLineIVs(ReadOnlySpan line, BattleTemplateConfig config) + { + // Parse stats, with unspecified name representation (try all). + var result = config.TryParseStats(line, IVs); + var success = result.IsParsedAllStats; + if (!result.IsParseClean) + InvalidLines.Add($"Invalid IVs: {line}"); + return success; + } + + private bool AdjustNature(int plus, int minus) + { + if (plus == -1) + InvalidLines.Add("Invalid Nature adjustment, missing plus stat."); + if (minus == -1) + InvalidLines.Add("Invalid Nature adjustment, missing minus stat."); + else + Nature = NatureAmp.CreateNatureFromAmps(plus, minus); + return true; + } +} diff --git a/PKHeX.Core/Editing/Showdown/ShowdownTeam.cs b/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownTeam.cs similarity index 98% rename from PKHeX.Core/Editing/Showdown/ShowdownTeam.cs rename to PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownTeam.cs index 51707f26b..90a8e0a2f 100644 --- a/PKHeX.Core/Editing/Showdown/ShowdownTeam.cs +++ b/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownTeam.cs @@ -7,6 +7,9 @@ namespace PKHeX.Core; /// /// Logic for retrieving Showdown teams from URLs. /// +/// +/// +/// public static class ShowdownTeam { /// @@ -82,7 +85,9 @@ public static bool IsURL(ReadOnlySpan text, [NotNullWhen(true)] out string text = text.Trim(); if (text.StartsWith("https://psim.us/t/") || // short link text.StartsWith("https://teams.pokemonshowdown.com/")) + { return TryCheckWeb(text, out url); + } if (text.StartsWith("https://play.pokemonshowdown.com/api/getteam?teamid=")) return TryCheckAPI(text, out url); diff --git a/PKHeX.Core/Editing/BattleTemplate/StatDisplayConfig.cs b/PKHeX.Core/Editing/BattleTemplate/StatDisplayConfig.cs new file mode 100644 index 000000000..71a692eb2 --- /dev/null +++ b/PKHeX.Core/Editing/BattleTemplate/StatDisplayConfig.cs @@ -0,0 +1,338 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Text; + +namespace PKHeX.Core; + +/// +/// Configuration for displaying stats. +/// +[TypeConverter(typeof(ExpandableObjectConverter))] +public sealed class StatDisplayConfig +{ + /// + /// Stat names are displayed without localization; H:X A:X B:X C:X D:X S:X + /// + public static readonly StatDisplayConfig HABCDS = new() + { + Names = ["H", "A", "B", "C", "D", "S"], + Separator = " ", + ValueGap = ":", + IsLeft = true, + AlwaysShow = true, + }; + + /// + /// Stat names are displayed without localization; X/X/X/X/X/X + /// + /// + /// Same as but with no leading zeroes. + /// + public static readonly StatDisplayConfig Raw = new() + { + Names = [], + Separator = "/", + ValueGap = "", + AlwaysShow = true, + }; + + /// + /// Stat names are displayed without localization; XX/XX/XX/XX/XX/XX + /// + /// + /// Same as but with 2 digits (leading zeroes). + /// + public static readonly StatDisplayConfig Raw00 = new() + { + Names = [], + Separator = "/", + ValueGap = "", + AlwaysShow = true, + MinimumDigits = 2, + }; + + /// + /// List of stat display styles that are commonly used and not specific to a localization. + /// + public static List Custom { get; } = [HABCDS, Raw]; // Raw00 parses equivalent to Raw + + /// List of stat names to display + public required string[] Names { get; init; } + + /// Separator between each stat+value declaration + public string Separator { get; init; } = " / "; + + /// Separator between the stat name and value + public string ValueGap { get; init; } = " "; + + /// true if the text is displayed on the left side of the value + public bool IsLeft { get; init; } + + /// true if the stat is always shown, even if the value is default + public bool AlwaysShow { get; init; } + + /// Minimum number of digits to show for the stat value. + public int MinimumDigits { get; init; } + + /// + /// Gets the index of the displayed stat name (in visual order) via a case-insensitive search. + /// + /// Stat name, trimmed. + /// -1 if not found, otherwise the index of the stat name. + public int GetStatIndex(ReadOnlySpan stat) + { + for (int i = 0; i < Names.Length; i++) + { + if (stat.Equals(Names[i], StringComparison.OrdinalIgnoreCase)) + return i; + } + return -1; + } + + public override string ToString() => string.Join(Separator, Names); + + /// + /// Formats a stat value into a string builder. + /// + /// Result string builder + /// Display index of the stat + /// Stat value + /// Optional suffix for the value, to display a stat amplification request + /// true to skip the value, only displaying the stat name and amplification (if provided) + public void Format(StringBuilder sb, int statIndex, T statValue, ReadOnlySpan valueSuffix = default, bool skipValue = false) + { + var statName = statIndex < Names.Length ? Names[statIndex] : ""; + var length = GetStatSize(statName, statValue, valueSuffix, skipValue); + + if (sb.Length + length > sb.Capacity) + sb.EnsureCapacity(sb.Length + length); + Append(sb, statName, statValue, valueSuffix, skipValue); + } + + private void Append(StringBuilder sb, ReadOnlySpan statName, T statValue, ReadOnlySpan valueSuffix, bool skipValue) + { + int start = sb.Length; + + if (!skipValue) + { + sb.Append(statValue); + var end = sb.Length; + if (end < MinimumDigits) + sb.Insert(start, "0", MinimumDigits - end); + } + sb.Append(valueSuffix); + + if (IsLeft) + { + sb.Insert(start, ValueGap); + sb.Insert(start, statName); + } + else + { + sb.Append(ValueGap); + sb.Append(statName); + } + } + + private int GetStatSize(ReadOnlySpan statName, T statValue, ReadOnlySpan valueSuffix, bool skipValue) + { + var length = statName.Length + ValueGap.Length + valueSuffix.Length; + if (!skipValue) + length += (int)Math.Max(MinimumDigits, Math.Floor(Math.Log10(Convert.ToDouble(statValue)) + 1)); + return length; + } + + /// + /// Gets the separator character used for parsing. + /// + private char GetSeparatorParse() => GetSeparatorParse(Separator); + + private static char GetSeparatorParse(ReadOnlySpan sep) => sep.Length switch + { + 0 => ' ', + 1 => sep[0], + _ => sep.Trim()[0] + }; + + /// + /// Imports a list of stats from a string. + /// + /// Input string + /// Result storage + /// Parse result + public StatParseResult TryParse(ReadOnlySpan message, Span result) + { + var separator = GetSeparatorParse(); + var gap = ValueGap.AsSpan().Trim(); + // If stats are not labeled, parse with the straightforward parser. + if (Names.Length == 0) + return TryParseRaw(message, result, separator); + else if (IsLeft) + return TryParseIsLeft(message, result, separator, gap); + else + return TryParseRight(message, result, separator, gap); + } + + private StatParseResult TryParseIsLeft(ReadOnlySpan message, Span result, char separator, ReadOnlySpan valueGap) + { + var rec = new StatParseResult(); + + for (int i = 0; i < Names.Length; i++) + { + if (message.Length == 0) + break; + + var statName = Names[i]; + var index = message.IndexOf(statName, StringComparison.OrdinalIgnoreCase); + if (index == -1) + continue; + + if (index != 0) + rec.MarkDirty(); // We have something before our stat name, so it isn't clean. + + message = message[statName.Length..].TrimStart(); + if (valueGap.Length > 0 && message.StartsWith(valueGap)) + message = message[valueGap.Length..].TrimStart(); + + var value = message; + + var indexSeparator = value.IndexOf(separator); + if (indexSeparator != -1) + value = value[..indexSeparator].Trim(); + else + message = default; // everything remaining belongs in the value we are going to parse. + + if (value.Length != 0) + { + var amped = TryPeekAmp(ref value, ref rec, i); + if (amped && value.Length == 0) + rec.MarkParsed(index); + else + TryParse(result, ref rec, value, i); + } + + if (indexSeparator != -1) + message = message[(indexSeparator+1)..].TrimStart(); + else + break; + } + + if (!message.IsWhiteSpace()) // shouldn't be anything left in the message to parse + rec.MarkDirty(); + rec.FinishParse(Names.Length); + return rec; + } + + private StatParseResult TryParseRight(ReadOnlySpan message, Span result, char separator, ReadOnlySpan valueGap) + { + var rec = new StatParseResult(); + + for (int i = 0; i < Names.Length; i++) + { + if (message.Length == 0) + break; + + var statName = Names[i]; + var index = message.IndexOf(statName, StringComparison.OrdinalIgnoreCase); + if (index == -1) + continue; + + var value = message[..index].Trim(); + var indexSeparator = value.LastIndexOf(separator); + if (indexSeparator != -1) + { + rec.MarkDirty(); // We have something before our stat name, so it isn't clean. + value = value[(indexSeparator + 1)..].TrimStart(); + } + + if (valueGap.Length > 0 && value.EndsWith(valueGap)) + value = value[..^valueGap.Length]; + + if (value.Length != 0) + { + var amped = TryPeekAmp(ref value, ref rec, i); + if (amped && value.Length == 0) + rec.MarkParsed(index); + else + TryParse(result, ref rec, value, i); + } + + message = message[(index + statName.Length)..].TrimStart(); + if (message.StartsWith(separator)) + message = message[1..].TrimStart(); + } + + if (!message.IsWhiteSpace()) // shouldn't be anything left in the message to parse + rec.MarkDirty(); + rec.FinishParse(Names.Length); + return rec; + } + + /// + /// Parses a raw stat string. + /// + /// Input string + /// Output storage + /// Separator character + public static StatParseResult TryParseRaw(ReadOnlySpan message, Span result, char separator) + { + var rec = new StatParseResult(); + + // Expect the message to contain all entries of `result` separated by the separator and an arbitrary amount of spaces permitted. + // The message is split by the separator, and each part is trimmed of whitespace. + for (int i = 0; i < result.Length; i++) + { + var index = message.IndexOf(separator); + + var value = index != -1 ? message[..index].Trim() : message.Trim(); + message = message[(index+1)..].TrimStart(); + + if (value.Length == 0) + { + rec.MarkDirty(); // Something is wrong with the message, as we have an empty stat. + continue; // Maybe it's a duplicate separator; keep parsing and hope that the required amount are parsed. + } + + var amped = TryPeekAmp(ref value, ref rec, i); + if (amped && value.Length == 0) + rec.MarkParsed(index); + else + TryParse(result, ref rec, value, i); + } + + if (!message.IsWhiteSpace()) // shouldn't be anything left in the message to parse + rec.MarkDirty(); + rec.FinishParseOnly(result.Length); + return rec; + } + + private static void TryParse(Span result, ref StatParseResult rec, ReadOnlySpan value, int statIndex) + { + if (!int.TryParse(value, out var stat) || stat < 0) + { + rec.MarkDirty(); + return; + } + result[statIndex] = stat; + rec.MarkParsed(statIndex); + } + + private static bool TryPeekAmp(ref ReadOnlySpan value, ref StatParseResult rec, int statIndex) + { + var last = value[^1]; + if (last == '+') + { + rec.Plus = (sbyte)statIndex; + value = value[..^1].TrimEnd(); + return true; + } + if (last == '-') + { + rec.Minus = (sbyte)statIndex; + value = value[..^1].TrimEnd(); + return true; + } + return false; + } +} diff --git a/PKHeX.Core/Editing/BattleTemplate/StatDisplayStyle.cs b/PKHeX.Core/Editing/BattleTemplate/StatDisplayStyle.cs new file mode 100644 index 000000000..29709657d --- /dev/null +++ b/PKHeX.Core/Editing/BattleTemplate/StatDisplayStyle.cs @@ -0,0 +1,37 @@ +namespace PKHeX.Core; + +/// +/// Style to display stat names. +/// +public enum StatDisplayStyle : sbyte +{ + Custom = -1, + + /// + /// Stat names are displayed in abbreviated (2-3 characters) localized text. + /// + Abbreviated, + + /// + /// Stat names are displayed in full localized text. + /// + Full, + + /// + /// Stat names are displayed as a single character. + /// + /// + /// This is the typical format used by the Japanese community; HABCDS. + /// + HABCDS, + + /// + /// Stat names are displayed without localization; X/X/X/X/X/X + /// + Raw, + + /// + /// Stat names are displayed without localization; XX/XX/XX/XX/XX/XX + /// + Raw00, +} diff --git a/PKHeX.Core/Editing/BattleTemplate/StatParseResult.cs b/PKHeX.Core/Editing/BattleTemplate/StatParseResult.cs new file mode 100644 index 000000000..3b32b968b --- /dev/null +++ b/PKHeX.Core/Editing/BattleTemplate/StatParseResult.cs @@ -0,0 +1,128 @@ +using System; + +namespace PKHeX.Core; + +/// +/// Value result object of parsing a stat string. +/// +public record struct StatParseResult() +{ + private const uint MaxStatCount = 6; // Number of stats in the game + private const sbyte NoStatAmp = -1; + + /// + /// Count of parsed stats. + /// + public byte CountParsed { get; private set; } = 0; // could potentially make this a computed value (popcnt), but it's not worth it + + /// + /// Indexes of parsed stats. + /// + public byte IndexesParsed { get; private set; } = 0; + + /// + /// Stat index of increased stat. + /// + public sbyte Plus { get; set; } = NoStatAmp; + + /// + /// Stat index of decreased stat. + /// + public sbyte Minus { get; set; } = NoStatAmp; + + /// + /// Indicates if the parsing was clean (no un-parsed text). + /// + public bool IsParseClean { get; private set; } = true; + + /// + /// Indicates if all stat indexes available were parsed. + /// + public bool IsParsedAllStats { get; private set; } = false; + + /// + /// Marks the stat index as parsed, and updates the count of parsed stats. + /// + /// Visual index of the stat to mark as parsed. + /// True if the stat had not been parsed before, false if it was already parsed. + public bool MarkParsed(int statIndex) + { + // Check if the stat index is valid (0-5) + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)statIndex, MaxStatCount); + if (WasParsed(statIndex)) + return false; + // Mark the stat index as parsed + IndexesParsed |= (byte)(1 << statIndex); + ++CountParsed; + return true; + } + + /// + /// Checks if the stat index was parsed. + /// + /// Visual index of the stat to check. + /// True if the stat was parsed, false otherwise. + public bool WasParsed(int statIndex) + { + // Check if the stat index is valid (0-5) + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)statIndex, MaxStatCount); + return (IndexesParsed & (1 << statIndex)) != 0; + } + + /// + /// Marks the parsing as finished, and updates the internal state to indicate if all stats were parsed. + /// + /// + /// This is used when not all stats are required to be parsed. + /// + /// + public void FinishParse(int expect) + { + if (CountParsed == 0 && !HasAmps) + MarkDirty(); + IsParsedAllStats = CountParsed == expect || IsParseClean; + } + + /// + /// Marks the parsing as finished, and updates the internal state to indicate if all stats were parsed. + /// + /// + /// This is used when a specific number of stats is expected. + /// + /// + public void FinishParseOnly(int expect) => IsParsedAllStats = CountParsed == expect; + + /// + /// Marks the parsing as dirty, indicating that the string was not a clean input string (user modified or the syntax doesn't match the spec). + /// + public void MarkDirty() => IsParseClean = false; + + /// + /// Indicates if any stat has any amplified (+/-) requested, indicative of nature. + /// + public bool HasAmps => Plus != NoStatAmp || Minus != NoStatAmp; + + /// + /// Reorders the speed stat to be in the middle of the stats. + /// + /// + /// Speed is visually represented as the last stat in the list, but it is actually the 3rd stat stored. + /// + public void TreatAmpsAsSpeedNotLast() + { + Plus = GetSpeedMiddleIndex(Plus); + Minus = GetSpeedMiddleIndex(Minus); + } + + // Move speed from index 5 to index 3, and the other stats down to account for HP not being boosted. + private static sbyte GetSpeedMiddleIndex(sbyte amp) => amp switch + { + 0 => -1, + 1 => 0, // Atk + 2 => 1, // Def + 3 => 3, // SpA + 4 => 4, // SpD + 5 => 2, // Spe + _ => amp, + }; +} diff --git a/PKHeX.Core/Editing/Showdown/ShowdownSet.cs b/PKHeX.Core/Editing/Showdown/ShowdownSet.cs deleted file mode 100644 index 69d7a352c..000000000 --- a/PKHeX.Core/Editing/Showdown/ShowdownSet.cs +++ /dev/null @@ -1,784 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using static PKHeX.Core.Species; - -namespace PKHeX.Core; - -/// -/// Logic for exporting and importing data in Pokémon Showdown's text format. -/// -public sealed class ShowdownSet : IBattleTemplate -{ - private static readonly string[] StatNames = ["HP", "Atk", "Def", "Spe", "SpA", "SpD"]; - private const string LineSplit = ": "; - private const string ItemSplit = " @ "; - private const int MAX_SPECIES = (int)MAX_COUNT - 1; - internal const string DefaultLanguage = GameLanguage.DefaultLanguage; - private static readonly GameStrings DefaultStrings = GameInfo.GetStrings(DefaultLanguage); - - private static ReadOnlySpan DashedSpecies => - [ - (int)NidoranF, (int)NidoranM, - (int)HoOh, - (int)Jangmoo, (int)Hakamoo, (int)Kommoo, - (int)TingLu, (int)ChienPao, (int)WoChien, (int)ChiYu, - ]; - - public ushort Species { get; private set; } - public EntityContext Context { get; private set; } = RecentTrainerCache.Context; - public string Nickname { get; private set; } = string.Empty; - public byte? Gender { get; private set; } - public int HeldItem { get; private set; } - public int Ability { get; private set; } = -1; - public byte Level { get; private set; } = 100; - public bool Shiny { get; private set; } - public byte Friendship { get; private set; } = 255; - public Nature Nature { get; private set; } = Nature.Random; - public string FormName { get; private set; } = string.Empty; - public byte Form { get; private set; } - public int[] EVs { get; } = [00, 00, 00, 00, 00, 00]; - public int[] IVs { get; } = [31, 31, 31, 31, 31, 31]; - public sbyte HiddenPowerType { get; private set; } = -1; - public MoveType TeraType { get; private set; } = MoveType.Any; - public ushort[] Moves { get; } = [0, 0, 0, 0]; - public bool CanGigantamax { get; private set; } - public byte DynamaxLevel { get; private set; } = 10; - - /// - /// Any lines that failed to be parsed. - /// - public readonly List InvalidLines = new(0); - - private GameStrings Strings { get; set; } = DefaultStrings; - - /// - /// Loads a new from the input string. - /// - /// Single-line string which will be split before loading. - public ShowdownSet(ReadOnlySpan input) => LoadLines(input.EnumerateLines()); - - /// - /// Loads a new from the input string. - /// - /// Enumerable list of lines. - public ShowdownSet(IEnumerable lines) => LoadLines(lines); - - private void LoadLines(SpanLineEnumerator lines) - { - ParseLines(lines); - SanitizeResult(); - } - - private void LoadLines(IEnumerable lines) - { - ParseLines(lines); - SanitizeResult(); - } - - private void SanitizeResult() - { - FormName = ShowdownParsing.SetShowdownFormName(Species, FormName, Ability); - Form = ShowdownParsing.GetFormFromString(FormName, Strings, Species, Context); - - // Handle edge case with fixed-gender forms. - if (Species is (int)Meowstic or (int)Indeedee or (int)Basculegion or (int)Oinkologne) - ReviseGenderedForms(); - } - - private void ReviseGenderedForms() - { - if (Gender == 1) // Recognized with (F) - { - FormName = "F"; - Form = 1; - } - else - { - FormName = Form == 1 ? "F" : "M"; - Gender = Form; - } - } - - private const int MaxMoveCount = 4; - - // Skip lines that are too short or too long. - // Longest line is ~74 (Gen2 EVs) - // Length permitted: 3-80 - // The shortest Pokémon name in Japanese is "ニ" (Ni) which is the name for the Pokémon, Nidoran♂ (male Nidoran). It has only one letter. - // We will handle this 1-2 letter edge case only if the line is the first line of the set, in the rare chance we are importing for a non-English language? - private const int MinLength = 3; - private const int MaxLength = 80; - private static bool IsLengthOutOfRange(ReadOnlySpan trim) => IsLengthOutOfRange(trim.Length); - private static bool IsLengthOutOfRange(int length) => (uint)(length - MinLength) > MaxLength - MinLength; - - private void ParseLines(SpanLineEnumerator lines) - { - int movectr = 0; - bool first = true; - foreach (var line in lines) - { - ReadOnlySpan trim = line.Trim(); - if (IsLengthOutOfRange(trim)) - { - // Try for other languages just in case. - if (first && trim.Length != 0) - { - ParseFirstLine(trim); - first = false; - continue; - } - InvalidLines.Add(line.ToString()); - continue; - } - - if (first) - { - ParseFirstLine(trim); - first = false; - continue; - } - if (ParseLine(trim, ref movectr)) - return; // End of moves, end of set data - } - } - - private void ParseLines(IEnumerable lines) - { - int movectr = 0; - bool first = true; - foreach (var line in lines) - { - ReadOnlySpan trim = line.Trim(); - if (IsLengthOutOfRange(trim)) - { - // Try for other languages just in case. - if (first && trim.Length != 0) - { - ParseFirstLine(trim); - first = false; - continue; - } - InvalidLines.Add(line); - continue; - } - - if (first) - { - ParseFirstLine(trim); - first = false; - continue; - } - if (ParseLine(trim, ref movectr)) - return; // End of moves, end of set data - } - } - - private bool ParseLine(ReadOnlySpan line, ref int movectr) - { - var moves = Moves.AsSpan(); - if (line[0] is '-' or '–') - { - var moveString = ParseLineMove(line); - int move = StringUtil.FindIndexIgnoreCase(Strings.movelist, moveString); - if (move < 0) - InvalidLines.Add($"Unknown Move: {moveString}"); - else if (moves.Contains((ushort)move)) - InvalidLines.Add($"Duplicate Move: {moveString}"); - else - moves[movectr++] = (ushort)move; - - return movectr == MaxMoveCount; - } - - if (movectr != 0) - return true; - - bool valid; - var split = line.IndexOf(LineSplit, StringComparison.Ordinal); - if (split == -1) - { - valid = ParseSingle(line); // Nature - } - else - { - var left = line[..split].Trim(); - var right = line[(split + LineSplit.Length)..].Trim(); - valid = ParseEntry(left, right); - } - - if (!valid) - InvalidLines.Add(line.ToString()); - return false; - } - - private bool ParseSingle(ReadOnlySpan identifier) - { - if (!identifier.EndsWith("Nature", StringComparison.OrdinalIgnoreCase)) - return false; - var firstSpace = identifier.IndexOf(' '); - if (firstSpace == -1) - return false; - var nature = identifier[..firstSpace]; - return (Nature = (Nature)StringUtil.FindIndexIgnoreCase(Strings.natures, nature)).IsFixed(); - } - - private bool ParseEntry(ReadOnlySpan identifier, ReadOnlySpan value) => identifier switch - { - "Ability" => (Ability = StringUtil.FindIndexIgnoreCase(Strings.abilitylist, value)) >= 0, - "Nature" => (Nature = (Nature)StringUtil.FindIndexIgnoreCase(Strings.natures , value)).IsFixed(), - "Shiny" => Shiny = StringUtil.IsMatchIgnoreCase("Yes", value), - "Gigantamax" => CanGigantamax = StringUtil.IsMatchIgnoreCase("Yes", value), - "Friendship" => ParseFriendship(value), - "EVs" => ParseLineEVs(value), - "IVs" => ParseLineIVs(value), - "Level" => ParseLevel(value), - "Dynamax Level" => ParseDynamax(value), - "Tera Type" => ParseTeraType(value), - _ => false, - }; - - private bool ParseLevel(ReadOnlySpan value) - { - if (!byte.TryParse(value.Trim(), out var val)) - return false; - if ((uint)val is 0 or > 100) - return false; - Level = val; - return true; - } - - private bool ParseFriendship(ReadOnlySpan value) - { - if (!byte.TryParse(value.Trim(), out var val)) - return false; - Friendship = val; - return true; - } - - private bool ParseDynamax(ReadOnlySpan value) - { - Context = EntityContext.Gen8; - var val = Util.ToInt32(value); - if ((uint)val > 10) - return false; - DynamaxLevel = (byte)val; - return true; - } - - private bool ParseTeraType(ReadOnlySpan value) - { - Context = EntityContext.Gen9; - var types = Strings.types; - var val = StringUtil.FindIndexIgnoreCase(types, value); - if (val < 0) - return false; - if (val == TeraTypeUtil.StellarTypeDisplayStringIndex) - val = TeraTypeUtil.Stellar; - TeraType = (MoveType)val; - return true; - } - - /// - /// Gets the standard Text representation of the set details. - /// - public string Text => GetText(); - - /// - /// Gets the localized Text representation of the set details. - /// - /// Language code - public string LocalizedText(string lang = DefaultLanguage) => LocalizedText(GameLanguage.GetLanguageIndex(lang)); - - /// - /// Gets the localized Text representation of the set details. - /// - /// Language ID - private string LocalizedText(int lang) - { - var strings = GameInfo.GetStrings(lang); - return GetText(strings); - } - - private string GetText(GameStrings? strings = null) - { - if (Species is 0 or > MAX_SPECIES) - return string.Empty; - - if (strings is not null) - Strings = strings; - - var result = GetSetLines(); - return string.Join(Environment.NewLine, result); - } - - public List GetSetLines() - { - var result = new List(); - - // First Line: Name, Nickname, Gender, Item - var form = ShowdownParsing.GetShowdownFormName(Species, FormName); - result.Add(GetStringFirstLine(form)); - - // IVs - var maxIV = Context.Generation() < 3 ? 15 : 31; - var ivs = GetStringStats(IVs, maxIV); - if (ivs.Length != 0) - result.Add($"IVs: {string.Join(" / ", ivs)}"); - - // EVs - var evs = GetStringStats(EVs, 0); - if (evs.Length != 0) - result.Add($"EVs: {string.Join(" / ", evs)}"); - - // Secondary Stats - if ((uint)Ability < Strings.Ability.Count) - result.Add($"Ability: {Strings.Ability[Ability]}"); - if (Context == EntityContext.Gen9 && TeraType != MoveType.Any) - { - if ((uint)TeraType <= TeraTypeUtil.MaxType) // Fairy - result.Add($"Tera Type: {Strings.Types[(int)TeraType]}"); - else if ((uint)TeraType == TeraTypeUtil.Stellar) - result.Add($"Tera Type: {Strings.Types[TeraTypeUtil.StellarTypeDisplayStringIndex]}"); - } - - if (Level != 100) - result.Add($"Level: {Level}"); - if (Shiny) - result.Add("Shiny: Yes"); - if (Context == EntityContext.Gen8 && DynamaxLevel != 10) - result.Add($"Dynamax Level: {DynamaxLevel}"); - if (Context == EntityContext.Gen8 && CanGigantamax) - result.Add("Gigantamax: Yes"); - - if ((uint)Nature < Strings.Natures.Count) - result.Add($"{Strings.Natures[(byte)Nature]} Nature"); - - // Moves - result.AddRange(GetStringMoves()); - return result; - } - - private string GetStringFirstLine(string form) - { - string specForm = Strings.Species[Species]; - if (form.Length != 0) - specForm += $"-{form.Replace("Mega ", "Mega-")}"; - else if (Species == (int)NidoranM) - specForm = specForm.Replace("♂", "-M"); - else if (Species == (int)NidoranF) - specForm = specForm.Replace("♀", "-F"); - - string result = GetSpeciesNickname(specForm); - - // omit genderless or nonspecific - if (Gender is 1) - result += " (F)"; - else if (Gender is 0) - result += " (M)"; - - if (HeldItem > 0) - { - var items = Strings.GetItemStrings(Context); - if ((uint)HeldItem < items.Length) - result += $" @ {items[HeldItem]}"; - } - return result; - } - - private string GetSpeciesNickname(string specForm) - { - if (Nickname.Length == 0 || Nickname == specForm) - return specForm; - bool isNicknamed = SpeciesName.IsNicknamedAnyLanguage(Species, Nickname, Context.Generation()); - if (!isNicknamed) - return specForm; - return $"{Nickname} ({specForm})"; - } - - public static string[] GetStringStats(ReadOnlySpan stats, T ignoreValue) where T : IEquatable - { - var count = stats.Length - stats.Count(ignoreValue); - if (count == 0) - return []; - - var result = new string[count]; - int ctr = 0; - for (int i = 0; i < stats.Length; i++) - { - var statIndex = GetStatIndexStored(i); - var statValue = stats[statIndex]; - if (statValue.Equals(ignoreValue)) - continue; // ignore unused stats - var statName = StatNames[statIndex]; - result[ctr++] = $"{statValue} {statName}"; - } - return result; - } - - private IEnumerable GetStringMoves() - { - var moves = Strings.Move; - foreach (var move in Moves) - { - if (move == 0 || move >= moves.Count) - continue; - - if (move != (int)Move.HiddenPower || HiddenPowerType == -1) - { - yield return $"- {moves[move]}"; - continue; - } - - var type = 1 + HiddenPowerType; // skip Normal - var typeName = Strings.Types[type]; - yield return $"- {moves[move]} [{typeName}]"; - } - } - - private static int GetStatIndexStored(int displayIndex) => displayIndex switch - { - 3 => 4, - 4 => 5, - 5 => 3, - _ => displayIndex, - }; - - /// - /// Forces some properties to indicate the set for future display values. - /// - /// PKM to convert to string - public void InterpretAsPreview(PKM pk) - { - if (pk.Format <= 2) // Nature preview from IVs - Nature = Experience.GetNatureVC(pk.EXP); - } - - /// - /// Converts the data into an importable set format for Pokémon Showdown. - /// - /// PKM to convert to string - /// New ShowdownSet object representing the input - public ShowdownSet(PKM pk) - { - if (pk.Species == 0) - return; - - Context = pk.Context; - - Nickname = pk.Nickname; - Species = pk.Species; - HeldItem = pk.HeldItem; - Ability = pk.Ability; - pk.GetEVs(EVs); - pk.GetIVs(IVs); - - var moves = Moves.AsSpan(); - pk.GetMoves(moves); - if (moves.Contains((ushort)Move.HiddenPower)) - HiddenPowerType = (sbyte)HiddenPower.GetType(IVs, Context); - - Nature = pk.StatNature; - Gender = pk.Gender < 2 ? pk.Gender : (byte)2; - Friendship = pk.CurrentFriendship; - Level = pk.CurrentLevel; - Shiny = pk.IsShiny; - - if (pk is PK8 g) // Only set Gigantamax if it is a PK8 - { - CanGigantamax = g.CanGigantamax; - DynamaxLevel = g.DynamaxLevel; - } - - if (pk is ITeraType t) - TeraType = t.TeraType; - if (pk is IHyperTrain h) - { - for (int i = 0; i < 6; i++) - { - if (h.IsHyperTrained(i)) - IVs[i] = pk.MaxIV; - } - } - - FormName = ShowdownParsing.GetStringFromForm(Form = pk.Form, Strings, Species, Context); - } - - private void ParseFirstLine(ReadOnlySpan first) - { - int itemSplit = first.IndexOf(ItemSplit, StringComparison.Ordinal); - if (itemSplit != -1) - { - var itemName = first[(itemSplit + ItemSplit.Length)..]; - var speciesName = first[..itemSplit]; - - ParseItemName(itemName); - ParseFirstLineNoItem(speciesName); - } - else - { - ParseFirstLineNoItem(first); - } - } - - private void ParseItemName(ReadOnlySpan itemName) - { - if (TrySetItem(Context, itemName)) - return; - if (TrySetItem(EntityContext.Gen3, itemName)) - return; - if (TrySetItem(EntityContext.Gen2, itemName)) - return; - InvalidLines.Add($"Unknown Item: {itemName}"); - - bool TrySetItem(EntityContext context, ReadOnlySpan span) - { - var items = Strings.GetItemStrings(context); - int item = StringUtil.FindIndexIgnoreCase(items, span); - if (item < 0) - return false; - HeldItem = item; - Context = context; - return true; - } - } - - private void ParseFirstLineNoItem(ReadOnlySpan line) - { - // Gender Detection - if (line.EndsWith("(M)", StringComparison.Ordinal)) - { - line = line[..^3].TrimEnd(); - Gender = 0; - } - else if (line.EndsWith("(F)", StringComparison.Ordinal)) - { - line = line[..^3].TrimEnd(); - Gender = 1; - } - - // Nickname Detection - if (line.IndexOf('(') != -1 && line.IndexOf(')') != -1) - ParseSpeciesNickname(line); - else - ParseSpeciesForm(line); - } - - private const string Gmax = "-Gmax"; - - private bool ParseSpeciesForm(ReadOnlySpan speciesLine) - { - speciesLine = speciesLine.Trim(); - if (speciesLine.Length == 0) - return false; - - if (speciesLine.EndsWith(Gmax, StringComparison.Ordinal)) - { - CanGigantamax = true; - speciesLine = speciesLine[..^Gmax.Length]; - } - - var speciesIndex = StringUtil.FindIndexIgnoreCase(Strings.specieslist, speciesLine); - if (speciesIndex > 0) - { - // success, nothing else ! - Species = (ushort)speciesIndex; - return true; - } - - // Form string present. - int end = speciesLine.IndexOf('-'); - if (end < 0) - return false; - - speciesIndex = StringUtil.FindIndexIgnoreCase(Strings.specieslist, speciesLine[..end]); - if (speciesIndex > 0) - { - Species = (ushort)speciesIndex; - FormName = speciesLine[(end + 1)..].ToString(); - return true; - } - - // failure to parse, check edge cases - foreach (var e in DashedSpecies) - { - var sn = Strings.Species[e]; - if (!speciesLine.StartsWith(sn.Replace("♂", "-M").Replace("♀", "-F"), StringComparison.Ordinal)) - continue; - Species = e; - FormName = speciesLine[sn.Length..].ToString(); - return true; - } - - // Version Megas - end = speciesLine[Math.Max(0, end - 1)..].LastIndexOf('-'); - if (end < 0) - return false; - - speciesIndex = StringUtil.FindIndexIgnoreCase(Strings.specieslist, speciesLine[..end]); - if (speciesIndex > 0) - { - Species = (ushort)speciesIndex; - FormName = speciesLine[(end + 1)..].ToString(); - return true; - } - return false; - } - - private void ParseSpeciesNickname(ReadOnlySpan line) - { - // Entering into this method requires both ( and ) to be present within the input line. - int index = line.LastIndexOf('('); - ReadOnlySpan species; - ReadOnlySpan nickname; - if (index > 1) // parenthesis value after: Nickname (Species), correct. - { - nickname = line[..index].TrimEnd(); - species = line[(index + 1)..]; - if (species.Length != 0 && species[^1] == ')') - species = species[..^1]; - } - else // parenthesis value before: (Species) Nickname, incorrect - { - int start = index + 1; - int end = line.LastIndexOf(')'); - var tmp = line[start..end]; - if (end < line.Length - 2) - { - nickname = line[(end + 2)..]; - species = tmp; - } - else // (Species), or garbage - { - species = tmp; - nickname = []; - } - } - - if (ParseSpeciesForm(species)) - Nickname = nickname.ToString(); - else if (ParseSpeciesForm(nickname)) - Nickname = species.ToString(); - } - - private ReadOnlySpan ParseLineMove(ReadOnlySpan line) - { - var startSearch = line[1] == ' ' ? 2 : 1; - var option = line.IndexOf('/'); - line = option != -1 ? line[startSearch..option] : line[startSearch..]; - - var moveString = line.Trim(); - - var hiddenPowerName = Strings.Move[(int)Move.HiddenPower]; - if (!moveString.StartsWith(hiddenPowerName, StringComparison.OrdinalIgnoreCase)) - return moveString; // regular move - - if (moveString.Length == hiddenPowerName.Length) - return hiddenPowerName; - - // Defined Hidden Power - var type = GetHiddenPowerType(moveString[(hiddenPowerName.Length + 1)..]); - var types = Strings.types.AsSpan(1, HiddenPower.TypeCount); - int hpVal = StringUtil.FindIndexIgnoreCase(types, type); // Get HP Type - if (hpVal == -1) - return hiddenPowerName; - - HiddenPowerType = (sbyte)hpVal; - if (IVs.AsSpan().ContainsAnyExcept(31)) - { - if (!HiddenPower.SetIVsForType(hpVal, IVs, Context)) - InvalidLines.Add($"Invalid IVs for Hidden Power Type: {type}"); - } - else if (hpVal >= 0) - { - HiddenPower.SetIVs(hpVal, IVs, Context); // Alter IVs - } - else - { - InvalidLines.Add($"Invalid Hidden Power Type: {type}"); - } - return hiddenPowerName; - } - - private static ReadOnlySpan GetHiddenPowerType(ReadOnlySpan line) - { - var type = line.Trim(); - if (type.Length == 0) - return type; - - if (type[0] == '(' && type[^1] == ')') - return type[1..^1].Trim(); - if (type[0] == '[' && type[^1] == ']') - return type[1..^1].Trim(); - - return type; - } - - private bool ParseLineEVs(ReadOnlySpan line) - { - int start = 0; - while (true) - { - var chunk = line[start..]; - var separator = chunk.IndexOf('/'); - var len = separator == -1 ? chunk.Length : separator; - var tuple = chunk[..len].Trim(); - if (!AbsorbValue(tuple)) - InvalidLines.Add($"Invalid EV tuple: {tuple}"); - if (separator == -1) - break; // no more stats - start += separator + 1; - } - return true; - - bool AbsorbValue(ReadOnlySpan text) - { - var space = text.IndexOf(' '); - if (space == -1) - return false; - var stat = text[(space + 1)..].Trim(); - var statIndex = StringUtil.FindIndexIgnoreCase(StatNames, stat); - if (statIndex == -1) - return false; - var value = text[..space].Trim(); - if (!ushort.TryParse(value, out var statValue)) - return false; - EVs[statIndex] = statValue; - return true; - } - } - - private bool ParseLineIVs(ReadOnlySpan line) - { - int start = 0; - while (true) - { - var chunk = line[start..]; - var separator = chunk.IndexOf('/'); - var len = separator == -1 ? chunk.Length : separator; - var tuple = chunk[..len].Trim(); - if (!AbsorbValue(tuple)) - InvalidLines.Add($"Invalid IV tuple: {tuple}"); - if (separator == -1) - break; // no more stats - start += separator + 1; - } - return true; - - bool AbsorbValue(ReadOnlySpan text) - { - var space = text.IndexOf(' '); - if (space == -1) - return false; - var stat = text[(space + 1)..].Trim(); - var statIndex = StringUtil.FindIndexIgnoreCase(StatNames, stat); - if (statIndex == -1) - return false; - var value = text[..space].Trim(); - if (!byte.TryParse(value, out var statValue)) - return false; - IVs[statIndex] = statValue; - return true; - } - } -} diff --git a/PKHeX.Core/Game/Enums/LanguageGC.cs b/PKHeX.Core/Game/Enums/LanguageGC.cs index 3b7c3a825..788edcd77 100644 --- a/PKHeX.Core/Game/Enums/LanguageGC.cs +++ b/PKHeX.Core/Game/Enums/LanguageGC.cs @@ -58,7 +58,7 @@ public static class LanguageGCRemap /// public static LanguageID ToLanguageID(this LanguageGC lang) => lang switch { - LanguageGC.Hacked => LanguageID.Hacked, + LanguageGC.Hacked => LanguageID.None, LanguageGC.Japanese => LanguageID.Japanese, LanguageGC.English => LanguageID.English, LanguageGC.German => LanguageID.German, @@ -73,7 +73,7 @@ public static class LanguageGCRemap /// public static LanguageGC ToLanguageGC(this LanguageID lang) => lang switch { - LanguageID.Hacked => LanguageGC.Hacked, + LanguageID.None => LanguageGC.Hacked, LanguageID.Japanese => LanguageGC.Japanese, LanguageID.English => LanguageGC.English, LanguageID.German => LanguageGC.German, diff --git a/PKHeX.Core/Game/Enums/LanguageID.cs b/PKHeX.Core/Game/Enums/LanguageID.cs index 741b7247b..d40a84e8d 100644 --- a/PKHeX.Core/Game/Enums/LanguageID.cs +++ b/PKHeX.Core/Game/Enums/LanguageID.cs @@ -1,4 +1,4 @@ -namespace PKHeX.Core; +namespace PKHeX.Core; /// /// Contiguous series Game Language IDs @@ -9,7 +9,7 @@ public enum LanguageID : byte /// Undefined Language ID, usually indicative of a value not being set. /// /// Gen5 Japanese In-game Trades happen to not have their Language value set, and express Language=0. - Hacked = 0, + None = 0, /// /// Japanese (日本語) diff --git a/PKHeX.Core/Game/GameStrings/GameLanguage.cs b/PKHeX.Core/Game/GameStrings/GameLanguage.cs index 3ee29fe61..1d9fb721d 100644 --- a/PKHeX.Core/Game/GameStrings/GameLanguage.cs +++ b/PKHeX.Core/Game/GameStrings/GameLanguage.cs @@ -35,6 +35,8 @@ public static int GetLanguageIndex(string lang) /// Language codes supported for loading string resources /// /// + public static ReadOnlySpan AllSupportedLanguages => LanguageCodes; + private static readonly string[] LanguageCodes = ["ja", "en", "fr", "it", "de", "es", "ko", "zh-Hans", "zh-Hant"]; /// diff --git a/PKHeX.Core/Legality/Encounters/Templates/Gen1/EncounterGift1.cs b/PKHeX.Core/Legality/Encounters/Templates/Gen1/EncounterGift1.cs index 9dcc4d468..cfc91688f 100644 --- a/PKHeX.Core/Legality/Encounters/Templates/Gen1/EncounterGift1.cs +++ b/PKHeX.Core/Legality/Encounters/Templates/Gen1/EncounterGift1.cs @@ -196,7 +196,7 @@ private LanguageID GetLanguage(LanguageID request) if (Language == LanguageRestriction.International && request is not (English or French or Italian or German or Spanish)) return English; - if (request is Hacked or UNUSED_6 or >= Korean) + if (request is None or UNUSED_6 or >= Korean) return English; return request; } diff --git a/PKHeX.Core/Legality/Encounters/Templates/Gen2/EncounterGift2.cs b/PKHeX.Core/Legality/Encounters/Templates/Gen2/EncounterGift2.cs index 02269af08..18ac02786 100644 --- a/PKHeX.Core/Legality/Encounters/Templates/Gen2/EncounterGift2.cs +++ b/PKHeX.Core/Legality/Encounters/Templates/Gen2/EncounterGift2.cs @@ -162,7 +162,7 @@ private LanguageID GetLanguage(LanguageID request) if (Language == LanguageRestriction.InternationalNotEnglish && request is not (French or Italian or German or Spanish)) return French; - if (request is Hacked or UNUSED_6 or >= Korean) + if (request is None or UNUSED_6 or >= Korean) return English; return request; } diff --git a/PKHeX.Core/Legality/Encounters/Templates/Gen3/Gifts/EncounterGift3.cs b/PKHeX.Core/Legality/Encounters/Templates/Gen3/Gifts/EncounterGift3.cs index 51602f87c..77f483381 100644 --- a/PKHeX.Core/Legality/Encounters/Templates/Gen3/Gifts/EncounterGift3.cs +++ b/PKHeX.Core/Legality/Encounters/Templates/Gen3/Gifts/EncounterGift3.cs @@ -332,7 +332,7 @@ private LanguageID GetSafeLanguageNotEgg(LanguageID language) { if (Language != 0) return (LanguageID) Language; - if (language < LanguageID.Korean && language != LanguageID.Hacked) + if (language < LanguageID.Korean && language != LanguageID.None) { if (Language == 0 && language is not LanguageID.Japanese) return language; diff --git a/PKHeX.Core/Legality/Verifiers/LanguageVerifier.cs b/PKHeX.Core/Legality/Verifiers/LanguageVerifier.cs index c55f23811..a3c8e187b 100644 --- a/PKHeX.Core/Legality/Verifiers/LanguageVerifier.cs +++ b/PKHeX.Core/Legality/Verifiers/LanguageVerifier.cs @@ -54,7 +54,7 @@ public static bool IsValidLanguageID(int currentLanguage, int maxLanguageID, PKM if (currentLanguage > maxLanguageID) return false; // Language not available (yet) - if (currentLanguage <= (int)LanguageID.Hacked && !(enc is EncounterTrade5BW && EncounterTrade5BW.IsValidMissingLanguage(pk))) + if (currentLanguage <= (int)LanguageID.None && !(enc is EncounterTrade5BW && EncounterTrade5BW.IsValidMissingLanguage(pk))) return false; // Missing Language value is not obtainable return true; // Language is possible diff --git a/PKHeX.Core/PKM/Util/Language.cs b/PKHeX.Core/PKM/Util/Language.cs index 36f53347d..3c1132f1d 100644 --- a/PKHeX.Core/PKM/Util/Language.cs +++ b/PKHeX.Core/PKM/Util/Language.cs @@ -80,9 +80,29 @@ public static class Language Korean => "ko", ChineseS => "zh-Hans", ChineseT => "zh-Hant", + English => "en", _ => GameLanguage.DefaultLanguage, }; + /// + /// Gets the value from a language code. + /// + /// Language code. + /// Language ID. + public static LanguageID GetLanguageValue(string language) => language switch + { + "ja" => Japanese, + "fr" => French, + "it" => Italian, + "de" => German, + "es" => Spanish, + "ko" => Korean, + "zh-Hans" => ChineseS, + "zh-Hant" => ChineseT, + "en" => English, + _ => GetLanguageValue(GameLanguage.DefaultLanguage), + }; + /// /// Gets the Main Series language ID from a GameCube (C/XD) language ID. /// diff --git a/PKHeX.Core/Resources/config/battle_de.json b/PKHeX.Core/Resources/config/battle_de.json new file mode 100644 index 000000000..c4b0ed792 --- /dev/null +++ b/PKHeX.Core/Resources/config/battle_de.json @@ -0,0 +1,35 @@ +{ + "StatNames": { + "Names": ["KP", "Ang", "Vert", "Init", "SpA", "SpV"], + "ValueGap": " ", + "Separator": " / " + }, + "StatNamesFull": { + "Names": ["KP", "Angriff", "Verteidigung", "Initiative", "Sp. Angriff", "Sp. Verteidigung"], + "ValueGap": " ", + "Separator": " / " + }, + "Male": "Männlich", + "Female": "Weiblich", + "Left": [ + { "Token": "Friendship", "Text": "Freundschaft: " }, + { "Token": "EVs", "Text": "EVs: " }, + { "Token": "IVs", "Text": "DVs: " }, + { "Token": "AVs", "Text": "AVs: " }, + { "Token": "GVs", "Text": "GVs: " }, + { "Token": "Level", "Text": "Level: " }, + { "Token": "Ability", "Text": "Fähigkeit: " }, + { "Token": "DynamaxLevel", "Text": "Dynamax Level: " }, + { "Token": "TeraType", "Text": "Tera-Typ: " }, + { "Token": "Gender", "Text": "Geschlecht: "}, + { "Token": "Nickname", "Text": "Spitzname: "}, + { "Token": "HeldItem", "Text": "Item: "} + ], + "Right": [ + { "Token": "Nature", "Text": " Wesen" } + ], + "Center": [ + { "Token": "Shiny", "Text": "Schillerndes: Ja" }, + { "Token": "Gigantamax", "Text": "Gigadynamax: Ja" } + ] +} diff --git a/PKHeX.Core/Resources/config/battle_en.json b/PKHeX.Core/Resources/config/battle_en.json new file mode 100644 index 000000000..c296291c6 --- /dev/null +++ b/PKHeX.Core/Resources/config/battle_en.json @@ -0,0 +1,35 @@ +{ + "StatNames": { + "Names": ["HP", "Atk", "Def", "SpA", "SpD", "Spe"], + "ValueGap": " ", + "Separator": " / " + }, + "StatNamesFull": { + "Names": ["HP", "Attack", "Defense", "Sp. Atk", "Sp. Def", "Speed"], + "ValueGap": " ", + "Separator": " / " + }, + "Male": "Male", + "Female": "Female", + "Left": [ + { "Token": "Friendship", "Text": "Friendship: " }, + { "Token": "EVs", "Text": "EVs: " }, + { "Token": "IVs", "Text": "IVs: " }, + { "Token": "AVs", "Text": "AVs: " }, + { "Token": "GVs", "Text": "GVs: " }, + { "Token": "Level", "Text": "Level: " }, + { "Token": "Ability", "Text": "Ability: " }, + { "Token": "DynamaxLevel", "Text": "Dynamax Level: " }, + { "Token": "TeraType", "Text": "Tera Type: " }, + { "Token": "Gender", "Text": "Gender: "}, + { "Token": "Nickname", "Text": "Nickname: "}, + { "Token": "HeldItem", "Text": "Held Item: "} + ], + "Right": [ + { "Token": "Nature", "Text": " Nature" } + ], + "Center": [ + { "Token": "Shiny", "Text": "Shiny: Yes" }, + { "Token": "Gigantamax", "Text": "Gigantamax: Yes" } + ] +} diff --git a/PKHeX.Core/Resources/config/battle_es.json b/PKHeX.Core/Resources/config/battle_es.json new file mode 100644 index 000000000..8103efe71 --- /dev/null +++ b/PKHeX.Core/Resources/config/battle_es.json @@ -0,0 +1,35 @@ +{ + "StatNames": { + "Names": ["PS", "Atq", "Def", "AtS", "DeS", "Vel"], + "ValueGap": " ", + "Separator": " / " + }, + "StatNamesFull": { + "Names": ["PS", "Ataque", "Defensa", "Atq. Esp.", "Def. Esp.", "Velocidad"], + "ValueGap": " ", + "Separator": " / " + }, + "Male": "Masculino", + "Female": "Femenina", + "Left": [ + { "Token": "Friendship", "Text": "Felicidad: " }, + { "Token": "EVs", "Text": "EVs: " }, + { "Token": "IVs", "Text": "IVs: " }, + { "Token": "AVs", "Text": "AVs: " }, + { "Token": "GVs", "Text": "GVs: " }, + { "Token": "Level", "Text": "Nivel: " }, + { "Token": "Ability", "Text": "Habilidad: " }, + { "Token": "DynamaxLevel", "Text": "Nivel Dinamax: " }, + { "Token": "TeraType", "Text": "Teratipo: " }, + { "Token": "Nature", "Text": "Naturaleza " }, + { "Token": "Gender", "Text": "Género: "}, + { "Token": "Nickname", "Text": "Mote: "}, + { "Token": "HeldItem", "Text": "Objeto Equipado: "} + ], + "Right": [ + ], + "Center": [ + { "Token": "Shiny", "Text": "Shiny: Sí" }, + { "Token": "Gigantamax", "Text": "Gigamax: Sí" } + ] +} diff --git a/PKHeX.Core/Resources/config/battle_fr.json b/PKHeX.Core/Resources/config/battle_fr.json new file mode 100644 index 000000000..6ecc607ad --- /dev/null +++ b/PKHeX.Core/Resources/config/battle_fr.json @@ -0,0 +1,35 @@ +{ + "StatNames": { + "Names": ["PV", "Atq", "Def", "AtS", "DeS", "Vit"], + "ValueGap": " ", + "Separator": " / " + }, + "StatNamesFull": { + "Names": ["PV", "Attaque", "Défense", "Atq. Spé.", "Déf. Spé.", "Vitesse"], + "ValueGap": " ", + "Separator": " / " + }, + "Male": "Homme", + "Female": "Femme", + "Left": [ + { "Token": "Friendship", "Text": "Bonheur : " }, + { "Token": "EVs", "Text": "EVs : " }, + { "Token": "IVs", "Text": "IVs : " }, + { "Token": "AVs", "Text": "AVs : " }, + { "Token": "GVs", "Text": "GVs : " }, + { "Token": "Level", "Text": "Niveau : " }, + { "Token": "Ability", "Text": "Talent : " }, + { "Token": "DynamaxLevel", "Text": "Niveau Dynamax : " }, + { "Token": "TeraType", "Text": "Type Téra : " }, + { "Token": "Nature", "Text": "Nature " }, + { "Token": "Gender", "Text": "Gender : "}, + { "Token": "Nickname", "Text": "Surnom : "}, + { "Token": "HeldItem", "Text": "Objet : "} + ], + "Right": [ + ], + "Center": [ + { "Token": "Shiny", "Text": "Chromatique: Oui" }, + { "Token": "Gigantamax", "Text": "Gigamax: Oui" } + ] +} diff --git a/PKHeX.Core/Resources/config/battle_it.json b/PKHeX.Core/Resources/config/battle_it.json new file mode 100644 index 000000000..ae10c3df5 --- /dev/null +++ b/PKHeX.Core/Resources/config/battle_it.json @@ -0,0 +1,35 @@ +{ + "StatNames": { + "Names": ["PS", "Att", "Dif", "AtS", "DiS", "Vel"], + "ValueGap": " ", + "Separator": " / " + }, + "StatNamesFull": { + "Names": ["PS", "Attacco", "Difesa", "Attacco Sp.", "Difesa Sp.", "Velocità"], + "ValueGap": " ", + "Separator": " / " + }, + "Male": "Maschio", + "Female": "Femmina", + "Left": [ + { "Token": "Friendship", "Text": "Amicizia: " }, + { "Token": "EVs", "Text": "EVs: " }, + { "Token": "IVs", "Text": "IVs: " }, + { "Token": "AVs", "Text": "AVs: " }, + { "Token": "GVs", "Text": "GVs: " }, + { "Token": "Level", "Text": "Livello: " }, + { "Token": "Ability", "Text": "Abilità: " }, + { "Token": "DynamaxLevel", "Text": "Livello Dynamax: " }, + { "Token": "TeraType", "Text": "Teratipo: " }, + { "Token": "Nature", "Text": "Natura " }, + { "Token": "Gender", "Text": "Genere: "}, + { "Token": "Nickname", "Text": "Soprannome: "}, + { "Token": "HeldItem", "Text": "Strumento tenuto: "} + ], + "Right": [ + ], + "Center": [ + { "Token": "Shiny", "Text": "Cromatico: Si" }, + { "Token": "Gigantamax", "Text": "Gigamax: Si" } + ] +} diff --git a/PKHeX.Core/Resources/config/battle_ja.json b/PKHeX.Core/Resources/config/battle_ja.json new file mode 100644 index 000000000..392b34ee4 --- /dev/null +++ b/PKHeX.Core/Resources/config/battle_ja.json @@ -0,0 +1,35 @@ +{ + "StatNames": { + "Names": ["HP", "攻撃", "防御", "特攻", "特防", "素早さ"], + "ValueGap": " ", + "Separator": " / " + }, + "StatNamesFull": { + "Names": ["HP", "攻撃", "防御", "特攻", "特防", "素早さ"], + "ValueGap": " ", + "Separator": " / " + }, + "Male": "男性", + "Female": "女性", + "Left": [ + { "Token": "Friendship", "Text": "なつき度 " }, + { "Token": "EVs", "Text": "努力値 " }, + { "Token": "IVs", "Text": "個体値 " }, + { "Token": "AVs", "Text": "AVs " }, + { "Token": "GVs", "Text": "頑張る " }, + { "Token": "Level", "Text": "Lv " }, + { "Token": "Ability", "Text": "特性 " }, + { "Token": "DynamaxLevel", "Text": "ダイマックスレベル " }, + { "Token": "TeraType", "Text": "テラスタイプ " }, + { "Token": "Gender", "Text": "性別 "}, + { "Token": "Nickname", "Text": "ニックネーム "}, + { "Token": "HeldItem", "Text": "持ち物 "} + ], + "Right": [ + { "Token": "Nature", "Text": "性格" } + ], + "Center": [ + { "Token": "Shiny", "Text": "光ひかる: はい" }, + { "Token": "Gigantamax", "Text": "キョダイマックス: はい" } + ] +} diff --git a/PKHeX.Core/Resources/config/battle_ko.json b/PKHeX.Core/Resources/config/battle_ko.json new file mode 100644 index 000000000..6fa539be7 --- /dev/null +++ b/PKHeX.Core/Resources/config/battle_ko.json @@ -0,0 +1,35 @@ +{ + "StatNames": { + "Names": ["HP", "공격", "방어", "특공", "특방", "스피드"], + "ValueGap": " ", + "Separator": " / " + }, + "StatNamesFull": { + "Names": ["HP", "공격", "방어", "특공", "특방", "스피드"], + "ValueGap": " ", + "Separator": " / " + }, + "Male": "남성", + "Female": "여성", + "Left": [ + { "Token": "Friendship", "Text": "친밀도 " }, + { "Token": "EVs", "Text": "EVs: " }, + { "Token": "IVs", "Text": "IVs: " }, + { "Token": "AVs", "Text": "AVs: " }, + { "Token": "GVs", "Text": "노력 레벨을 " }, + { "Token": "Level", "Text": "스피드 " }, + { "Token": "Ability", "Text": "특성 " }, + { "Token": "DynamaxLevel", "Text": "다이맥스 레벨 " }, + { "Token": "TeraType", "Text": "테라스탈타입 " }, + { "Token": "Gender", "Text": "성별 "}, + { "Token": "Nickname", "Text": "이름 "}, + { "Token": "HeldItem", "Text": "持ち物 "} + ], + "Right": [ + { "Token": "Nature", "Text": "성격" } + ], + "Center": [ + { "Token": "Shiny", "Text": "빛나는: 예" }, + { "Token": "Gigantamax", "Text": "거다이맥스: 예" } + ] +} diff --git a/PKHeX.Core/Resources/config/battle_zh-hans.json b/PKHeX.Core/Resources/config/battle_zh-hans.json new file mode 100644 index 000000000..771fee55e --- /dev/null +++ b/PKHeX.Core/Resources/config/battle_zh-hans.json @@ -0,0 +1,35 @@ +{ + "StatNames": { + "Names": ["HP", "攻击", "防御", "特攻", "特防", "速度"], + "ValueGap": " ", + "Separator": " / " + }, + "StatNamesFull": { + "Names": ["HP", "攻击", "防御", "特攻", "特防", "速度"], + "ValueGap": " ", + "Separator": " / " + }, + "Male": "男", + "Female": "女", + "Left": [ + { "Token": "Friendship", "Text": "亲密度: " }, + { "Token": "EVs", "Text": "努力值: " }, + { "Token": "IVs", "Text": "个体值: " }, + { "Token": "AVs", "Text": "AVs " }, + { "Token": "GVs", "Text": "奋斗等级 " }, + { "Token": "Level", "Text": "等级: " }, + { "Token": "Ability", "Text": "特性: " }, + { "Token": "DynamaxLevel", "Text": "极巨等级: " }, + { "Token": "TeraType", "Text": "太晶属性: " }, + { "Token": "Nature", "Text": "性格: " }, + { "Token": "Gender", "Text": "性别: "}, + { "Token": "Nickname", "Text": "昵称: "}, + { "Token": "HeldItem", "Text": "持有物: "} + ], + "Right": [ + ], + "Center": [ + { "Token": "Shiny", "Text": "發光寶: 是的" }, + { "Token": "Gigantamax", "Text": "超极巨: 是的" } + ] +} diff --git a/PKHeX.Core/Resources/config/battle_zh-hant.json b/PKHeX.Core/Resources/config/battle_zh-hant.json new file mode 100644 index 000000000..672b56a5f --- /dev/null +++ b/PKHeX.Core/Resources/config/battle_zh-hant.json @@ -0,0 +1,35 @@ +{ + "StatNames": { + "Names": ["HP", "攻擊", "防禦", "特攻", "特防", "速度"], + "ValueGap": " ", + "Separator": " / " + }, + "StatNamesFull": { + "Names": ["HP", "攻擊", "防禦", "特攻", "特防", "速度"], + "ValueGap": " ", + "Separator": " / " + }, + "Male": "男", + "Female": "女", + "Left": [ + { "Token": "Friendship", "Text": "親密度: " }, + { "Token": "EVs", "Text": "努力值: " }, + { "Token": "IVs", "Text": "個體值: " }, + { "Token": "AVs", "Text": "AVs: " }, + { "Token": "GVs", "Text": "奋斗等级: " }, + { "Token": "Level", "Text": "等級: " }, + { "Token": "Ability", "Text": "特性: " }, + { "Token": "DynamaxLevel", "Text": "極巨化等級: " }, + { "Token": "TeraType", "Text": "太晶屬性: " }, + { "Token": "Nature", "Text": "性格: " }, + { "Token": "Gender", "Text": "性別: "}, + { "Token": "Nickname", "Text": "昵稱: "}, + { "Token": "HeldItem", "Text": "携帶物品: "} + ], + "Right": [ + ], + "Center": [ + { "Token": "Shiny", "Text": "發光寶: 是的" }, + { "Token": "Gigantamax", "Text": "超極巨: 是的" } + ] +} diff --git a/PKHeX.Core/Util/EmbeddedResourceCache.cs b/PKHeX.Core/Util/EmbeddedResourceCache.cs index d0640c986..a623b3410 100644 --- a/PKHeX.Core/Util/EmbeddedResourceCache.cs +++ b/PKHeX.Core/Util/EmbeddedResourceCache.cs @@ -38,7 +38,9 @@ public EmbeddedResourceCache(Assembly assembly) private static string GetFileName(string resName) { - var period = resName.LastIndexOf('.', resName.Length - 5); + var period = resName.LastIndexOf('.'); + if (resName.Length - period <= 5) + period = resName.LastIndexOf('.', period - 1); var start = period + 1; System.Diagnostics.Debug.Assert(start != 0); // should have a period in the name diff --git a/PKHeX.WinForms/Controls/PKM Editor/MoveDisplay.cs b/PKHeX.WinForms/Controls/PKM Editor/MoveDisplay.cs index 2850c49d9..b2484914f 100644 --- a/PKHeX.WinForms/Controls/PKM Editor/MoveDisplay.cs +++ b/PKHeX.WinForms/Controls/PKM Editor/MoveDisplay.cs @@ -10,7 +10,7 @@ public partial class MoveDisplay : UserControl { public MoveDisplay() => InitializeComponent(); - public int Populate(PKM pk, ushort move, EntityContext context, ReadOnlySpan moves, bool valid = true) + public int Populate(PKM pk, GameStrings strings, ushort move, EntityContext context, ReadOnlySpan moves, bool valid = true) { if (move == 0 || move >= moves.Length) { @@ -24,7 +24,7 @@ public int Populate(PKM pk, ushort move, EntityContext context, ReadOnlySpan> fetch) { var list = fetch(sav); - var result = ShowdownParsing.GetShowdownSets(list, Environment.NewLine + Environment.NewLine); + var programLanguage = Language.GetLanguageValue(Main.Settings.Startup.Language); + var settings = Main.Settings.BattleTemplate.Export.GetSettings(programLanguage, sav.Context); + var result = ShowdownParsing.GetShowdownSets(list, Environment.NewLine + Environment.NewLine, settings); if (string.IsNullOrWhiteSpace(result)) return; if (WinFormsUtil.SetClipboardText(result)) diff --git a/PKHeX.WinForms/Controls/Slots/PokePreview.Designer.cs b/PKHeX.WinForms/Controls/Slots/PokePreview.Designer.cs index 51911e453..c2e2ec998 100644 --- a/PKHeX.WinForms/Controls/Slots/PokePreview.Designer.cs +++ b/PKHeX.WinForms/Controls/Slots/PokePreview.Designer.cs @@ -30,13 +30,13 @@ private void InitializeComponent() { PAN_All = new System.Windows.Forms.Panel(); FLP_List = new System.Windows.Forms.FlowLayoutPanel(); - L_Stats = new System.Windows.Forms.Label(); + L_LinesBeforeMoves = new System.Windows.Forms.Label(); FLP_Moves = new System.Windows.Forms.FlowLayoutPanel(); Move1 = new MoveDisplay(); Move2 = new MoveDisplay(); Move3 = new MoveDisplay(); Move4 = new MoveDisplay(); - L_Etc = new System.Windows.Forms.Label(); + L_LinesAfterMoves = new System.Windows.Forms.Label(); PAN_Top = new System.Windows.Forms.Panel(); FLP_Top = new System.Windows.Forms.FlowLayoutPanel(); PB_Ball = new System.Windows.Forms.PictureBox(); @@ -68,9 +68,9 @@ private void InitializeComponent() // FLP_List.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; FLP_List.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; - FLP_List.Controls.Add(L_Stats); + FLP_List.Controls.Add(L_LinesBeforeMoves); FLP_List.Controls.Add(FLP_Moves); - FLP_List.Controls.Add(L_Etc); + FLP_List.Controls.Add(L_LinesAfterMoves); FLP_List.FlowDirection = System.Windows.Forms.FlowDirection.TopDown; FLP_List.Location = new System.Drawing.Point(0, 34); FLP_List.Margin = new System.Windows.Forms.Padding(0); @@ -79,15 +79,15 @@ private void InitializeComponent() FLP_List.TabIndex = 1; FLP_List.WrapContents = false; // - // L_Stats + // L_LinesBeforeMoves // - L_Stats.AutoSize = true; - L_Stats.Location = new System.Drawing.Point(2, 4); - L_Stats.Margin = new System.Windows.Forms.Padding(2, 4, 0, 0); - L_Stats.Name = "L_Stats"; - L_Stats.Size = new System.Drawing.Size(32, 15); - L_Stats.TabIndex = 5; - L_Stats.Text = "Stats"; + L_LinesBeforeMoves.AutoSize = true; + L_LinesBeforeMoves.Location = new System.Drawing.Point(2, 4); + L_LinesBeforeMoves.Margin = new System.Windows.Forms.Padding(2, 4, 0, 0); + L_LinesBeforeMoves.Name = "L_LinesBeforeMoves"; + L_LinesBeforeMoves.Size = new System.Drawing.Size(36, 17); + L_LinesBeforeMoves.TabIndex = 5; + L_LinesBeforeMoves.Text = "Stats"; // // FLP_Moves // @@ -100,7 +100,7 @@ private void InitializeComponent() FLP_Moves.Controls.Add(Move4); FLP_List.SetFlowBreak(FLP_Moves, true); FLP_Moves.FlowDirection = System.Windows.Forms.FlowDirection.TopDown; - FLP_Moves.Location = new System.Drawing.Point(0, 23); + FLP_Moves.Location = new System.Drawing.Point(0, 25); FLP_Moves.Margin = new System.Windows.Forms.Padding(0, 4, 0, 4); FLP_Moves.Name = "FLP_Moves"; FLP_Moves.Size = new System.Drawing.Size(142, 96); @@ -150,15 +150,15 @@ private void InitializeComponent() Move4.Size = new System.Drawing.Size(138, 24); Move4.TabIndex = 4; // - // L_Etc + // L_LinesAfterMoves // - L_Etc.AutoSize = true; - L_Etc.Location = new System.Drawing.Point(2, 123); - L_Etc.Margin = new System.Windows.Forms.Padding(2, 0, 0, 4); - L_Etc.Name = "L_Etc"; - L_Etc.Size = new System.Drawing.Size(28, 15); - L_Etc.TabIndex = 6; - L_Etc.Text = "Info"; + L_LinesAfterMoves.AutoSize = true; + L_LinesAfterMoves.Location = new System.Drawing.Point(2, 125); + L_LinesAfterMoves.Margin = new System.Windows.Forms.Padding(2, 0, 0, 4); + L_LinesAfterMoves.Name = "L_LinesAfterMoves"; + L_LinesAfterMoves.Size = new System.Drawing.Size(30, 17); + L_LinesAfterMoves.TabIndex = 6; + L_LinesAfterMoves.Text = "Info"; // // PAN_Top // @@ -243,7 +243,7 @@ private void InitializeComponent() private System.Windows.Forms.Panel PAN_All; private System.Windows.Forms.FlowLayoutPanel FLP_List; - private System.Windows.Forms.Label L_Stats; + private System.Windows.Forms.Label L_LinesBeforeMoves; private System.Windows.Forms.Panel PAN_Top; private System.Windows.Forms.Label L_Name; private System.Windows.Forms.PictureBox PB_Ball; @@ -252,7 +252,7 @@ private void InitializeComponent() private MoveDisplay Move2; private MoveDisplay Move3; private MoveDisplay Move4; - private System.Windows.Forms.Label L_Etc; + private System.Windows.Forms.Label L_LinesAfterMoves; private System.Windows.Forms.FlowLayoutPanel FLP_Moves; private System.Windows.Forms.FlowLayoutPanel FLP_Top; } diff --git a/PKHeX.WinForms/Controls/Slots/PokePreview.cs b/PKHeX.WinForms/Controls/Slots/PokePreview.cs index 35eb32224..f354d98eb 100644 --- a/PKHeX.WinForms/Controls/Slots/PokePreview.cs +++ b/PKHeX.WinForms/Controls/Slots/PokePreview.cs @@ -1,7 +1,5 @@ using System; -using System.Diagnostics.CodeAnalysis; using System.Drawing; -using System.Text; using System.Windows.Forms; using PKHeX.Core; @@ -30,17 +28,17 @@ public PokePreview() Properties.Resources.gender_2, ]; - public void Populate(PKM pk) + public void Populate(PKM pk, in BattleTemplateExportSettings settings) { var la = new LegalityAnalysis(pk); - int width = PopulateHeader(pk); - PopulateMoves(pk, la, ref width); - PopulateText(pk, la, width); + int width = PopulateHeader(pk, settings); + PopulateMoves(pk, la, settings, ref width); + PopulateText(pk, la, settings, width); } - private int PopulateHeader(PKM pk) + private int PopulateHeader(PKM pk, in BattleTemplateExportSettings settings) { - var name = GetNameTitle(pk); + var name = GetNameTitle(pk, settings); var size = MeasureSize(name, L_Name.Font); L_Name.Width = Math.Max(InitialNameWidth, size.Width); L_Name.Text = name; @@ -52,14 +50,19 @@ private int PopulateHeader(PKM pk) return Math.Max(InitialWidth, width); } - private static string GetNameTitle(PKM pk) + private static string GetNameTitle(PKM pk, in BattleTemplateExportSettings settings) { + // Don't care about form; the user will be able to see the sprite next to the preview. var nick = pk.Nickname; - var all = GameInfo.Strings.Species; + var strings = settings.Localization.Strings; + var all = strings.Species; var species = pk.Species; if (species >= all.Count) return nick; var expect = all[species]; + if (settings.IsTokenInExport(BattleTemplateToken.Nickname)) + return expect; // Nickname will be on another line. + if (nick.Equals(expect, StringComparison.OrdinalIgnoreCase)) return nick; return $"{nick} ({expect})"; @@ -87,32 +90,33 @@ private void PopulateGender(PKM pk) PB_Gender.Image = GenderImages[gender]; } - private void PopulateMoves(PKM pk, LegalityAnalysis la, ref int width) + private void PopulateMoves(PKM pk, LegalityAnalysis la, in BattleTemplateExportSettings settings, ref int width) { var context = pk.Context; - var names = GameInfo.Strings.movelist; + var strings = settings.Localization.Strings; + var names = strings.movelist; var check = la.Info.Moves; - var w1 = Move1.Populate(pk, pk.Move1, context, names, check[0].Valid); - var w2 = Move2.Populate(pk, pk.Move2, context, names, check[1].Valid); - var w3 = Move3.Populate(pk, pk.Move3, context, names, check[2].Valid); - var w4 = Move4.Populate(pk, pk.Move4, context, names, check[3].Valid); + var w1 = Move1.Populate(pk, strings, pk.Move1, context, names, check[0].Valid); + var w2 = Move2.Populate(pk, strings, pk.Move2, context, names, check[1].Valid); + var w3 = Move3.Populate(pk, strings, pk.Move3, context, names, check[2].Valid); + var w4 = Move4.Populate(pk, strings, pk.Move4, context, names, check[3].Valid); var maxWidth = Math.Max(w1, Math.Max(w2, Math.Max(w3, w4))); width = Math.Max(width, maxWidth + Move1.Margin.Horizontal + interiorMargin); } - private void PopulateText(PKM pk, LegalityAnalysis la, int width) + private void PopulateText(PKM pk, LegalityAnalysis la, in BattleTemplateExportSettings settings, int width) { - var (stats, enc) = GetStatsString(pk, la); - var settings = Main.Settings.Hover; + var (before, after) = GetBeforeAndAfter(pk, la, settings); + var hover = Main.Settings.Hover; bool hasMoves = pk.MoveCount != 0; FLP_Moves.Visible = hasMoves; var height = FLP_List.Top + interiorMargin; if (hasMoves) height += FLP_Moves.Height + FLP_Moves.Margin.Vertical; - ToggleLabel(L_Stats, stats, settings.PreviewShowPaste, ref width, ref height); - ToggleLabel(L_Etc, enc, settings.HoverSlotShowEncounter, ref width, ref height); + ToggleLabel(L_LinesBeforeMoves, before, hover.PreviewShowPaste, ref width, ref height); + ToggleLabel(L_LinesAfterMoves, after, hover.HoverSlotShowEncounter, ref width, ref height); Size = new Size(width, height); } @@ -137,78 +141,67 @@ public static Size MeasureSize(ReadOnlySpan text, Font font) return TextRenderer.MeasureText(text, font, new Size(), flags); } - private static (string Detail, string Encounter) GetStatsString(PKM pk, LegalityAnalysis la) + private static (string Before, string After) GetBeforeAndAfter(PKM pk, LegalityAnalysis la, in BattleTemplateExportSettings settings) { - var setText = SummaryPreviewer.GetPreviewText(pk, la); - var sb = new StringBuilder(); - var lines = setText.AsSpan().EnumerateLines(); - if (!lines.MoveNext()) - throw new ArgumentException("Invalid text format", nameof(pk)); + var order = settings.Order; + // Bifurcate the order into two sections; split via Moves. + var moveIndex = settings.GetTokenIndex(BattleTemplateToken.Moves); + var before = moveIndex == -1 ? order : order[..moveIndex]; + var after = moveIndex == -1 ? default : order[(moveIndex + 1)..]; + if (before.Length > 0 && before[0] == BattleTemplateToken.FirstLine) + before = before[1..]; // remove first line token; trust that users don't randomly move it lower in the list. - var first = lines.Current; - var itemIndex = first.IndexOf('@'); - if (itemIndex != -1) // Held Item - { - var remaining = first[(itemIndex + 2)..]; - if (remaining[^1] == ')') - remaining = remaining[..^3]; // lop off gender - var item = remaining.Trim(); - if (item.Length != 0) - sb.AppendLine($"Held Item: {item}"); - } + var start = SummaryPreviewer.GetPreviewText(pk, settings with { Order = before }); + var end = SummaryPreviewer.GetPreviewText(pk, settings with { Order = after }); + if (settings.IsTokenInExport(BattleTemplateToken.IVs, before)) + TryAppendOtherStats(pk, ref start, settings); + else if (settings.IsTokenInExport(BattleTemplateToken.IVs, after)) + TryAppendOtherStats(pk, ref end, settings); - if (pk is IGanbaru g) - AddGanbaru(g, sb); - if (pk is IAwakened a) - AddAwakening(a, sb); + if (Main.Settings.Hover.HoverSlotShowEncounter) + end = SummaryPreviewer.AppendEncounterInfo(la, end); - while (lines.MoveNext()) - { - var line = lines.Current; - if (IsMoveLine(line)) - { - while (lines.MoveNext()) - { - if (!IsMoveLine(lines.Current)) - break; - } - break; - } - sb.Append(line).AppendLine(); - } - - var detail = sb.ToString(); - sb.Clear(); - while (lines.MoveNext()) - { - var line = lines.Current; - sb.Append(line).AppendLine(); - } - var enc = sb.ToString(); - return (detail.TrimEnd(), enc.TrimEnd()); - - static bool IsMoveLine(ReadOnlySpan line) => line.Length != 0 && line[0] == '-'; + return (start, end); } - private static void AddGanbaru(IGanbaru g, StringBuilder sb) + private static void TryAppendOtherStats(PKM pk, ref string line, in BattleTemplateExportSettings settings) + { + if (pk is IGanbaru g) + AppendGanbaru(g, ref line, settings); + if (pk is IAwakened a) + AppendAwakening(a, ref line, settings); + } + + private static void AppendGanbaru(IGanbaru g, ref string line, in BattleTemplateExportSettings settings) { Span gvs = stackalloc byte[6]; g.GetGVs(gvs); - TryAdd(sb, "GVs", gvs); + var statNames = settings.Localization.Config.GetStatDisplay(settings.StatsOther); + var value = TryAdd(gvs, statNames); + if (value.Length == 0) + return; + var result = settings.Localization.Config.Push(BattleTemplateToken.GVs, value); + line += Environment.NewLine + result; } - private static void AddAwakening(IAwakened a, StringBuilder sb) + private static void AppendAwakening(IAwakened a, ref string line, in BattleTemplateExportSettings settings) { Span avs = stackalloc byte[6]; a.GetAVs(avs); - TryAdd(sb, "AVs", avs); + var statNames = settings.Localization.Config.GetStatDisplay(settings.StatsOther); + var value = TryAdd(avs, statNames); + if (value.Length == 0) + return; + var result = settings.Localization.Config.Push(BattleTemplateToken.AVs, value); + line += Environment.NewLine + result; } - private static void TryAdd(StringBuilder sb, [ConstantExpected] string type, ReadOnlySpan stats, T ignore = default) where T : unmanaged, IEquatable + private static string TryAdd(ReadOnlySpan stats, StatDisplayConfig statNames, T ignore = default) where T : unmanaged, IEquatable { - var chunks = ShowdownSet.GetStringStats(stats, ignore); - if (chunks.Length != 0) - sb.AppendLine($"{type}: {string.Join(" / ", chunks)}"); + var chunks = ShowdownSet.GetStringStats(stats, ignore, statNames); + if (chunks.Length == 0) + return string.Empty; + return string.Join(" / ", chunks); } /// Prevent stealing focus from the form that shows this. diff --git a/PKHeX.WinForms/Controls/Slots/SlotUtil.cs b/PKHeX.WinForms/Controls/Slots/SlotUtil.cs index efa507571..8cf46258d 100644 --- a/PKHeX.WinForms/Controls/Slots/SlotUtil.cs +++ b/PKHeX.WinForms/Controls/Slots/SlotUtil.cs @@ -66,6 +66,11 @@ public static void UpdateSlot(PictureBox pb, ISlotInfo c, PKM p, SaveFile s, boo pb.BackColor = Color.Transparent; pb.Image = img; - pb.AccessibleDescription = ShowdownParsing.GetLocalizedPreviewText(p, Main.CurrentLanguage); + + var x = Main.Settings; + var programLanguage = Language.GetLanguageValue(x.Startup.Language); + var cfg = x.BattleTemplate; + var settings = cfg.Hover.GetSettings(programLanguage, p.Context); + pb.AccessibleDescription = ShowdownParsing.GetLocalizedPreviewText(p, settings); } } diff --git a/PKHeX.WinForms/Controls/Slots/SummaryPreviewer.cs b/PKHeX.WinForms/Controls/Slots/SummaryPreviewer.cs index 1ce553717..554e754ec 100644 --- a/PKHeX.WinForms/Controls/Slots/SummaryPreviewer.cs +++ b/PKHeX.WinForms/Controls/Slots/SummaryPreviewer.cs @@ -24,20 +24,32 @@ public void Show(Control pb, PKM pk) return; } + var programLanguage = Language.GetLanguageValue(Main.Settings.Startup.Language); + var cfg = Main.Settings.BattleTemplate; + var settings = cfg.Hover.GetSettings(programLanguage, pk.Context); + if (Settings.HoverSlotShowPreview && Control.ModifierKeys != Keys.Alt) - UpdatePreview(pb, pk); + { + UpdatePreview(pb, pk, settings); + } else if (Settings.HoverSlotShowText) - ShowSet.SetToolTip(pb, GetPreviewText(pk, new LegalityAnalysis(pk))); + { + var text = GetPreviewText(pk, settings); + if (Settings.HoverSlotShowEncounter) + text = AppendEncounterInfo(new LegalityAnalysis(pk), text); + ShowSet.SetToolTip(pb, text); + } + if (Settings.HoverSlotPlayCry) Cry.PlayCry(pk, pk.Context); } - private void UpdatePreview(Control pb, PKM pk) + private void UpdatePreview(Control pb, PKM pk, BattleTemplateExportSettings settings) { _source.Cancel(); _source = new(); UpdatePreviewPosition(new()); - Previewer.Populate(pk); + Previewer.Populate(pk, settings); Previewer.Show(); } @@ -80,12 +92,13 @@ public void Clear() Cry.Stop(); } - public static string GetPreviewText(PKM pk, LegalityAnalysis la) + public static string GetPreviewText(PKM pk, BattleTemplateExportSettings settings) => ShowdownParsing.GetLocalizedPreviewText(pk, settings); + + public static string AppendEncounterInfo(LegalityAnalysis la, string text) { - var text = ShowdownParsing.GetLocalizedPreviewText(pk, Main.Settings.Startup.Language); - if (!Main.Settings.Hover.HoverSlotShowEncounter) - return text; - var result = new List { text, string.Empty }; + var result = new List(8) { text }; + if (text.Length != 0) // add a blank line between the set and the encounter info if isn't already a blank line + result.Add(""); LegalityFormatting.AddEncounterInfo(la, result); return string.Join(Environment.NewLine, result); } diff --git a/PKHeX.WinForms/MainWindow/Main.cs b/PKHeX.WinForms/MainWindow/Main.cs index ac3b34863..2d02272eb 100644 --- a/PKHeX.WinForms/MainWindow/Main.cs +++ b/PKHeX.WinForms/MainWindow/Main.cs @@ -524,18 +524,15 @@ private void ClickShowdownImportPKM(object sender, EventArgs e) // Get Simulator Data var text = Clipboard.GetText(); - ShowdownSet set; - if (ShowdownTeam.IsURL(text, out var url) && ShowdownTeam.TryGetSets(url, out var content)) - set = ShowdownParsing.GetShowdownSets(content).FirstOrDefault() ?? new(""); // take only first set - else if (PokepasteTeam.IsURL(text, out url) && PokepasteTeam.TryGetSets(url, out content)) - set = ShowdownParsing.GetShowdownSets(content).FirstOrDefault() ?? new(""); // take only first set - else - set = new ShowdownSet(text); + var sets = BattleTemplateTeams.TryGetSets(text); + var set = sets.FirstOrDefault() ?? new(""); // take only first set if (set.Species == 0) { WinFormsUtil.Alert(MsgSimulatorFailClipboard); return; } - var reformatted = set.Text; + var programLanguage = Language.GetLanguageValue(Settings.Startup.Language); + var settings = Settings.BattleTemplate.Export.GetSettings(programLanguage, set.Context); + var reformatted = set.GetText(settings); if (DialogResult.Yes != WinFormsUtil.Prompt(MessageBoxButtons.YesNo, MsgSimulatorLoad, reformatted)) return; @@ -555,7 +552,9 @@ private void ClickShowdownExportPKM(object sender, EventArgs e) } var pk = PreparePKM(); - var text = ShowdownParsing.GetShowdownText(pk); + var programLanguage = Language.GetLanguageValue(Settings.Startup.Language); + var settings = Settings.BattleTemplate.Export.GetSettings(programLanguage, pk.Context); + var text = ShowdownParsing.GetShowdownText(pk, settings); bool success = WinFormsUtil.SetClipboardText(text); if (!success || !Clipboard.GetText().Equals(text)) WinFormsUtil.Alert(MsgClipboardFailWrite, MsgSimulatorExportFail); diff --git a/PKHeX.WinForms/Properties/PKHeXSettings.cs b/PKHeX.WinForms/Properties/PKHeXSettings.cs index a1f981751..52db0ce8e 100644 --- a/PKHeX.WinForms/Properties/PKHeXSettings.cs +++ b/PKHeX.WinForms/Properties/PKHeXSettings.cs @@ -35,6 +35,7 @@ public sealed class PKHeXSettings public SpriteSettings Sprite { get; set; } = new(); public SoundSettings Sounds { get; set; } = new(); public HoverSettings Hover { get; set; } = new(); + public BattleTemplateSettings BattleTemplate { get; set; } = new(); // GUI Specific public DrawConfig Draw { get; set; } = new(); diff --git a/PKHeX.WinForms/Subforms/SAV_Database.cs b/PKHeX.WinForms/Subforms/SAV_Database.cs index 2fde38cff..ae244bc4d 100644 --- a/PKHeX.WinForms/Subforms/SAV_Database.cs +++ b/PKHeX.WinForms/Subforms/SAV_Database.cs @@ -110,7 +110,11 @@ public SAV_Database(PKMEditor f1, SAVEditor saveditor) return; var pk = Results[index]; - slot.AccessibleDescription = ShowdownParsing.GetLocalizedPreviewText(pk.Entity, Main.CurrentLanguage); + + var x = Main.Settings; + var programLanguage = Language.GetLanguageValue(x.Startup.Language); + var settings = x.BattleTemplate.Hover.GetSettings(programLanguage, pk.Entity.Context); + slot.AccessibleDescription = ShowdownParsing.GetLocalizedPreviewText(pk.Entity, settings); }; } diff --git a/PKHeX.WinForms/Subforms/SettingsEditor.cs b/PKHeX.WinForms/Subforms/SettingsEditor.cs index c877d4ed8..c14edf300 100644 --- a/PKHeX.WinForms/Subforms/SettingsEditor.cs +++ b/PKHeX.WinForms/Subforms/SettingsEditor.cs @@ -64,6 +64,7 @@ private void LoadSettings(object obj) var tab = new TabPage(p) { Name = $"Tab_{p}" }; var pg = new PropertyGrid { SelectedObject = state, Dock = DockStyle.Fill }; tab.Controls.Add(pg); + pg.ExpandAllGridItems(); tabControl1.TabPages.Add(tab); } } diff --git a/Tests/PKHeX.Core.Tests/Simulator/ShowdownSetTests.cs b/Tests/PKHeX.Core.Tests/Simulator/ShowdownSetTests.cs index 4bfc7f4ad..aa8810df3 100644 --- a/Tests/PKHeX.Core.Tests/Simulator/ShowdownSetTests.cs +++ b/Tests/PKHeX.Core.Tests/Simulator/ShowdownSetTests.cs @@ -10,9 +10,11 @@ public class ShowdownSetTests [Fact] public void SimulatorGetParse() { + var settings = new BattleTemplateExportSettings(BattleTemplateConfig.CommunityStandard); + foreach (ReadOnlySpan setstr in Sets) { - var set = new ShowdownSet(setstr).GetSetLines(); + var set = new ShowdownSet(setstr).GetSetLines(settings); foreach (var line in set) setstr.Contains(line, StringComparison.Ordinal).Should().BeTrue($"Line {line} should be in the set {setstr}"); } @@ -163,6 +165,67 @@ public void SimulatorParseEmpty() Assert.False(sets.Any()); } + [Theory] + [InlineData(SetAllTokenExample)] + public void SimulatorTranslate(string message, string languageOriginal = "en") + { + var settingsOriginal = new BattleTemplateExportSettings(BattleTemplateConfig.CommunityStandard, languageOriginal); + if (!ShowdownParsing.TryParseAnyLanguage(message, out var set)) + throw new Exception("Input failed"); + + var all = BattleTemplateLocalization.GetAll(); + foreach (var l in all) + { + var languageTarget = l.Key; + if (languageTarget == languageOriginal) + continue; + + var exportSettings = new BattleTemplateExportSettings(languageTarget); + var translated = set.GetText(exportSettings); + translated.Should().NotBeNullOrEmpty(); + translated.Should().NotBe(message); + + // Convert back, should be 1:1 + if (!ShowdownParsing.TryParseAnyLanguage(translated, out var set2)) + throw new Exception($"{languageTarget} parse failed"); + set2.InvalidLines.Should().BeEmpty(); + set2.Species.Should().Be(set.Species); + set2.Form.Should().Be(set.Form); + + var result = set2.GetText(settingsOriginal); + result.Should().Be(message); + } + } + + [Theory] + [InlineData(SetAllTokenExample)] + public void SimulatorTranslateHABCDS(string message, string languageOriginal = "en") + { + var settingsOriginal = new BattleTemplateExportSettings(BattleTemplateConfig.CommunityStandard, languageOriginal); + if (!ShowdownParsing.TryParseAnyLanguage(message, out var set)) + throw new Exception("Input failed"); + + var target = new BattleTemplateExportSettings("ja") + { + StatsIVs = StatDisplayStyle.HABCDS, + StatsEVs = StatDisplayStyle.HABCDS, + }; + + var translated = set.GetText(target); + translated.Should().NotBeNullOrEmpty(); + translated.Should().NotBe(message); + + // Convert back, should be 1:1 + if (!ShowdownParsing.TryParseAnyLanguage(translated, out var set2)) + throw new Exception("ja parse failed"); + set2.InvalidLines.Should().BeEmpty(); + set2.Species.Should().Be(set.Species); + set2.Form.Should().Be(set.Form); + + var result = set2.GetText(settingsOriginal); + result.Should().Be(message); + } + [Theory] [InlineData(SetDuplicateMoves, 3)] public void SimulatorParseDuplicate(string text, int moveCount) @@ -261,6 +324,24 @@ Modest Nature - Hyper Voice """; + private const string SetAllTokenExample = + """ + Pikachu (F) @ Oran Berry + Ability: Static + Level: 69 + Shiny: Yes + Friendship: 42 + Dynamax Level: 3 + Gigantamax: Yes + EVs: 12 HP / 5 Atk / 6 Def / 17 SpA / 4 SpD / 101 Spe + Quirky Nature + IVs: 30 HP / 22 Atk / 29 Def / 7 SpA / 1 SpD / 0 Spe + - Pound + - Sky Attack + - Hyperspace Fury + - Metronome + """; + private const string SetSmeargle = """ Smeargle @ Focus Sash