using System; using System.Collections.Generic; using static PKHeX.Core.Species; namespace PKHeX.Core { /// /// Logic for exporting and importing data in Pokémon Showdown's text format. /// public sealed class ShowdownSet : IBattleTemplate { private static readonly string[] StatNames = { "HP", "Atk", "Def", "SpA", "SpD", "Spe" }; private static readonly string[] Splitters = {"\r\n", "\n"}; private static readonly string[] StatSplitters = { " / ", " " }; private static readonly string[] LineSplit = {": "}; private static readonly string[] ItemSplit = {" @ "}; private static readonly char[] ParenJunk = { '[', ']', '(', ')' }; private static readonly ushort[] DashedSpecies = {782, 783, 784, 250, 032, 029}; // Kommo-o, Ho-Oh, Nidoran-M, Nidoran-F private const int MAX_SPECIES = (int)MAX_COUNT - 1; private static readonly GameStrings DefaultStrings = GameInfo.GetStrings(GameLanguage.DefaultLanguage); /// public int Species { get; private set; } = -1; /// public int Format { get; private set; } = RecentTrainerCache.Format; /// public string Nickname { get; set; } = string.Empty; /// public int Gender { get; private set; } = -1; /// public int HeldItem { get; private set; } /// public int Ability { get; private set; } = -1; /// public int Level { get; private set; } = 100; /// public bool Shiny { get; private set; } /// public int Friendship { get; private set; } = 255; /// public int Nature { get; set; } = -1; /// public string FormName { get; private set; } = string.Empty; /// public int Form { get; private set; } /// public int[] EVs { get; private set; } = {00, 00, 00, 00, 00, 00}; /// public int[] IVs { get; private set; } = {31, 31, 31, 31, 31, 31}; /// public int HiddenPowerType { get; set; } = -1; /// public int[] Moves { get; } = {0, 0, 0, 0}; /// public bool CanGigantamax { get; set; } /// /// Any lines that failed to be parsed. /// public readonly List InvalidLines = new(); private GameStrings Strings { get; set; } = DefaultStrings; private int[] IVsSpeedFirst => new[] {IVs[0], IVs[1], IVs[2], IVs[5], IVs[3], IVs[4]}; private int[] IVsSpeedLast => new[] {IVs[0], IVs[1], IVs[2], IVs[4], IVs[5], IVs[3]}; private int[] EVsSpeedFirst => new[] {EVs[0], EVs[1], EVs[2], EVs[5], EVs[3], EVs[4]}; private int[] EVsSpeedLast => new[] {EVs[0], EVs[1], EVs[2], EVs[4], EVs[5], EVs[3]}; /// /// Loads a new from the input string. /// /// Single-line string which will be split before loading. public ShowdownSet(string input) : this(input.Split(Splitters, 0)) { } /// /// Loads a new from the input string. /// /// Enumerable list of lines. public ShowdownSet(IEnumerable lines) => LoadLines(lines); private void LoadLines(IEnumerable lines) { ParseLines(lines); FormName = ShowdownParsing.SetShowdownFormName(Species, FormName, Ability); Form = ShowdownParsing.GetFormFromString(FormName, Strings, Species, Format); // Handle edge case with fixed-gender forms. if (Species is (int) Meowstic or (int) Indeedee) ReviseGenderedForms(); } private static IEnumerable GetSanitizedLines(IEnumerable lines) { foreach (var line in lines) { var trim = line.Trim(); if (trim.Length <= 2) continue; // Sanitize apostrophes & dashes if (trim.IndexOf('\'') != -1) trim = trim.Replace('\'', '’'); if (trim.IndexOf('–') != -1) trim = trim.Replace('–', '-'); yield return trim; } } 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; private void ParseLines(IEnumerable lines) { lines = GetSanitizedLines(lines); using var e = lines.GetEnumerator(); if (!e.MoveNext()) return; ParseFirstLine(e.Current!); int movectr = 0; while (e.MoveNext()) { var line = e.Current!; if (string.IsNullOrWhiteSpace(line)) continue; if (line[0] == '-') { string moveString = ParseLineMove(line); int move = StringUtil.FindIndexIgnoreCase(Strings.movelist, moveString); if (move < 0) InvalidLines.Add($"Unknown Move: {moveString}"); else if (Array.IndexOf(Moves, move) != -1) InvalidLines.Add($"Duplicate Move: {moveString}"); else Moves[movectr++] = move; if (movectr == MaxMoveCount) return; // End of moves, end of set data continue; } if (movectr != 0) break; var split = line.Split(LineSplit, StringSplitOptions.None); var valid = split.Length == 1 ? ParseSingle(line) // Nature : ParseEntry(split[0].Trim(), split[1].Trim()); if (!valid) InvalidLines.Add(line); } } private bool ParseSingle(string identifier) { if (!identifier.EndsWith("Nature")) return false; var naturestr = identifier.Split(' ')[0].Trim(); return (Nature = StringUtil.FindIndexIgnoreCase(Strings.natures, naturestr)) >= 0; } private bool ParseEntry(string identifier, string value) { switch (identifier) { case "Ability" or "Trait": return (Ability = StringUtil.FindIndexIgnoreCase(Strings.abilitylist, value)) >= 0; case "Shiny": return Shiny = StringUtil.IsMatchIgnoreCase("Yes", value); case "Gigantamax": return CanGigantamax = StringUtil.IsMatchIgnoreCase("Yes", value); case "Nature": return (Nature = StringUtil.FindIndexIgnoreCase(Strings.natures, value)) >= 0; case "EV" or "EVs": ParseLineEVs(value); return true; case "IV" or "IVs": ParseLineIVs(value); return true; case "Level": { if (!int.TryParse(value.Trim(), out int val)) return false; Level = val; return true; } case "Friendship" or "Happiness": { if (!int.TryParse(value.Trim(), out int val)) return false; Friendship = val; return true; } default: return false; } } /// /// Gets the standard Text representation of the set details. /// public string Text => GetText(); /// /// Gets the localized Text representation of the set details. /// /// 2 character language code public string LocalizedText(string lang) => LocalizedText(GameLanguage.GetLanguageIndex(lang)); /// /// Gets the localized Text representation of the set details. /// /// Language ID private string LocalizedText(int lang) { var strings = GameInfo.GetStrings(lang); return GetText(strings); } private string GetText(GameStrings? strings = null) { if (Species is <= 0 or > MAX_SPECIES) return string.Empty; if (strings != null) Strings = strings; var result = GetSetLines(); return string.Join(Environment.NewLine, result); } public List GetSetLines() { var result = new List(); // First Line: Name, Nickname, Gender, Item var form = ShowdownParsing.GetShowdownFormName(Species, FormName); result.Add(GetStringFirstLine(form)); // IVs var ivs = GetStringStats(IVsSpeedLast, Format < 3 ? 15 : 31); if (ivs.Count > 0) result.Add($"IVs: {string.Join(" / ", ivs)}"); // EVs var evs = GetStringStats(EVsSpeedLast, 0); if (evs.Count > 0) result.Add($"EVs: {string.Join(" / ", evs)}"); // Secondary Stats if ((uint)Ability < Strings.Ability.Count) result.Add($"Ability: {Strings.Ability[Ability]}"); if (Level != 100) result.Add($"Level: {Level}"); if (CanGigantamax) result.Add("Gigantamax: Yes"); if (Shiny) result.Add("Shiny: Yes"); if ((uint)Nature < Strings.Natures.Count) result.Add($"{Strings.Natures[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(Format); if ((uint)HeldItem < items.Length) result += $" @ {items[HeldItem]}"; } return result; } private string GetSpeciesNickname(string specForm) { if (Nickname.Length == 0) return specForm; bool isNicknamed = SpeciesName.IsNicknamedAnyLanguage(Species, Nickname, Format); if (!isNicknamed) return specForm; return $"{Nickname} ({specForm})"; } private static IList GetStringStats(ReadOnlySpan stats, int ignore) { var result = new List(); for (int i = 0; i < stats.Length; i++) { if (stats[i] == ignore) continue; // ignore unused stats result.Add($"{stats[i]} {StatNames[i]}"); } return result; } private IEnumerable GetStringMoves() { var moves = Strings.Move; foreach (int move in Moves) { if (move == 0 || (uint)move >= moves.Count) continue; if (move == 237) // Hidden Power { yield return $"- {moves[move]} [{Strings.Types[1 + HiddenPowerType]}]"; continue; } yield return $"- {moves[move]}"; } } /// /// Converts the data into an importable set format for Pokémon Showdown. /// /// PKM to convert to string /// New ShowdownSet object representing the input public ShowdownSet(PKM pkm) { if (pkm.Species <= 0) return; Format = pkm.Format; Nickname = pkm.Nickname; Species = pkm.Species; HeldItem = pkm.HeldItem; Ability = pkm.Ability; EVs = pkm.EVs; IVs = pkm.IVs; Moves = pkm.Moves; Nature = pkm.StatNature; Gender = pkm.Gender < 2 ? pkm.Gender : 2; Friendship = pkm.CurrentFriendship; Level = Experience.GetLevel(pkm.EXP, pkm.PersonalInfo.EXPGrowth); Shiny = pkm.IsShiny; if (pkm is IGigantamax g) CanGigantamax = g.CanGigantamax; HiddenPowerType = HiddenPower.GetType(IVs, Format); if (pkm is IHyperTrain h) { for (int i = 0; i < 6; i++) { if (h.IsHyperTrained(i)) IVs[i] = pkm.MaxIV; } } FormName = ShowdownParsing.GetStringFromForm(Form = pkm.Form, Strings, Species, Format); } private void ParseFirstLine(string first) { if (first.Contains(" @ ")) { string[] pieces = first.Split(ItemSplit, StringSplitOptions.None); string itemName = pieces[^1].Trim(); ParseItemName(itemName); ParseFirstLineNoItem(pieces[0]); } else { ParseFirstLineNoItem(first); } } private void ParseItemName(string itemName) { if (TrySetItem(Format)) return; if (TrySetItem(3)) return; if (TrySetItem(2)) return; InvalidLines.Add($"Unknown Item: {itemName}"); bool TrySetItem(int format) { var items = Strings.GetItemStrings(format); int item = StringUtil.FindIndexIgnoreCase(items, itemName); if (item < 0) return false; HeldItem = item; Format = format; return true; } } private void ParseFirstLineNoItem(string line) { // Gender Detection if (line.EndsWith("(M)")) { line = line[..^3]; Gender = 0; } else if (line.EndsWith("(F)")) { line = line[..^3]; Gender = 1; } // Nickname Detection if (line.IndexOf('(') != -1 && line.IndexOf(')') != -1) ParseSpeciesNickname(line); else ParseSpeciesForm(line); } private const string Gmax = "-Gmax"; private bool ParseSpeciesForm(string speciesLine) { speciesLine = speciesLine.Trim(); if (speciesLine.Length == 0) return false; if (speciesLine.EndsWith(Gmax)) { CanGigantamax = true; speciesLine = speciesLine[..^Gmax.Length]; } if ((Species = StringUtil.FindIndexIgnoreCase(Strings.specieslist, speciesLine)) >= 0) // success, nothing else! return true; // Form string present. int end = speciesLine.LastIndexOf('-'); if (end < 0) return false; Species = StringUtil.FindIndexIgnoreCase(Strings.specieslist, speciesLine[..end]); FormName = speciesLine[(end + 1)..]; if (Species >= 0) 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"))) continue; Species = e; FormName = speciesLine[sn.Length..]; return true; } // Version Megas end = speciesLine.LastIndexOf('-', Math.Max(0, end - 1)); if (end < 0) return false; Species = StringUtil.FindIndexIgnoreCase(Strings.specieslist, speciesLine[..end]); FormName = speciesLine[(end + 1)..]; return Species >= 0; } private void ParseSpeciesNickname(string line) { int index = line.LastIndexOf('('); string species, nickname; if (index > 1) // parenthesis value after: Nickname (Species), correct. { nickname = line[..index].Trim(); species = line[index..].Trim(); species = RemoveAll(species, ParenJunk); // Trim out excess data } else // parenthesis value before: (Species) Nickname, incorrect { int start = index + 1; int end = line.IndexOf(')'); var tmp = line[start..end]; if (end < line.Length - 2) { nickname = line[(end + 2)..]; species = tmp; } else // (Species), or garbage { species = tmp; nickname = string.Empty; } } if (ParseSpeciesForm(species)) Nickname = nickname; else if (ParseSpeciesForm(nickname)) Nickname = species; } private string ParseLineMove(string line) { const int hiddenPower = 237; string moveString = line[(line[1] == ' ' ? 2 : 1)..].Split('/')[0].Trim(); if (!moveString.StartsWith(Strings.Move[hiddenPower])) // Hidden Power return moveString; // regular move if (moveString.Length <= 13) return Strings.Move[hiddenPower]; // Defined Hidden Power string type = moveString[13..]; type = RemoveAll(type, ParenJunk); // Trim out excess data int hpVal = StringUtil.FindIndexIgnoreCase(Strings.types, type) - 1; // Get HP Type HiddenPowerType = hpVal; if (!Array.TrueForAll(IVs, z => z == 31)) { if (!HiddenPower.SetIVsForType(hpVal, IVs, Format)) InvalidLines.Add($"Invalid IVs for Hidden Power Type: {type}"); } else if (hpVal >= 0) { HiddenPower.SetIVs(hpVal, IVs, Format); // Alter IVs } else { InvalidLines.Add($"Invalid Hidden Power Type: {type}"); } return Strings.Move[hiddenPower]; } private void ParseLineEVs(string line) { var list = SplitLineStats(line); if ((list.Length & 1) == 1) InvalidLines.Add("Unknown EV input."); for (int i = 0; i < list.Length / 2; i++) { int pos = i * 2; int index = StringUtil.FindIndexIgnoreCase(StatNames, list[pos + 1]); if (index >= 0 && ushort.TryParse(list[pos + 0], out var EV)) EVs[index] = EV; else InvalidLines.Add($"Unknown EV stat: {list[pos]}"); } EVs = EVsSpeedFirst; } private void ParseLineIVs(string line) { var list = SplitLineStats(line); if ((list.Length & 1) == 1) InvalidLines.Add("Unknown IV input."); for (int i = 0; i < list.Length / 2; i++) { int pos = i * 2; int index = StringUtil.FindIndexIgnoreCase(StatNames, list[pos + 1]); if (index >= 0 && byte.TryParse(list[pos + 0], out var iv)) IVs[index] = iv; else InvalidLines.Add($"Unknown IV stat: {list[pos]}"); } IVs = IVsSpeedFirst; } private static string RemoveAll(string original, char[] remove) { Span result = stackalloc char[original.Length]; int ctr = 0; foreach (var c in original) { if (Array.IndexOf(remove, c) == -1) result[ctr++] = c; } if (ctr == original.Length) return original; return new string(result[..ctr].ToArray()); } private static string[] SplitLineStats(string line) { // Because people think they can type sets out... return line .Replace("SAtk", "SpA").Replace("Sp Atk", "SpA") .Replace("SDef", "SpD").Replace("Sp Def", "SpD") .Replace("Spd", "Spe").Replace("Speed", "Spe").Split(StatSplitters, StringSplitOptions.None); } } }