PKHeX/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownSet.cs
Kurt 2c541ad422
Update to .NET 10 (#4676)
* Update to .NET 10
* Property fields
* API signature updates
* Extension method blocks

* Completed dark mode support
  Outside of my control:
- vertical tab control (pkm editor)
- datetimepicker controls
- lgpe event flags (no idea)
- some control types having white-borders when they should really be gray

Box background is 50% transparency to effectively darken the image.

* Custom legality report popup
* Event diff dialog, version select dialog
* Add quick overwrite popup for export sav
* Extension methods
* Dark Mode: glow currently editing sprite
* Add invalid encounter hint for trade evolutions
* Extension properties
* Append legality hint on hover card
* Slot image loading: clear the screen-reader description if a slot is empty/invalid, rather than retain the previous description. Changing boxes would easily confuse users on this.
2025-12-31 01:42:05 -06:00

1127 lines
40 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Text;
using static PKHeX.Core.Species;
using static PKHeX.Core.BattleTemplateParseErrorType;
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 const char ItemSplit = '@';
private const int MAX_SPECIES = (int)MAX_COUNT - 1;
private const int MaxMoveCount = 4;
private const string DefaultLanguage = BattleTemplateLocalization.DefaultLanguage; // English
private static BattleTemplateLocalization DefaultStrings => BattleTemplateLocalization.Default;
private static ReadOnlySpan<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; } = Experience.MaxLevel;
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<BattleTemplateParseError> InvalidLines = new(0);
private void LogError(BattleTemplateParseErrorType type, ReadOnlySpan<char> text = default)
=> InvalidLines.Add(new(type, text.ToString()));
/// <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>
/// <param name="localization">Localization to parse the lines with.</param>
public ShowdownSet(ReadOnlySpan<char> input, BattleTemplateLocalization? localization = null) => LoadLines(input.EnumerateLines(), localization ?? DefaultStrings);
/// <summary>
/// Loads a new <see cref="ShowdownSet"/> from the input string.
/// </summary>
/// <param name="lines">Enumerable list of lines.</param>
/// <param name="localization">Localization to parse the lines with.</param>
public ShowdownSet(IEnumerable<string> lines, BattleTemplateLocalization? localization = null) => LoadLines(lines, localization ?? DefaultStrings);
private void LoadLines(SpanLineEnumerator lines, BattleTemplateLocalization localization)
{
ParseLines(lines, localization);
SanitizeResult(localization);
}
private void LoadLines(IEnumerable<string> lines, BattleTemplateLocalization localization)
{
ParseLines(lines, localization);
SanitizeResult(localization);
}
private void SanitizeResult(BattleTemplateLocalization localization)
{
ReviseContextIfPastGenForm(localization.Strings);
if (localization.Strings.Language == LanguageID.English)
FormName = ShowdownParsing.GetFormNameFromShowdownFormName(Species, FormName, Ability);
Form = ShowdownParsing.GetFormFromString(FormName, localization.Strings, Species, Context);
// Handle edge case with fixed-gender forms.
if (SpeciesCategory.IsFormGenderSpecific(Species))
ReviseGenderedForms();
}
private void ReviseContextIfPastGenForm(GameStrings strings)
{
if (FormName.Length == 0)
return; // no form name
if (FormInfo.HasTotemForm(Species) && ShowdownParsing.IsTotemForm(FormName))
Context = EntityContext.Gen7;
else if (Species is (int)Pikachu && ShowdownParsing.IsCosplayPikachu(FormName, strings.forms))
Context = EntityContext.Gen6;
}
private void ReviseGenderedForms()
{
if (Gender == 1) // Recognized with (F)
{
FormName = "F";
Form = 1;
}
else
{
FormName = Form == 1 ? "F" : "M";
Gender = Form;
}
}
// Skip lines that are too short or too long.
// Longest line is ~74 (Gen2 EVs)
// Length permitted: 3-80
// The shortest Pokémon name in Japanese is "ニ" (Ni) which is the name for the Pokémon, Nidoran♂ (male Nidoran). It has only one letter.
// We will handle this 1-2 letter edge case only if the line is the first line of the set, in the rare chance we are importing for a non-English language?
private const int MinLength = 3;
private const int MaxLength = 80;
private static bool IsLengthOutOfRange(ReadOnlySpan<char> trim) => IsLengthOutOfRange(trim.Length);
private static bool IsLengthOutOfRange(int length) => (uint)(length - MinLength) > MaxLength - MinLength;
private void ParseLines(SpanLineEnumerator lines, BattleTemplateLocalization localization)
{
int countMoves = 0;
bool first = true;
foreach (var line in lines)
{
var trim = line.Trim();
if (IsLengthOutOfRange(trim))
{
// Try for other languages just in case.
if (first && trim.Length != 0)
{
ParseFirstLine(trim, localization.Strings);
first = false;
continue;
}
LogError(LineLength, line);
continue;
}
if (first)
{
ParseFirstLine(trim, localization.Strings);
first = false;
continue;
}
ParseLine(trim, ref countMoves, localization);
}
}
private void ParseLines(IEnumerable<string> lines, BattleTemplateLocalization localization)
{
int countMoves = 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, localization.Strings);
first = false;
continue;
}
LogError(LineLength, line);
continue;
}
if (first)
{
ParseFirstLine(trim, localization.Strings);
first = false;
continue;
}
ParseLine(trim, ref countMoves, localization);
}
}
private void ParseLine(ReadOnlySpan<char> line, ref int countMoves, BattleTemplateLocalization localization)
{
// Check the first char, to see if it is a move input.
var firstChar = line[0];
if (firstChar is '-' or '')
{
ParseMoveLine(line, ref countMoves, localization);
return;
}
// Check if it is a directional move input.
var dirMove = BattleTemplateConfig.GetMoveDisplay(MoveDisplayStyle.Directional);
var dirMoveIndex = dirMove.IndexOf(firstChar);
if (dirMoveIndex != -1)
{
ParseMoveLineIndex(line, ref countMoves, localization, dirMoveIndex);
return;
}
// Check if it is an Ability selection/line
if (firstChar is '[' or '@')
{
ParseLineAbilityBracket(line, localization.Strings);
return;
}
// Otherwise, tokenized input line.
var token = localization.Config.TryParse(line, out var value);
if (token == BattleTemplateToken.None)
{
LogError(TokenUnknown, line);
return;
}
var valid = ParseEntry(token, value, localization);
if (!valid)
LogError(TokenFailParse, line);
}
private void ParseMoveLine(ReadOnlySpan<char> line, ref int countMoves, BattleTemplateLocalization localization)
{
if (countMoves >= MaxMoveCount)
{
LogError(MoveCountTooMany, line);
return;
}
TryAddMoveAtIndex(line, ref countMoves, localization, countMoves);
}
private void ParseMoveLineIndex(ReadOnlySpan<char> line, ref int countMoves, BattleTemplateLocalization localization, int index)
{
if (Moves[index] != 0)
{
LogError(MoveSlotAlreadyUsed, line);
return;
}
TryAddMoveAtIndex(line, ref countMoves, localization, index);
}
private void TryAddMoveAtIndex(ReadOnlySpan<char> line, ref int countMoves, BattleTemplateLocalization localization, int index)
{
var strings = localization.Strings;
var movelist = strings.movelist;
var moveString = ParseLineMove(line, strings);
int move = StringUtil.FindIndexIgnoreCase(movelist, moveString);
if (move < 0)
{
LogError(MoveUnrecognized, moveString);
}
else if (Moves.Contains((ushort)move))
{
LogError(MoveDuplicate, moveString);
}
else
{
Moves[index] = (ushort)move;
countMoves++;
}
}
private void ParseLineAbilityBracket(ReadOnlySpan<char> line, GameStrings localizationStrings)
{
// Try to peel off Held Item if it is specified.
var itemStart = line.IndexOf(ItemSplit);
if (itemStart != -1)
{
var itemName = line[(itemStart + 1)..].TrimStart();
if (!ParseItemName(itemName, localizationStrings))
LogError(ItemUnrecognized, itemName);
line = line[..itemStart];
}
// Remainder should be [Ability]
var abilityEnd = line.IndexOf(']');
if (abilityEnd == -1 || line.Length == 1) // '[' should be present if ']' is; length check.
{
LogError(AbilityDeclaration, line);
return; // invalid line
}
var abilityName = line[1..abilityEnd].Trim();
var abilityIndex = StringUtil.FindIndexIgnoreCase(localizationStrings.abilitylist, abilityName);
if (abilityIndex < 0)
{
LogError(AbilityUnrecognized, abilityName);
return; // invalid line
}
Ability = abilityIndex;
}
private bool ParseEntry(BattleTemplateToken token, ReadOnlySpan<char> value, BattleTemplateLocalization localization) => token switch
{
BattleTemplateToken.Ability => ParseLineAbility(value, localization.Strings.abilitylist),
BattleTemplateToken.Nature => ParseLineNature(value, localization.Strings.natures),
BattleTemplateToken.Shiny => Shiny = true,
BattleTemplateToken.Gigantamax => CanGigantamax = true,
BattleTemplateToken.HeldItem => ParseItemName(value, localization.Strings),
BattleTemplateToken.Nickname => ParseNickname(value),
BattleTemplateToken.Gender => ParseGender(value, localization.Config),
BattleTemplateToken.Friendship => ParseFriendship(value),
BattleTemplateToken.EVs => ParseLineEVs(value, localization),
BattleTemplateToken.IVs => ParseLineIVs(value, localization.Config),
BattleTemplateToken.Level => ParseLevel(value),
BattleTemplateToken.DynamaxLevel => ParseDynamax(value),
BattleTemplateToken.TeraType => ParseTeraType(value, localization.Strings.types),
_ => false,
};
private bool ParseLineAbility(ReadOnlySpan<char> value, ReadOnlySpan<string> abilityNames)
{
var index = StringUtil.FindIndexIgnoreCase(abilityNames, value);
if (index < 0)
{
LogError(AbilityUnrecognized, value);
return false;
}
if (Ability != -1 && Ability != index)
{
LogError(AbilityAlreadySpecified, value);
return false;
}
Ability = index;
return true;
}
private bool ParseLineNature(ReadOnlySpan<char> value, ReadOnlySpan<string> natureNames)
{
var index = StringUtil.FindIndexIgnoreCase(natureNames, value);
if (index < 0)
return false;
var nature = (Nature)index;
if (!nature.IsFixed())
{
LogError(NatureUnrecognized, value);
return false;
}
if (Nature != Nature.Random && Nature != nature)
{
LogError(NatureAlreadySpecified, value);
return false;
}
Nature = nature;
return true;
}
private bool ParseNickname(ReadOnlySpan<char> value)
{
if (value.Length == 0)
return false;
// ignore length, but generally should be <= the Context's max length
Nickname = value.ToString();
return true;
}
private bool ParseGender(ReadOnlySpan<char> value, BattleTemplateConfig cfg)
{
if (value.Equals(cfg.Male, StringComparison.OrdinalIgnoreCase))
{
Gender = EntityGender.Male;
return true;
}
if (value.Equals(cfg.Female, StringComparison.OrdinalIgnoreCase))
{
Gender = EntityGender.Female;
return true;
}
return false;
}
private bool ParseLevel(ReadOnlySpan<char> value)
{
if (!byte.TryParse(value.Trim(), out var val))
return false;
if ((uint)val is 0 or > Experience.MaxLevel)
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, ReadOnlySpan<string> types)
{
Context = EntityContext.Gen9;
var val = StringUtil.FindIndexIgnoreCase(types, value);
if (val < 0)
return false;
if (val == TeraTypeUtil.StellarTypeDisplayStringIndex)
val = TeraTypeUtil.Stellar;
TeraType = (MoveType)val;
return true;
}
/// <summary>
/// Gets the standard Text representation of the set details.
/// </summary>
public string Text => GetText(BattleTemplateExportSettings.Showdown);
/// <inheritdoc cref="Text"/>
/// <param name="language">Language code</param>
public string GetText(string language = DefaultLanguage) => GetText(new BattleTemplateExportSettings(language));
/// <inheritdoc cref="Text"/>
/// <param name="language">Language ID</param>
public string GetText(LanguageID language) => GetText(new BattleTemplateExportSettings(language));
/// <inheritdoc cref="Text"/>
/// <param name="settings">Export settings</param>
public string GetText(in BattleTemplateExportSettings settings)
{
if (Species is 0 or > MAX_SPECIES)
return string.Empty;
var result = GetSetLines(settings);
return string.Join(Environment.NewLine, result);
}
/// <inheritdoc cref="GetSetLines(in BattleTemplateExportSettings)"/>
public List<string> GetSetLines(string language = DefaultLanguage) => GetSetLines(new BattleTemplateExportSettings(language));
/// <summary>
/// Gets all lines comprising the exported set details.
/// </summary>
/// <param name="settings">Export settings</param>
/// <returns>List of lines comprising the set</returns>
public List<string> GetSetLines(in BattleTemplateExportSettings settings)
{
var result = new List<string>(DefaultListAllocation);
if (settings.Order.Length == 0)
return result;
GetSetLines(result, settings);
return result;
}
/// <inheritdoc cref="GetSetLines(in BattleTemplateExportSettings)"/>
public void GetSetLines(List<string> result, in BattleTemplateExportSettings settings)
{
var tokens = settings.Order;
foreach (var token in tokens)
PushToken(token, result, settings);
}
private string GetStringFirstLine(string form, in BattleTemplateExportSettings settings)
{
var strings = settings.Localization.Strings;
var speciesList = strings.specieslist;
if (Species >= speciesList.Length)
return string.Empty; // invalid species
string specForm = speciesList[Species];
var gender = Gender;
if (form.Length != 0)
{
specForm += $"-{form.Replace("Mega ", "Mega-")}";
}
else if (Species == (int)NidoranM)
{
specForm = specForm.Replace("♂", "-M");
if (gender != EntityGender.Female)
gender = null;
}
else if (Species == (int)NidoranF)
{
specForm = specForm.Replace("♀", "-F");
if (gender != EntityGender.Male)
gender = null;
}
var nickname = Nickname;
if (settings.IsTokenInExport(BattleTemplateToken.Nickname))
nickname = string.Empty; // omit nickname if not in export
var result = GetSpeciesNickname(specForm, nickname, Species, Context);
// Append Gender if not default/random.
if (gender < EntityGender.Genderless && !settings.IsTokenInExport(BattleTemplateToken.Gender))
{
if (gender is 0)
result += $" {FirstLineMale}";
else if (gender is 1)
result += $" {FirstLineFemale}";
}
// Append item if specified.
if (HeldItem > 0 && !settings.IsTokenInExport(BattleTemplateToken.HeldItem) && !settings.IsTokenInExport(BattleTemplateToken.AbilityHeldItem))
{
var items = strings.GetItemStrings(Context);
if ((uint)HeldItem < items.Length)
result += $" {ItemSplit} {items[HeldItem]}";
}
return result;
}
private static string GetSpeciesNickname(string specForm, string nickname, ushort species, EntityContext context)
{
if (nickname.Length == 0 || nickname == specForm)
return specForm;
bool isNicknamed = SpeciesName.IsNicknamedAnyLanguage(species, nickname, context);
if (!isNicknamed)
return specForm;
return $"{nickname} ({specForm})";
}
private void PushToken(BattleTemplateToken token, List<string> result, in BattleTemplateExportSettings settings)
{
var cfg = settings.Localization.Config;
var strings = settings.Localization.Strings;
switch (token)
{
// Core
case BattleTemplateToken.FirstLine:
result.Add(GetStringFirstLine(FormName, settings));
break;
case BattleTemplateToken.Ability when (uint)Ability < strings.Ability.Count:
result.Add(cfg.Push(BattleTemplateToken.Ability, strings.Ability[Ability]));
break;
case BattleTemplateToken.Nature when (uint)Nature < strings.Natures.Count:
result.Add(cfg.Push(token, strings.Natures[(byte)Nature]));
break;
case BattleTemplateToken.Moves:
GetStringMoves(result, settings);
break;
// Stats
case BattleTemplateToken.Level when Level != Experience.MaxLevel:
result.Add(cfg.Push(token, Level));
break;
case BattleTemplateToken.Friendship when Friendship != 255:
result.Add(cfg.Push(token, Friendship));
break;
case BattleTemplateToken.IVs:
var maxIV = Context.Generation < 3 ? 15 : 31;
if (!IVs.ContainsAnyExcept(maxIV))
break; // skip if all IVs are maxed
var nameIVs = cfg.GetStatDisplay(settings.StatsIVs);
var ivs = GetStringStats(IVs, maxIV, nameIVs);
if (ivs.Length != 0)
result.Add(cfg.Push(BattleTemplateToken.IVs, ivs));
break;
// EVs
case BattleTemplateToken.EVsWithNature:
case BattleTemplateToken.EVsAppendNature:
case BattleTemplateToken.EVs when EVs.ContainsAnyExcept(0):
AddEVs(result, settings, token);
break;
// Boolean
case BattleTemplateToken.Shiny when Shiny:
result.Add(cfg.Push(token));
break;
// Gen8
case BattleTemplateToken.DynamaxLevel when Context == EntityContext.Gen8 && DynamaxLevel != 10:
result.Add(cfg.Push(token, DynamaxLevel));
break;
case BattleTemplateToken.Gigantamax when Context == EntityContext.Gen8 && CanGigantamax:
result.Add(cfg.Push(token));
break;
// Gen9
case BattleTemplateToken.TeraType when Context == EntityContext.Gen9 && TeraType != MoveType.Any:
if ((uint)TeraType <= TeraTypeUtil.MaxType) // Fairy
result.Add(cfg.Push(BattleTemplateToken.TeraType, strings.Types[(int)TeraType]));
else if ((uint)TeraType == TeraTypeUtil.Stellar)
result.Add(cfg.Push(BattleTemplateToken.TeraType, strings.Types[TeraTypeUtil.StellarTypeDisplayStringIndex]));
break;
// Edge Cases
case BattleTemplateToken.HeldItem when HeldItem > 0:
var itemNames = strings.GetItemStrings(Context);
if ((uint)HeldItem < itemNames.Length)
result.Add(cfg.Push(token, itemNames[HeldItem]));
break;
case BattleTemplateToken.Nickname when !string.IsNullOrWhiteSpace(Nickname):
result.Add(cfg.Push(token, Nickname));
break;
case BattleTemplateToken.Gender when Gender != EntityGender.Genderless:
result.Add(cfg.Push(token, Gender == 0 ? cfg.Male : cfg.Female));
break;
case BattleTemplateToken.AbilityHeldItem when Ability >= 0 || HeldItem > 0:
result.Add(GetAbilityHeldItem(strings, Ability, HeldItem, Context));
break;
}
}
private void AddEVs(List<string> result, in BattleTemplateExportSettings settings, BattleTemplateToken token)
{
var cfg = settings.Localization.Config;
var nameEVs = cfg.GetStatDisplay(settings.StatsEVs);
var line = token switch
{
BattleTemplateToken.EVsWithNature => GetStringStatsNatureAmp(EVs, 0, nameEVs, Nature),
BattleTemplateToken.EVsAppendNature => GetStringStatsNatureAmp(EVs, 0, nameEVs, Nature),
_ => GetStringStats(EVs, 0, nameEVs),
};
if (token is BattleTemplateToken.EVsAppendNature && Nature.IsFixed())
line += $" ({settings.Localization.Strings.natures[(int)Nature]})";
result.Add(cfg.Push(BattleTemplateToken.EVs, line));
}
private static string GetAbilityHeldItem(GameStrings strings, int ability, int item, EntityContext context)
{
var abilityNames = strings.abilitylist;
if ((uint)ability >= abilityNames.Length)
ability = 0; // invalid ability
var abilityName = abilityNames[ability];
var itemNames = strings.GetItemStrings(context);
if ((uint)item >= itemNames.Length)
item = 0; // invalid item
var itemName = itemNames[item];
if (ability <= 0)
return $"{ItemSplit} {itemName}";
if (item <= 0)
return $"[{abilityName}]";
return $"[{abilityName}] {ItemSplit} {itemName}";
}
/// <inheritdoc cref="GetStringStats{T}(ReadOnlySpan{T}, T, StatDisplayConfig)"/>
/// <remarks>Appends the nature amplification to the stat values, if not a neutral nature.</remarks>
public static string GetStringStatsNatureAmp<T>(ReadOnlySpan<T> stats, T ignoreValue, StatDisplayConfig statNames, Nature nature) where T : IEquatable<T>
{
var (plus, minus) = nature.GetNatureModification();
if (plus == minus)
return GetStringStats(stats, ignoreValue, statNames); // neutral nature won't appear any different
// Shift as HP is not affected by nature.
plus++;
minus++;
var count = stats.Length;
if (!statNames.AlwaysShow)
{
for (int i = 0; i < stats.Length; i++)
{
if (stats[i].Equals(ignoreValue) && i != plus && i != minus)
count--; // ignore unused stats
}
}
if (count == 0)
return string.Empty;
var result = new StringBuilder();
int ctr = 0;
for (int i = 0; i < stats.Length; i++)
{
var statIndex = GetStatIndexStored(i);
var statValue = stats[statIndex];
var hideValue = statValue.Equals(ignoreValue) && !statNames.AlwaysShow;
if (hideValue && statIndex != plus && statIndex != minus)
continue; // ignore unused stats
var amp = statIndex == plus ? "+" : statIndex == minus ? "-" : string.Empty;
if (ctr++ != 0)
result.Append(statNames.Separator);
statNames.Format(result, i, statValue, amp, hideValue);
}
return result.ToString();
}
/// <summary>
/// Gets the string representation of the stats.
/// </summary>
/// <param name="stats">Stats to display</param>
/// <param name="ignoreValue">Value to ignore</param>
/// <param name="statNames">Stat names to use</param>
public static string GetStringStats<T>(ReadOnlySpan<T> stats, T ignoreValue, StatDisplayConfig statNames) where T : IEquatable<T>
{
var count = stats.Length;
if (!statNames.AlwaysShow)
{
foreach (var stat in stats)
{
if (stat.Equals(ignoreValue))
count--; // ignore unused stats
}
}
if (count == 0)
return string.Empty;
var result = new StringBuilder();
int ctr = 0;
for (int i = 0; i < stats.Length; i++)
{
var statIndex = GetStatIndexStored(i);
var statValue = stats[statIndex];
if (statValue.Equals(ignoreValue) && !statNames.AlwaysShow)
continue;
if (ctr++ != 0)
result.Append(statNames.Separator);
statNames.Format(result, i, statValue);
}
return result.ToString();
}
private void GetStringMoves(List<string> result, in BattleTemplateExportSettings settings)
{
var strings = settings.Localization.Strings;
var moveNames = strings.movelist;
var style = settings.Moves;
var prefixes = BattleTemplateConfig.GetMoveDisplay(style);
var added = 0;
for (var i = 0; i < Moves.Length; i++)
{
var move = Moves[i];
if (move == 0 && !(style is MoveDisplayStyle.Directional && added != 0))
continue;
if (move >= moveNames.Length)
continue;
var moveName = moveNames[move];
string line;
if (move != (int)Move.HiddenPower || HiddenPowerType == -1)
{
line = $"{prefixes[i]} {moveName}";
}
else
{
var type = 1 + HiddenPowerType; // skip Normal
var typeName = strings.Types[type];
line = $"{prefixes[i]} {moveName} [{typeName}]";
}
result.Add(line);
added++;
}
}
private static int GetStatIndexStored(int displayIndex) => displayIndex switch
{
3 => 4,
4 => 5,
5 => 3,
_ => displayIndex,
};
/// <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>
/// <param name="localization">Localization to parse the lines with.</param>
/// <returns>New ShowdownSet object representing the input <see cref="pk"/></returns>
public ShowdownSet(PKM pk, BattleTemplateLocalization? localization = null)
{
localization ??= DefaultStrings;
if (pk.Species == 0)
return;
Context = pk.Context;
Nickname = pk.Nickname;
Species = pk.Species;
HeldItem = pk.HeldItem;
Ability = pk.Ability;
pk.GetEVs(EVs);
pk.GetIVs(IVs);
pk.GetMoves(Moves);
if (Moves.Contains((ushort)Move.HiddenPower))
HiddenPowerType = (sbyte)HiddenPower.GetType(IVs, Context);
Nature = pk.StatNature;
Gender = pk.Gender < 2 ? pk.Gender : (byte)2;
Friendship = pk.CurrentFriendship;
Level = pk.CurrentLevel;
Shiny = pk.IsShiny;
if (pk is PK8 g) // Only set Gigantamax if it is a PK8
{
CanGigantamax = g.CanGigantamax;
DynamaxLevel = g.DynamaxLevel;
}
if (pk is ITeraType t)
TeraType = t.TeraType;
if (pk is IHyperTrain h)
{
for (int i = 0; i < 6; i++)
{
if (h.IsHyperTrained(i))
IVs[i] = pk.MaxIV;
}
}
FormName = ShowdownParsing.GetStringFromForm(Form = pk.Form, localization.Strings, Species, Context);
}
private void ParseFirstLine(ReadOnlySpan<char> first, GameStrings strings)
{
int itemSplit = first.IndexOf(ItemSplit);
if (itemSplit != -1)
{
var itemName = first[(itemSplit + 1)..].TrimStart();
var speciesName = first[..itemSplit].TrimEnd();
if (!ParseItemName(itemName, strings))
LogError(ItemUnrecognized, itemName);
ParseFirstLineNoItem(speciesName, strings);
}
else
{
ParseFirstLineNoItem(first, strings);
}
}
private bool ParseItemName(ReadOnlySpan<char> itemName, GameStrings strings)
{
var initial = Context;
if (TryGetItem(itemName, strings, initial))
return true;
if (TryGetItem(itemName, strings, EntityContext.Gen3))
return true;
if (TryGetItem(itemName, strings, EntityContext.Gen2))
return true;
if (initial is EntityContext.Gen3 or EntityContext.Gen2 && TryGetItem(itemName, strings, Latest.Context))
return true;
return false;
}
private bool TryGetItem(ReadOnlySpan<char> itemName, GameStrings strings, EntityContext context)
{
var items = strings.GetItemStrings(context);
var item = StringUtil.FindIndexIgnoreCase(items, itemName);
if (item < 0)
return false;
Context = context;
HeldItem = item;
return true;
}
private const string FirstLineMale = "(M)";
private const string FirstLineFemale = "(F)";
private void ParseFirstLineNoItem(ReadOnlySpan<char> line, GameStrings strings)
{
// Gender Detection
if (line.EndsWith(FirstLineMale, StringComparison.Ordinal))
{
line = line[..^3].TrimEnd();
Gender = 0;
}
else if (line.EndsWith(FirstLineFemale, StringComparison.Ordinal))
{
line = line[..^3].TrimEnd();
Gender = 1;
}
// Nickname Detection
if (line.Contains('(') && line.Contains(')'))
ParseSpeciesNickname(line, strings);
else
ParseSpeciesForm(line, strings);
}
private const string Gmax = "-Gmax";
/// <summary>
/// Average count of lines in a Showdown set.
/// </summary>
/// <remarks>Optimization to skip 1 size update allocation (from 4). Usually first-line, ability, (ivs, evs, shiny, level) 4*moves </remarks>
public const int DefaultListAllocation = 8;
private bool ParseSpeciesForm(ReadOnlySpan<char> speciesLine, GameStrings strings)
{
speciesLine = speciesLine.Trim();
if (speciesLine.Length == 0)
return false;
if (speciesLine.EndsWith(Gmax, StringComparison.Ordinal))
{
CanGigantamax = true;
speciesLine = speciesLine[..^Gmax.Length];
}
var speciesIndex = StringUtil.FindIndexIgnoreCase(strings.specieslist, speciesLine);
if (speciesIndex > 0)
{
// success, nothing else !
Species = (ushort)speciesIndex;
return true;
}
// Form string present.
int end = speciesLine.IndexOf('-');
if (end < 0)
return false;
speciesIndex = StringUtil.FindIndexIgnoreCase(strings.specieslist, speciesLine[..end]);
if (speciesIndex > 0)
{
Species = (ushort)speciesIndex;
FormName = speciesLine[(end + 1)..].ToString();
return true;
}
// failure to parse, check edge cases
foreach (var e in DashedSpecies)
{
var sn = strings.Species[e];
if (!speciesLine.StartsWith(sn.Replace("♂", "-M").Replace("♀", "-F"), StringComparison.Ordinal))
continue;
Species = e;
FormName = speciesLine[sn.Length..].ToString();
return true;
}
// Version Megas
end = speciesLine[Math.Max(0, end - 1)..].LastIndexOf('-');
if (end < 0)
return false;
speciesIndex = StringUtil.FindIndexIgnoreCase(strings.specieslist, speciesLine[..end]);
if (speciesIndex > 0)
{
Species = (ushort)speciesIndex;
FormName = speciesLine[(end + 1)..].ToString();
return true;
}
return false;
}
private void ParseSpeciesNickname(ReadOnlySpan<char> line, GameStrings strings)
{
// 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, strings))
Nickname = nickname.ToString();
else if (ParseSpeciesForm(nickname, strings))
Nickname = species.ToString();
}
private ReadOnlySpan<char> ParseLineMove(ReadOnlySpan<char> line, GameStrings strings)
{
line = line[1..].TrimStart();
// Discard any multi-move options; keep only first.
var option = line.IndexOf('/');
if (option != -1)
line = line[..option].TrimEnd();
var moveString = line;
var hiddenPowerName = strings.Move[(int)Move.HiddenPower];
if (!moveString.StartsWith(hiddenPowerName, StringComparison.OrdinalIgnoreCase))
return moveString; // regular move
if (moveString.Length == hiddenPowerName.Length)
return hiddenPowerName;
// Defined Hidden Power
var type = GetHiddenPowerType(moveString[(hiddenPowerName.Length + 1)..]);
var types = strings.HiddenPowerTypes;
int hpVal = StringUtil.FindIndexIgnoreCase(types, type); // Get HP Type
if (hpVal == -1)
return hiddenPowerName;
HiddenPowerType = (sbyte)hpVal;
var maxIV = Context.Generation < 3 ? 15 : 31;
if (IVs.ContainsAnyExcept(maxIV))
{
if (!HiddenPower.SetIVsForType(hpVal, IVs, Context))
LogError(HiddenPowerIncompatibleIVs, type);
}
else if (hpVal >= 0)
{
HiddenPower.SetIVs(hpVal, IVs, Context); // Alter IVs
}
else
{
LogError(HiddenPowerUnknownType, type);
}
return hiddenPowerName;
}
private static ReadOnlySpan<char> GetHiddenPowerType(ReadOnlySpan<char> line)
{
var type = line.Trim();
if (type.Length == 0)
return type;
// Allow for both (Type) and [Type]
if (type[0] == '(' && type[^1] == ')')
return type[1..^1].Trim();
if (type[0] == '[' && type[^1] == ']')
return type[1..^1].Trim();
return type;
}
private bool ParseLineEVs(ReadOnlySpan<char> line, BattleTemplateLocalization localization)
{
// If nature is present, parse it first.
var nature = line.IndexOf('(');
if (nature != -1)
{
var natureName = line[(nature + 1)..];
var end = natureName.IndexOf(')');
if (end == -1)
{
LogError(NatureEffortAmpDeclaration, natureName);
return false; // invalid line
}
natureName = natureName[..end].Trim();
var natureIndex = StringUtil.FindIndexIgnoreCase(localization.Strings.natures, natureName);
if (natureIndex == -1)
{
LogError(NatureEffortAmpUnknown, natureName);
return false; // invalid line
}
if (Nature != Nature.Random) // specified in a separate Nature line
LogError(NatureEffortAmpAlreadySpecified, natureName);
else
Nature = (Nature)natureIndex;
line = line[..nature].TrimEnd();
}
var result = localization.Config.TryParseStats(line, EVs);
var success = result.IsParsedAllStats;
if (result is { HasAmps: false })
return success;
// Use the amp nature ONLY if nature was not specified.
// Only indicate invalid if it differs from the current nature.
var currentNature = Nature;
result.TreatAmpsAsSpeedNotLast();
var ampNature = AdjustNature(result.Plus, result.Minus);
success &= ampNature;
if (ampNature && currentNature != Nature.Random && currentNature != Nature)
{
LogError(NatureEffortAmpConflictNature);
Nature = currentNature; // revert to original
}
return success;
}
private bool ParseLineIVs(ReadOnlySpan<char> line, BattleTemplateConfig config)
{
// Parse stats, with unspecified name representation (try all).
var result = config.TryParseStats(line, IVs);
return result.IsParsedAllStats;
}
private bool AdjustNature(sbyte plus, sbyte minus)
{
if (plus == StatParseResult.NoStatAmp)
LogError(NatureAmpNoPlus);
if (minus == StatParseResult.NoStatAmp)
LogError(NatureAmpNoMinus);
else
Nature = NatureAmp.CreateNatureFromAmps(plus, minus);
return true;
}
}