Feature: Localization of Battle Templates (Showdown Set) (#4482)

* Localization capability for each language, import & export
* Lines with stat names (IVs/EVs) can be configured to many representations (X/X/X/X/X/X, HABCDS, etc).
* Add nonstandard localizations
* Add token types for Showdown's new set format
* Add new program settings for hover & export styles. Allows users to select which presentation format they want for the hover previews, as well as the set export format.
* Revises preview hover GUI to use new settings
* Revises export events to use new settings
* Moves no longer indicate end of set
* Enhance robustness of stat parsing
* Expand all settings in settings editor on form load
* Extract clipboard -> sets operation to api for maintainability & reusability
This commit is contained in:
Kurt 2025-05-01 23:16:36 -05:00 committed by GitHub
parent 63516fc718
commit f730f7d19a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 2985 additions and 941 deletions

View File

@ -0,0 +1,281 @@
using System;
using System.Text;
namespace PKHeX.Core;
/// <summary>
/// Grammar and prefix/suffix tokens for <see cref="IBattleTemplate"/> localization.
/// </summary>
public sealed record BattleTemplateConfig
{
public sealed record BattleTemplateTuple(BattleTemplateToken Token, string Text);
/// <summary> Prefix tokens - e.g. Friendship: {100} </summary>
public required BattleTemplateTuple[] Left { get; init; }
/// <summary> Suffix tokens - e.g. {Timid} Nature </summary>
public required BattleTemplateTuple[] Right { get; init; }
/// <summary> Tokens that always display the same text, with no value - e.g. Shiny: Yes </summary>
public required BattleTemplateTuple[] Center { get; init; }
/// <summary>
/// Stat names, ordered with speed in the middle (not last).
/// </summary>
public required StatDisplayConfig StatNames { get; init; }
/// <summary>
/// Stat names, ordered with speed in the middle (not last).
/// </summary>
public required StatDisplayConfig StatNamesFull { get; init; }
public required string Male { get; init; }
public required string Female { get; init; }
/// <summary>
/// Gets the stat names in the requested format.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException"></exception>
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<char> 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<BattleTemplateToken> 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<BattleTemplateToken> Showdown => CommunityStandard;
public static ReadOnlySpan<BattleTemplateToken> 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<BattleTemplateToken> 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.
];
/// <summary>
/// Tries to parse the line for a token and value, if applicable.
/// </summary>
/// <param name="line">Line to parse</param>
/// <param name="value">Value for the token, if applicable</param>
/// <returns>Token type that was found</returns>
public BattleTemplateToken TryParse(ReadOnlySpan<char> line, out ReadOnlySpan<char> 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");
}
/// <summary>
/// Gets the string representation of the token. No value is combined with it.
/// </summary>
public string Push(BattleTemplateToken token) => GetToken(token, out _);
/// <summary>
/// Gets the string representation of the token, and combines the value with it.
/// </summary>
public string Push<T>(BattleTemplateToken token, T value)
{
var str = GetToken(token, out var isLeft);
if (isLeft)
return $"{str}{value}";
return $"{value}{str}";
}
/// <inheritdoc cref="Push{T}(BattleTemplateToken,T)"/>
public void Push<T>(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);
}
/// <summary>
/// Checks all representations of the stat name for a match.
/// </summary>
/// <param name="stat">Stat name</param>
/// <returns>-1 if not found, otherwise the index of the stat</returns>
public int GetStatIndex(ReadOnlySpan<char> 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<char> message, Span<int> bestResult)
{
var result = ParseInternal(message, bestResult);
ReorderSpeedNotLast(bestResult);
result.TreatAmpsAsSpeedNotLast();
return result;
}
private StatParseResult ParseInternal(ReadOnlySpan<char> message, Span<int> bestResult)
{
Span<int> 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<int> 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<T>(Span<T> arr)
{
ArgumentOutOfRangeException.ThrowIfLessThan(arr.Length, 6);
var speed = arr[5];
arr[5] = arr[4];
arr[4] = arr[3];
arr[3] = speed;
}
}

View File

@ -0,0 +1,12 @@
namespace PKHeX.Core;
/// <summary>
/// Token order for displaying the battle template.
/// </summary>
public enum BattleTemplateDisplayStyle : sbyte
{
Custom = -1,
Showdown = 0, // default
Legacy,
Brief, // default preview hover style
}

View File

@ -0,0 +1,96 @@
using System;
namespace PKHeX.Core;
/// <summary>
/// Settings for exporting a battle template.
/// </summary>
public readonly ref struct BattleTemplateExportSettings
{
/// <summary>
/// Order of the tokens in the export.
/// </summary>
public ReadOnlySpan<BattleTemplateToken> Order { get; init; }
/// <summary>
/// Localization for the battle template.
/// </summary>
public BattleTemplateLocalization Localization { get; }
/// <summary>
/// Display style for the EVs.
/// </summary>
public StatDisplayStyle StatsEVs { get; init; }
/// <summary>
/// Display style for the IVs.
/// </summary>
public StatDisplayStyle StatsIVs { get; init; }
public StatDisplayStyle StatsOther { get; init; }
/// <summary>
/// Display style for the moves.
/// </summary>
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<BattleTemplateToken> order, string language = BattleTemplateLocalization.DefaultLanguage)
{
Localization = BattleTemplateLocalization.GetLocalization(language);
Order = order;
}
public BattleTemplateExportSettings(ReadOnlySpan<BattleTemplateToken> order, LanguageID language)
{
Localization = BattleTemplateLocalization.GetLocalization(language);
Order = order;
}
/// <summary>
/// Checks if the token is in the export.
/// </summary>
public bool IsTokenInExport(BattleTemplateToken token)
{
foreach (var t in Order)
{
if (t == token)
return true;
}
return false;
}
/// <summary>
/// Gets the index of the token in the export.
/// </summary>
public int GetTokenIndex(BattleTemplateToken token)
{
for (int i = 0; i < Order.Length; i++)
{
if (Order[i] == token)
return i;
}
return -1;
}
/// <summary>
/// Checks if the token is in the export.
/// </summary>
/// <remarks>Should be a static method, but is not because it feels better this way.</remarks>
/// <param name="token">Token to check</param>
/// <param name="tokens">Tokens to check against</param>
public bool IsTokenInExport(BattleTemplateToken token, ReadOnlySpan<BattleTemplateToken> tokens)
{
foreach (var t in tokens)
{
if (t == token)
return true;
}
return false;
}
}

View File

@ -0,0 +1,78 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace PKHeX.Core;
/// <summary>
/// Provides information for localizing <see cref="IBattleTemplate"/> sets.
/// </summary>
/// <param name="Strings">In-game strings</param>
/// <param name="Config">Grammar and prefix/suffix tokens</param>
public sealed record BattleTemplateLocalization(GameStrings Strings, BattleTemplateConfig Config)
{
public const string DefaultLanguage = GameLanguage.DefaultLanguage; // English
private static readonly Dictionary<string, BattleTemplateLocalization> Cache = new();
public static readonly BattleTemplateLocalization Default = GetLocalization(DefaultLanguage);
/// <param name="language"><see cref="LanguageID"/> index</param>
/// <inheritdoc cref="GetLocalization(string)"/>
public static BattleTemplateLocalization GetLocalization(LanguageID language) =>
GetLocalization(language.GetLanguageCode());
/// <summary>
/// Gets the localization for the requested language.
/// </summary>
/// <param name="language">Language code</param>
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;
}
/// <summary>
/// Force loads all localizations.
/// </summary>
public static bool ForceLoadAll()
{
bool anyLoaded = false;
foreach (var lang in GameLanguage.AllSupportedLanguages)
{
if (Cache.ContainsKey(lang))
continue;
_ = GetLocalization(lang);
anyLoaded = true;
}
return anyLoaded;
}
/// <summary>
/// Gets all localizations.
/// </summary>
public static IReadOnlyDictionary<string, BattleTemplateLocalization> GetAll()
{
_ = ForceLoadAll();
return Cache;
}
}
[JsonSerializable(typeof(BattleTemplateConfig))]
public sealed partial class BattleTemplateConfigContext : JsonSerializerContext;

View File

@ -0,0 +1,49 @@
using System.Text.Json.Serialization;
namespace PKHeX.Core;
/// <summary>
/// Enum for the different tokens used in battle templates.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[JsonConverter(typeof(JsonStringEnumConverter<BattleTemplateToken>))]
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,
}

View File

@ -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<BattleTemplateToken> GetOrder(BattleTemplateDisplayStyle style, ReadOnlySpan<BattleTemplateToken> 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,
};
}

View File

@ -23,6 +23,7 @@ public interface IBattleTemplate : ISpeciesForm, IGigantamaxReadOnly, IDynamaxLe
/// <summary>
/// <see cref="PKM.HeldItem"/> of the Set entity.
/// </summary>
/// <remarks>Depends on <see cref="Context"/> for context-specific item lists.</remarks>
int HeldItem { get; }
/// <summary>

View File

@ -0,0 +1,17 @@
namespace PKHeX.Core;
/// <summary>
/// Style to display moves.
/// </summary>
public enum MoveDisplayStyle : byte
{
/// <summary>
/// Moves are slots 1-4, with no empty slots, and correspond to the rectangular grid without empty spaces.
/// </summary>
Fill,
/// <summary>
/// Move slots are assigned to the directional pad, and unused directional slots are not displayed.
/// </summary>
Directional,
}

View File

@ -0,0 +1,37 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
namespace PKHeX.Core;
/// <summary>
/// Logic for retrieving teams from URLs.
/// </summary>
public static class BattleTemplateTeams
{
/// <summary>
/// Tries to check if the input text is a valid URL for a team, and if so, retrieves the team data.
/// </summary>
/// <param name="text">The input text to check.</param>
/// <param name="content">When the method returns, contains the retrieved team data if the text is a valid URL; otherwise, null.</param>
/// <returns><c>true</c> if the text is a valid URL and the team data was successfully retrieved; otherwise, <c>false</c>.</returns>
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;
}
/// <summary>
/// Attempts to retrieve sets from the provided text. If the text is a valid URL, it retrieves the team data from the URL.
/// </summary>
/// <param name="text">The input text to check.</param>
/// <returns>An enumerable collection of <see cref="ShowdownSet"/> objects representing the sets.</returns>
public static IEnumerable<ShowdownSet> TryGetSets(string text)
{
var ingest = TryGetSetLines(text, out var many) ? many : text;
return ShowdownParsing.GetShowdownSets(ingest);
}
}

View File

@ -4,6 +4,12 @@
namespace PKHeX.Core;
/// <summary>
/// Logic for retrieving Showdown teams from URLs.
/// </summary>
/// <remarks>
/// <see href="https://pokepast.es/"/>
/// </remarks>
public static class PokepasteTeam
{
/// <summary>
@ -12,8 +18,17 @@ public static class PokepasteTeam
/// <param name="team">The numeric identifier of the team.</param>
/// <returns>A string containing the full URL to access the team data.</returns>
public static string GetURL(ulong team) => $"https://pokepast.es/{team:x16}/raw";
/// <inheritdoc cref="GetURL"/>
/// <remarks>For legacy team indexes (first 255 or so), shouldn't ever be triggered non-test team indexes.</remarks>
public static string GetURLOld(int team) => $"https://pokepast.es/{team}/raw";
/// <summary>
/// Attempts to retrieve the Showdown team data from a specified URL, and reformats it.
/// </summary>
/// <param name="url">The URL to retrieve the team data from.</param>
/// <param name="content">When the method returns, contains the processed team data if retrieval and formatting succeed; otherwise, null.</param>
/// <returns><c>true</c> if the team data is successfully retrieved and reformatted; otherwise, <c>false</c>.</returns>
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
/// </summary>
/// <param name="text">The text to evaluate.</param>
/// <param name="url">When the method returns, contains the normalized API URL if the text represents a valid Showdown team URL; otherwise, null.</param>
/// <param name="hash"></param>
/// <returns><c>true</c> if the text is a valid Showdown team URL; otherwise, <c>false</c>.</returns>
public static bool IsURL(ReadOnlySpan<char> text, [NotNullWhen(true)] out string? url)
{

View File

@ -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", ""];
/// <inheritdoc cref="ShowdownSet.DefaultListAllocation"/>
private const int DefaultListAllocation = ShowdownSet.DefaultListAllocation;
/// <summary>
/// Gets the Form ID from the input <see cref="name"/>.
/// </summary>
@ -147,12 +151,13 @@ public static string SetShowdownFormName(ushort species, string form, int abilit
/// Fetches <see cref="ShowdownSet"/> data from the input <see cref="lines"/>.
/// </summary>
/// <param name="lines">Raw lines containing numerous multi-line set data.</param>
/// <param name="localization">Localization data for the set.</param>
/// <returns><see cref="ShowdownSet"/> objects until <see cref="lines"/> is consumed.</returns>
public static IEnumerable<ShowdownSet> GetShowdownSets(IEnumerable<string> lines)
public static IEnumerable<ShowdownSet> GetShowdownSets(IEnumerable<string> 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<string>(8);
var setLines = new List<string>(DefaultListAllocation);
foreach (var line in lines)
{
if (!string.IsNullOrWhiteSpace(line))
@ -162,14 +167,54 @@ public static IEnumerable<ShowdownSet> GetShowdownSets(IEnumerable<string> 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);
}
/// <inheritdoc cref="GetShowdownSets(IEnumerable{string})"/>
/// <inheritdoc cref="GetShowdownSets(IEnumerable{string},BattleTemplateLocalization)"/>
public static IEnumerable<ShowdownSet> GetShowdownSets(IEnumerable<string> lines)
{
var setLines = new List<string>(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);
}
/// <inheritdoc cref="GetShowdownSets(IEnumerable{string},BattleTemplateLocalization)"/>
public static IEnumerable<ShowdownSet> GetShowdownSets(ReadOnlyMemory<char> 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);
}
/// <inheritdoc cref="GetShowdownSets(IEnumerable{string},BattleTemplateLocalization)"/>
/// <summary>
/// Language-unknown version of <see cref="GetShowdownSets(IEnumerable{string},BattleTemplateLocalization)"/>.
/// </summary>
public static IEnumerable<ShowdownSet> GetShowdownSets(ReadOnlyMemory<char> text)
{
int start = 0;
@ -186,17 +231,15 @@ public static IEnumerable<ShowdownSet> GetShowdownSets(ReadOnlyMemory<char> text
while (start < text.Length);
}
/// <inheritdoc cref="GetShowdownSets(ReadOnlyMemory{char})"/>
public static IEnumerable<ShowdownSet> GetShowdownSets(string text) => GetShowdownSets(text.AsMemory());
/// <inheritdoc cref="GetShowdownSets(ReadOnlyMemory{char},BattleTemplateLocalization)"/>
public static IEnumerable<ShowdownSet> GetShowdownSets(string text, BattleTemplateLocalization localization) => GetShowdownSets(text.AsMemory(), localization);
private static int GetLength(ReadOnlySpan<char> 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<char> 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<char> text, out int length)
/// <summary>
/// Attempts to parse the input <see cref="text"/> into a <see cref="ShowdownSet"/> object.
/// </summary>
/// <param name="text">Input string to parse.</param>
/// <param name="localization">Input localization to use.</param>
/// <param name="length">Amount of characters consumed from the input string.</param>
/// <returns>Parsed <see cref="ShowdownSet"/> object if successful, otherwise might be a best-match with some/all unparsed lines.</returns>
public static ShowdownSet GetShowdownSet(ReadOnlySpan<char> 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;
}
/// <inheritdoc cref="GetShowdownSet(ReadOnlySpan{char},BattleTemplateLocalization,out int)"/>
public static ShowdownSet GetShowdownSet(ReadOnlySpan<char> 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;
}
/// <inheritdoc cref="GetShowdownSets(ReadOnlyMemory{char},BattleTemplateLocalization)"/>
public static IEnumerable<ShowdownSet> GetShowdownSets(string text) => GetShowdownSets(text.AsMemory());
/// <inheritdoc cref="GetShowdownText(PKM, in BattleTemplateExportSettings)"/>
public static string GetShowdownText(PKM pk) => GetShowdownText(pk, BattleTemplateExportSettings.Showdown);
/// <summary>
/// Converts the <see cref="PKM"/> data into an importable set format for Pokémon Showdown.
/// </summary>
/// <param name="pk">PKM to convert to string</param>
/// <param name="settings">Import localization/style setting</param>
/// <returns>Multi line set data</returns>
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);
}
/// <summary>
/// Fetches ShowdownSet lines from the input <see cref="PKM"/> data.
/// </summary>
/// <param name="data">Pokémon data to summarize.</param>
/// <param name="lang">Localization setting</param>
/// <param name="settings">Export localization/style setting</param>
/// <returns>Consumable list of <see cref="ShowdownSet.Text"/> lines.</returns>
public static IEnumerable<string> GetShowdownText(IEnumerable<PKM> data, string lang = ShowdownSet.DefaultLanguage)
public static IEnumerable<string> GetShowdownText(IEnumerable<PKM> data, in BattleTemplateExportSettings settings)
{
List<string> result = new();
var sets = GetShowdownSets(data);
foreach (var set in sets)
yield return set.LocalizedText(lang);
result.Add(set.GetText(settings));
return result;
}
/// <summary>
@ -266,24 +337,123 @@ public static IEnumerable<ShowdownSet> GetShowdownSets(IEnumerable<PKM> data)
}
}
/// <inheritdoc cref="GetShowdownSets(IEnumerable{string},BattleTemplateLocalization)"/>
public static string GetShowdownSets(IEnumerable<PKM> data, string separator) => string.Join(separator, GetShowdownText(data, BattleTemplateExportSettings.Showdown));
/// <summary>
/// Fetches ShowdownSet lines from the input <see cref="PKM"/> data, and combines it into one string.
/// </summary>
/// <param name="data">Pokémon data to summarize.</param>
/// <param name="separator">Splitter between each set.</param>
/// <param name="settings">Import localization/style setting</param>
/// <returns>Single string containing all <see cref="ShowdownSet.Text"/> lines.</returns>
public static string GetShowdownSets(IEnumerable<PKM> data, string separator) => string.Join(separator, GetShowdownText(data));
public static string GetShowdownSets(IEnumerable<PKM> data, string separator, in BattleTemplateExportSettings settings) => string.Join(separator, GetShowdownText(data, settings));
/// <summary>
/// Gets a localized string preview of the provided <see cref="pk"/>.
/// </summary>
/// <param name="pk">Pokémon data</param>
/// <param name="language">Language code</param>
/// <param name="settings">Export settings</param>
/// <returns>Multi-line string</returns>
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);
}
/// <summary>
/// Tries to parse the input string into a <see cref="ShowdownSet"/> object.
/// </summary>
/// <param name="message">Input string to parse.</param>
/// <param name="set">Parsed <see cref="ShowdownSet"/> object if successful, otherwise might be a best-match with some unparsed lines.</param>
/// <returns>True if the input was parsed successfully, false otherwise.</returns>
public static bool TryParseAnyLanguage(ReadOnlySpan<char> 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;
}
/// <inheritdoc cref="TryParseAnyLanguage(ReadOnlySpan{char}, out ShowdownSet?)"/>
public static bool TryParseAnyLanguage(IReadOnlyList<string> 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;
}
/// <summary>
/// Tries to translate the input battle template <see cref="message"/> into a localized string.
/// </summary>
/// <param name="message">Input string to parse.</param>
/// <param name="outputSettings">Export settings</param>
/// <param name="translated">Translated string if successful.</param>
/// <returns><c>true</c> if the input was translated successfully, <c>false</c> otherwise.</returns>
public static bool TryTranslate(ReadOnlySpan<char> message, BattleTemplateExportSettings outputSettings, [NotNullWhen(true)] out string? translated)
{
translated = null;
if (!TryParseAnyLanguage(message, out var set))
return false;
translated = set.GetText(outputSettings);
return true;
}
/// <inheritdoc cref="TryTranslate(ReadOnlySpan{char}, BattleTemplateExportSettings, out string?)"/>
public static bool TryTranslate(IReadOnlyList<string> message, BattleTemplateExportSettings outputSettings, [NotNullWhen(true)] out string? translated)
{
translated = null;
if (!TryParseAnyLanguage(message, out var set))
return false;
translated = set.GetText(outputSettings);
return true;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,9 @@ namespace PKHeX.Core;
/// <summary>
/// Logic for retrieving Showdown teams from URLs.
/// </summary>
/// <remarks>
/// <see href="https://play.pokemonshowdown.com/"/>
/// </remarks>
public static class ShowdownTeam
{
/// <summary>
@ -82,7 +85,9 @@ public static bool IsURL(ReadOnlySpan<char> 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);

View File

@ -0,0 +1,338 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Text;
namespace PKHeX.Core;
/// <summary>
/// Configuration for displaying stats.
/// </summary>
[TypeConverter(typeof(ExpandableObjectConverter))]
public sealed class StatDisplayConfig
{
/// <summary>
/// Stat names are displayed without localization; H:X A:X B:X C:X D:X S:X
/// </summary>
public static readonly StatDisplayConfig HABCDS = new()
{
Names = ["H", "A", "B", "C", "D", "S"],
Separator = " ",
ValueGap = ":",
IsLeft = true,
AlwaysShow = true,
};
/// <summary>
/// Stat names are displayed without localization; X/X/X/X/X/X
/// </summary>
/// <remarks>
/// Same as <see cref="Raw00"/> but with no leading zeroes.
/// </remarks>
public static readonly StatDisplayConfig Raw = new()
{
Names = [],
Separator = "/",
ValueGap = "",
AlwaysShow = true,
};
/// <summary>
/// Stat names are displayed without localization; XX/XX/XX/XX/XX/XX
/// </summary>
/// <remarks>
/// Same as <see cref="Raw"/> but with 2 digits (leading zeroes).
/// </remarks>
public static readonly StatDisplayConfig Raw00 = new()
{
Names = [],
Separator = "/",
ValueGap = "",
AlwaysShow = true,
MinimumDigits = 2,
};
/// <summary>
/// List of stat display styles that are commonly used and not specific to a localization.
/// </summary>
public static List<StatDisplayConfig> Custom { get; } = [HABCDS, Raw]; // Raw00 parses equivalent to Raw
/// <summary>List of stat names to display</summary>
public required string[] Names { get; init; }
/// <summary>Separator between each stat+value declaration</summary>
public string Separator { get; init; } = " / ";
/// <summary>Separator between the stat name and value</summary>
public string ValueGap { get; init; } = " ";
/// <summary><c>true</c> if the text is displayed on the left side of the value</summary>
public bool IsLeft { get; init; }
/// <summary><c>true</c> if the stat is always shown, even if the value is default</summary>
public bool AlwaysShow { get; init; }
/// <summary>Minimum number of digits to show for the stat value.</summary>
public int MinimumDigits { get; init; }
/// <summary>
/// Gets the index of the displayed stat name (in visual order) via a case-insensitive search.
/// </summary>
/// <param name="stat">Stat name, trimmed.</param>
/// <returns>-1 if not found, otherwise the index of the stat name.</returns>
public int GetStatIndex(ReadOnlySpan<char> 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);
/// <summary>
/// Formats a stat value into a string builder.
/// </summary>
/// <param name="sb">Result string builder</param>
/// <param name="statIndex">Display index of the stat</param>
/// <param name="statValue">Stat value</param>
/// <param name="valueSuffix">Optional suffix for the value, to display a stat amplification request</param>
/// <param name="skipValue"><c>true</c> to skip the value, only displaying the stat name and amplification (if provided)</param>
public void Format<T>(StringBuilder sb, int statIndex, T statValue, ReadOnlySpan<char> 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<T>(StringBuilder sb, ReadOnlySpan<char> statName, T statValue, ReadOnlySpan<char> 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<T>(ReadOnlySpan<char> statName, T statValue, ReadOnlySpan<char> 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;
}
/// <summary>
/// Gets the separator character used for parsing.
/// </summary>
private char GetSeparatorParse() => GetSeparatorParse(Separator);
private static char GetSeparatorParse(ReadOnlySpan<char> sep) => sep.Length switch
{
0 => ' ',
1 => sep[0],
_ => sep.Trim()[0]
};
/// <summary>
/// Imports a list of stats from a string.
/// </summary>
/// <param name="message">Input string</param>
/// <param name="result">Result storage</param>
/// <returns>Parse result</returns>
public StatParseResult TryParse(ReadOnlySpan<char> message, Span<int> 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<char> message, Span<int> result, char separator, ReadOnlySpan<char> 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<char> message, Span<int> result, char separator, ReadOnlySpan<char> 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;
}
/// <summary>
/// Parses a raw stat string.
/// </summary>
/// <param name="message">Input string</param>
/// <param name="result">Output storage</param>
/// <param name="separator">Separator character</param>
public static StatParseResult TryParseRaw(ReadOnlySpan<char> message, Span<int> 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<int> result, ref StatParseResult rec, ReadOnlySpan<char> 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<char> 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;
}
}

View File

@ -0,0 +1,37 @@
namespace PKHeX.Core;
/// <summary>
/// Style to display stat names.
/// </summary>
public enum StatDisplayStyle : sbyte
{
Custom = -1,
/// <summary>
/// Stat names are displayed in abbreviated (2-3 characters) localized text.
/// </summary>
Abbreviated,
/// <summary>
/// Stat names are displayed in full localized text.
/// </summary>
Full,
/// <summary>
/// Stat names are displayed as a single character.
/// </summary>
/// <remarks>
/// This is the typical format used by the Japanese community; HABCDS.
/// </remarks>
HABCDS,
/// <summary>
/// Stat names are displayed without localization; X/X/X/X/X/X
/// </summary>
Raw,
/// <summary>
/// Stat names are displayed without localization; XX/XX/XX/XX/XX/XX
/// </summary>
Raw00,
}

View File

@ -0,0 +1,128 @@
using System;
namespace PKHeX.Core;
/// <summary>
/// Value result object of parsing a stat string.
/// </summary>
public record struct StatParseResult()
{
private const uint MaxStatCount = 6; // Number of stats in the game
private const sbyte NoStatAmp = -1;
/// <summary>
/// Count of parsed stats.
/// </summary>
public byte CountParsed { get; private set; } = 0; // could potentially make this a computed value (popcnt), but it's not worth it
/// <summary>
/// Indexes of parsed stats.
/// </summary>
public byte IndexesParsed { get; private set; } = 0;
/// <summary>
/// Stat index of increased stat.
/// </summary>
public sbyte Plus { get; set; } = NoStatAmp;
/// <summary>
/// Stat index of decreased stat.
/// </summary>
public sbyte Minus { get; set; } = NoStatAmp;
/// <summary>
/// Indicates if the parsing was clean (no un-parsed text).
/// </summary>
public bool IsParseClean { get; private set; } = true;
/// <summary>
/// Indicates if all stat indexes available were parsed.
/// </summary>
public bool IsParsedAllStats { get; private set; } = false;
/// <summary>
/// Marks the stat index as parsed, and updates the count of parsed stats.
/// </summary>
/// <param name="statIndex">Visual index of the stat to mark as parsed.</param>
/// <returns>True if the stat had not been parsed before, false if it was already parsed.</returns>
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;
}
/// <summary>
/// Checks if the stat index was parsed.
/// </summary>
/// <param name="statIndex">Visual index of the stat to check.</param>
/// <returns>True if the stat was parsed, false otherwise.</returns>
public bool WasParsed(int statIndex)
{
// Check if the stat index is valid (0-5)
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)statIndex, MaxStatCount);
return (IndexesParsed & (1 << statIndex)) != 0;
}
/// <summary>
/// Marks the parsing as finished, and updates the internal state to indicate if all stats were parsed.
/// </summary>
/// <remarks>
/// This is used when not all stats are required to be parsed.
/// </remarks>
/// <param name="expect"></param>
public void FinishParse(int expect)
{
if (CountParsed == 0 && !HasAmps)
MarkDirty();
IsParsedAllStats = CountParsed == expect || IsParseClean;
}
/// <summary>
/// Marks the parsing as finished, and updates the internal state to indicate if all stats were parsed.
/// </summary>
/// <remarks>
/// This is used when a specific number of stats is expected.
/// </remarks>
/// <param name="expect"></param>
public void FinishParseOnly(int expect) => IsParsedAllStats = CountParsed == expect;
/// <summary>
/// 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).
/// </summary>
public void MarkDirty() => IsParseClean = false;
/// <summary>
/// Indicates if any stat has any amplified (+/-) requested, indicative of nature.
/// </summary>
public bool HasAmps => Plus != NoStatAmp || Minus != NoStatAmp;
/// <summary>
/// Reorders the speed stat to be in the middle of the stats.
/// </summary>
/// <remarks>
/// Speed is visually represented as the last stat in the list, but it is actually the 3rd stat stored.
/// </remarks>
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,
};
}

View File

@ -1,784 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text;
using static PKHeX.Core.Species;
namespace PKHeX.Core;
/// <summary>
/// Logic for exporting and importing <see cref="PKM"/> data in Pokémon Showdown's text format.
/// </summary>
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<ushort> 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;
/// <summary>
/// Any lines that failed to be parsed.
/// </summary>
public readonly List<string> InvalidLines = new(0);
private GameStrings Strings { get; set; } = DefaultStrings;
/// <summary>
/// Loads a new <see cref="ShowdownSet"/> from the input string.
/// </summary>
/// <param name="input">Single-line string which will be split before loading.</param>
public ShowdownSet(ReadOnlySpan<char> input) => LoadLines(input.EnumerateLines());
/// <summary>
/// Loads a new <see cref="ShowdownSet"/> from the input string.
/// </summary>
/// <param name="lines">Enumerable list of lines.</param>
public ShowdownSet(IEnumerable<string> lines) => LoadLines(lines);
private void LoadLines(SpanLineEnumerator lines)
{
ParseLines(lines);
SanitizeResult();
}
private void LoadLines(IEnumerable<string> 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<char> 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<char> 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<string> lines)
{
int movectr = 0;
bool first = true;
foreach (var line in lines)
{
ReadOnlySpan<char> 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<char> 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<char> 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<char> identifier, ReadOnlySpan<char> 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<char> 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<char> value)
{
if (!byte.TryParse(value.Trim(), out var val))
return false;
Friendship = val;
return true;
}
private bool ParseDynamax(ReadOnlySpan<char> value)
{
Context = EntityContext.Gen8;
var val = Util.ToInt32(value);
if ((uint)val > 10)
return false;
DynamaxLevel = (byte)val;
return true;
}
private bool ParseTeraType(ReadOnlySpan<char> 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;
}
/// <summary>
/// Gets the standard Text representation of the set details.
/// </summary>
public string Text => GetText();
/// <summary>
/// Gets the localized Text representation of the set details.
/// </summary>
/// <param name="lang">Language code</param>
public string LocalizedText(string lang = DefaultLanguage) => LocalizedText(GameLanguage.GetLanguageIndex(lang));
/// <summary>
/// Gets the localized Text representation of the set details.
/// </summary>
/// <param name="lang">Language ID</param>
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<string> GetSetLines()
{
var result = new List<string>();
// 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<T>(ReadOnlySpan<T> stats, T ignoreValue) where T : IEquatable<T>
{
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<string> 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,
};
/// <summary>
/// Forces some properties to indicate the set for future display values.
/// </summary>
/// <param name="pk">PKM to convert to string</param>
public void InterpretAsPreview(PKM pk)
{
if (pk.Format <= 2) // Nature preview from IVs
Nature = Experience.GetNatureVC(pk.EXP);
}
/// <summary>
/// Converts the <see cref="PKM"/> data into an importable set format for Pokémon Showdown.
/// </summary>
/// <param name="pk">PKM to convert to string</param>
/// <returns>New ShowdownSet object representing the input <see cref="pk"/></returns>
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<char> 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<char> 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<char> 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<char> 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<char> 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<char> line)
{
// Entering into this method requires both ( and ) to be present within the input line.
int index = line.LastIndexOf('(');
ReadOnlySpan<char> species;
ReadOnlySpan<char> 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<char> ParseLineMove(ReadOnlySpan<char> 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<char> GetHiddenPowerType(ReadOnlySpan<char> 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<char> 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<char> 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<char> 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<char> 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;
}
}
}

View File

@ -58,7 +58,7 @@ public static class LanguageGCRemap
/// </summary>
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
/// </summary>
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,

View File

@ -1,4 +1,4 @@
namespace PKHeX.Core;
namespace PKHeX.Core;
/// <summary>
/// Contiguous series Game Language IDs
@ -9,7 +9,7 @@ public enum LanguageID : byte
/// Undefined Language ID, usually indicative of a value not being set.
/// </summary>
/// <remarks>Gen5 Japanese In-game Trades happen to not have their Language value set, and express Language=0.</remarks>
Hacked = 0,
None = 0,
/// <summary>
/// Japanese (日本語)

View File

@ -35,6 +35,8 @@ public static int GetLanguageIndex(string lang)
/// Language codes supported for loading string resources
/// </summary>
/// <see cref="ProgramLanguage"/>
public static ReadOnlySpan<string> AllSupportedLanguages => LanguageCodes;
private static readonly string[] LanguageCodes = ["ja", "en", "fr", "it", "de", "es", "ko", "zh-Hans", "zh-Hant"];
/// <summary>

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -80,9 +80,29 @@ public static class Language
Korean => "ko",
ChineseS => "zh-Hans",
ChineseT => "zh-Hant",
English => "en",
_ => GameLanguage.DefaultLanguage,
};
/// <summary>
/// Gets the <see cref="LanguageID"/> value from a language code.
/// </summary>
/// <param name="language">Language code.</param>
/// <returns>Language ID.</returns>
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),
};
/// <summary>
/// Gets the Main Series language ID from a GameCube (C/XD) language ID.
/// </summary>

View File

@ -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" }
]
}

View File

@ -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" }
]
}

View File

@ -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í" }
]
}

View File

@ -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" }
]
}

View File

@ -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" }
]
}

View File

@ -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": "キョダイマックス: はい" }
]
}

View File

@ -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": "거다이맥스: 예" }
]
}

View File

@ -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": "超极巨: 是的" }
]
}

View File

@ -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": "超極巨: 是的" }
]
}

View File

@ -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

View File

@ -10,7 +10,7 @@ public partial class MoveDisplay : UserControl
{
public MoveDisplay() => InitializeComponent();
public int Populate(PKM pk, ushort move, EntityContext context, ReadOnlySpan<string> moves, bool valid = true)
public int Populate(PKM pk, GameStrings strings, ushort move, EntityContext context, ReadOnlySpan<string> moves, bool valid = true)
{
if (move == 0 || move >= moves.Length)
{
@ -24,7 +24,7 @@ public int Populate(PKM pk, ushort move, EntityContext context, ReadOnlySpan<str
if (move == (int)Core.Move.HiddenPower && pk.Context is not EntityContext.Gen8a)
{
if (HiddenPower.TryGetTypeIndex(pk.HPType, out type))
name = $"{name} ({GameInfo.Strings.types[type]}) [{pk.HPPower}]";
name = $"{name} ({strings.types[type]}) [{pk.HPPower}]";
}
var size = PokePreview.MeasureSize(name, L_Move.Font);

View File

@ -1331,7 +1331,9 @@ public void ClickShowdownExportCurrentBox(object sender, EventArgs e)
private static void ExportShowdownText(SaveFile sav, string success, Func<SaveFile, IEnumerable<PKM>> 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))

View File

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

View File

@ -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<char> 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<char> 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<byte> gvs = stackalloc byte[6];
g.GetGVs(gvs);
TryAdd<byte>(sb, "GVs", gvs);
var statNames = settings.Localization.Config.GetStatDisplay(settings.StatsOther);
var value = TryAdd<byte>(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<byte> avs = stackalloc byte[6];
a.GetAVs(avs);
TryAdd<byte>(sb, "AVs", avs);
var statNames = settings.Localization.Config.GetStatDisplay(settings.StatsOther);
var value = TryAdd<byte>(avs, statNames);
if (value.Length == 0)
return;
var result = settings.Localization.Config.Push(BattleTemplateToken.AVs, value);
line += Environment.NewLine + result;
}
private static void TryAdd<T>(StringBuilder sb, [ConstantExpected] string type, ReadOnlySpan<T> stats, T ignore = default) where T : unmanaged, IEquatable<T>
private static string TryAdd<T>(ReadOnlySpan<T> stats, StatDisplayConfig statNames, T ignore = default) where T : unmanaged, IEquatable<T>
{
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);
}
/// <summary> Prevent stealing focus from the form that shows this. </summary>

View File

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

View File

@ -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<string> { text, string.Empty };
var result = new List<string>(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);
}

View File

@ -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);

View File

@ -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();

View File

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

View File

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

View File

@ -10,9 +10,11 @@ public class ShowdownSetTests
[Fact]
public void SimulatorGetParse()
{
var settings = new BattleTemplateExportSettings(BattleTemplateConfig.CommunityStandard);
foreach (ReadOnlySpan<char> 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