using System; using System.Collections.Generic; using System.ComponentModel; using System.Text; namespace PKHeX.Core; /// /// Configuration for displaying stats. /// [TypeConverter(typeof(ExpandableObjectConverter))] public sealed class StatDisplayConfig { /// /// Stat names are displayed without localization; H:X A:X B:X C:X D:X S:X /// public static readonly StatDisplayConfig HABCDS = new() { Names = ["H", "A", "B", "C", "D", "S"], Separator = " ", ValueGap = ":", IsLeft = true, AlwaysShow = true, }; /// /// Stat names are displayed without localization; X/X/X/X/X/X /// /// /// Same as but with no leading zeroes. /// public static readonly StatDisplayConfig Raw = new() { Names = [], Separator = "/", ValueGap = string.Empty, AlwaysShow = true, }; /// /// Stat names are displayed without localization; XX/XX/XX/XX/XX/XX /// /// /// Same as but with 2 digits (leading zeroes). /// public static readonly StatDisplayConfig Raw00 = new() { Names = [], Separator = "/", ValueGap = string.Empty, AlwaysShow = true, MinimumDigits = 2, }; /// /// List of stat display styles that are commonly used and not specific to a localization. /// public static List Custom { get; } = [HABCDS, Raw]; // Raw00 parses equivalent to Raw /// List of stat names to display public required string[] Names { get; init; } /// Separator between each stat+value declaration public string Separator { get; init; } = " / "; /// Separator between the stat name and value public string ValueGap { get; init; } = " "; /// if the text is displayed on the left side of the value public bool IsLeft { get; init; } /// if the stat is always shown, even if the value is default public bool AlwaysShow { get; init; } /// Minimum number of digits to show for the stat value. public int MinimumDigits { get; init; } /// /// Gets the index of the displayed stat name (in visual order) via a case-insensitive search. /// /// Stat name, trimmed. /// -1 if not found, otherwise the index of the stat name. public int GetStatIndex(ReadOnlySpan stat) { for (int i = 0; i < Names.Length; i++) { if (stat.Equals(Names[i], StringComparison.OrdinalIgnoreCase)) return i; } return -1; } public override string ToString() => string.Join(Separator, Names); /// /// Formats a stat value into a string builder. /// /// Result string builder /// Display index of the stat /// Stat value /// Optional suffix for the value, to display a stat amplification request /// to skip the value, only displaying the stat name and amplification (if provided) public void Format(StringBuilder sb, int statIndex, T statValue, ReadOnlySpan valueSuffix = default, bool skipValue = false) { var statName = statIndex < Names.Length ? Names[statIndex] : ""; var length = GetStatSize(statName, statValue, valueSuffix, skipValue); if (sb.Length + length > sb.Capacity) sb.EnsureCapacity(sb.Length + length); Append(sb, statName, statValue, valueSuffix, skipValue); } private void Append(StringBuilder sb, ReadOnlySpan statName, T statValue, ReadOnlySpan valueSuffix, bool skipValue) { int start = sb.Length; if (!skipValue) { sb.Append(statValue); var length = sb.Length - start; if (length < MinimumDigits) sb.Insert(start, "0", MinimumDigits - length); } sb.Append(valueSuffix); if (IsLeft) { sb.Insert(start, ValueGap); sb.Insert(start, statName); } else { sb.Append(ValueGap); sb.Append(statName); } } private int GetStatSize(ReadOnlySpan statName, T statValue, ReadOnlySpan valueSuffix, bool skipValue) { var length = statName.Length + ValueGap.Length + valueSuffix.Length; if (!skipValue) length += (int)Math.Max(MinimumDigits, Math.Floor(Math.Log10(Convert.ToDouble(statValue)) + 1)); return length; } /// /// Gets the separator character used for parsing. /// private char GetSeparatorParse() => GetSeparatorParse(Separator); private static char GetSeparatorParse(ReadOnlySpan sep) => sep.Length switch { 0 => ' ', 1 => sep[0], _ => sep.Trim()[0] }; /// /// Imports a list of stats from a string. /// /// Input string /// Result storage /// Parse result public StatParseResult TryParse(ReadOnlySpan message, Span result) { var separator = GetSeparatorParse(); var gap = ValueGap.AsSpan().Trim(); // If stats are not labeled, parse with the straightforward parser. if (Names.Length == 0) return TryParseRaw(message, result, separator); else if (IsLeft) return TryParseIsLeft(message, result, separator, gap); else return TryParseRight(message, result, separator, gap); } private StatParseResult TryParseIsLeft(ReadOnlySpan message, Span result, char separator, ReadOnlySpan valueGap) { // Parse left-to-right by splitting on separator, then identifying which stat each segment contains. // Format: "StatName Value / StatName Value / ..." var rec = new StatParseResult(); while (message.Length != 0) { // Get the next segment ReadOnlySpan segment; var indexSeparator = message.IndexOf(separator); if (indexSeparator != -1) { segment = message[..indexSeparator].Trim(); message = message[(indexSeparator + 1)..].TrimStart(); } else { segment = message.Trim(); message = default; } if (segment.Length == 0) { rec.MarkDirty(); // empty segment continue; } // Find which stat name this segment contains (should be at the start for IsLeft) var statIndex = TryFindStatNameAtStart(segment, out var statNameLength); if (statIndex == -1) { rec.MarkDirty(); // unrecognized stat continue; } // Extract the value after the stat name var value = segment[statNameLength..].TrimStart(); if (valueGap.Length > 0 && value.StartsWith(valueGap)) value = value[valueGap.Length..].TrimStart(); if (value.Length != 0) { var amped = TryPeekAmp(ref value, ref rec, statIndex); if (amped && value.Length == 0) rec.MarkParsed(statIndex); else TryParse(result, ref rec, value, statIndex); } else if (rec.WasParsed(statIndex)) { rec.MarkDirty(); // duplicate stat } } rec.FinishParse(Names.Length); return rec; } /// /// Tries to find a stat name at the start of the segment. /// /// Segment to search /// Length of the matched stat name /// Stat index if found, -1 otherwise private int TryFindStatNameAtStart(ReadOnlySpan segment, out int length) { for (int i = 0; i < Names.Length; i++) { var name = Names[i]; if (segment.StartsWith(name, StringComparison.OrdinalIgnoreCase)) { length = name.Length; return i; } } length = 0; return -1; } /// /// Tries to find a stat name at the end of the segment. /// /// Segment to search /// Length of the matched stat name /// Stat index if found, -1 otherwise private int TryFindStatNameAtEnd(ReadOnlySpan segment, out int length) { for (int i = 0; i < Names.Length; i++) { var name = Names[i]; if (segment.EndsWith(name, StringComparison.OrdinalIgnoreCase)) { length = name.Length; return i; } } length = 0; return -1; } private StatParseResult TryParseRight(ReadOnlySpan message, Span result, char separator, ReadOnlySpan valueGap) { // Parse left-to-right by splitting on separator, then identifying which stat each segment contains. // Format: "Value StatName / Value StatName / ..." var rec = new StatParseResult(); while (message.Length != 0) { // Get the next segment ReadOnlySpan segment; var indexSeparator = message.IndexOf(separator); if (indexSeparator != -1) { segment = message[..indexSeparator].Trim(); message = message[(indexSeparator + 1)..].TrimStart(); } else { segment = message.Trim(); message = default; } if (segment.Length == 0) { rec.MarkDirty(); // empty segment continue; } // Find which stat name this segment contains (should be at the end for Right/English style) var statIndex = TryFindStatNameAtEnd(segment, out var statNameLength); if (statIndex == -1) { rec.MarkDirty(); // unrecognized stat continue; } // Extract the value before the stat name var value = segment[..^statNameLength].TrimEnd(); if (valueGap.Length > 0 && value.EndsWith(valueGap)) value = value[..^valueGap.Length].TrimEnd(); if (value.Length != 0) { var amped = TryPeekAmp(ref value, ref rec, statIndex); if (amped && value.Length == 0) rec.MarkParsed(statIndex); else TryParse(result, ref rec, value, statIndex); } else if (rec.WasParsed(statIndex)) { rec.MarkDirty(); // duplicate stat } } rec.FinishParse(Names.Length); return rec; } /// /// Parses a raw stat string. /// /// Input string /// Output storage /// Separator character public static StatParseResult TryParseRaw(ReadOnlySpan message, Span result, char separator) { var rec = new StatParseResult(); // Expect the message to contain all entries of `result` separated by the separator and an arbitrary amount of spaces permitted. // The message is split by the separator, and each part is trimmed of whitespace. for (int i = 0; i < result.Length; i++) { var index = message.IndexOf(separator); ReadOnlySpan value; if (index != -1) { value = message[..index].TrimEnd(); message = message[(index + 1)..].TrimStart(); } else // no further iterations to be done { value = message; message = default; } if (value.Length == 0) { rec.MarkDirty(); // Something is wrong with the message, as we have an empty stat. continue; // Maybe it's a duplicate separator; keep parsing and hope that the required amount are parsed. } var amped = TryPeekAmp(ref value, ref rec, i); if (amped && value.Length == 0) rec.MarkParsed(index); else TryParse(result, ref rec, value, i); } if (!message.IsWhiteSpace()) // shouldn't be anything left in the message to parse rec.MarkDirty(); rec.FinishParseOnly(result.Length); return rec; } private static void TryParse(Span result, ref StatParseResult rec, ReadOnlySpan value, int statIndex) { if (!int.TryParse(value, out var stat) || stat < 0) { rec.MarkDirty(); return; } result[statIndex] = stat; rec.MarkParsed(statIndex); } private static bool TryPeekAmp(ref ReadOnlySpan value, ref StatParseResult rec, int statIndex) { var last = value[^1]; if (last == '+') { rec.Plus = (sbyte)statIndex; value = value[..^1].TrimEnd(); return true; } if (last == '-') { rec.Minus = (sbyte)statIndex; value = value[..^1].TrimEnd(); return true; } return false; } }