mirror of
https://github.com/kwsch/PKHeX.git
synced 2026-04-04 16:36:30 -05:00
Previously were ignored.
Thanks Claude Opus 4.5, it 1-shot the entire thing from my detailed prompt & unit test follow up request.
I added a skip-blank line for ParseLines when people import a set with a trailing newline. Rather than a blank "invalid line length {0}"
412 lines
14 KiB
C#
412 lines
14 KiB
C#
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 = string.Empty,
|
|
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 = string.Empty,
|
|
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><see langword="true"/> if the text is displayed on the left side of the value</summary>
|
|
public bool IsLeft { get; init; }
|
|
|
|
/// <summary><see langword="true"/> 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"><see langword="true"/> 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 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<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)
|
|
{
|
|
// 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<char> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tries to find a stat name at the start of the segment.
|
|
/// </summary>
|
|
/// <param name="segment">Segment to search</param>
|
|
/// <param name="length">Length of the matched stat name</param>
|
|
/// <returns>Stat index if found, -1 otherwise</returns>
|
|
private int TryFindStatNameAtStart(ReadOnlySpan<char> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tries to find a stat name at the end of the segment.
|
|
/// </summary>
|
|
/// <param name="segment">Segment to search</param>
|
|
/// <param name="length">Length of the matched stat name</param>
|
|
/// <returns>Stat index if found, -1 otherwise</returns>
|
|
private int TryFindStatNameAtEnd(ReadOnlySpan<char> 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<char> message, Span<int> result, char separator, ReadOnlySpan<char> 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<char> 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;
|
|
}
|
|
|
|
/// <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);
|
|
ReadOnlySpan<char> 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<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;
|
|
}
|
|
}
|