ShowdownSet: Parse wrong-ordered EVs

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}"
This commit is contained in:
Kurt 2026-01-23 16:41:16 -06:00
parent fe32739494
commit ad96b048b2
3 changed files with 179 additions and 54 deletions

View File

@ -142,6 +142,8 @@ private void ParseLines(SpanLineEnumerator lines, BattleTemplateLocalization loc
first = false;
continue;
}
if (trim.Length == 0)
break;
LogError(LineLength, line);
continue;
}

View File

@ -176,95 +176,160 @@ public StatParseResult TryParse(ReadOnlySpan<char> message, Span<int> result)
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();
for (int i = 0; i < Names.Length; i++)
while (message.Length != 0)
{
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);
// Get the next segment
ReadOnlySpan<char> segment;
var indexSeparator = message.IndexOf(separator);
if (indexSeparator != -1)
value = value[..indexSeparator].Trim();
{
segment = message[..indexSeparator].Trim();
message = message[(indexSeparator + 1)..].TrimStart();
}
else
message = default; // everything remaining belongs in the value we are going to parse.
{
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, i);
var amped = TryPeekAmp(ref value, ref rec, statIndex);
if (amped && value.Length == 0)
rec.MarkParsed(index);
rec.MarkParsed(statIndex);
else
TryParse(result, ref rec, value, i);
TryParse(result, ref rec, value, statIndex);
}
else if (rec.WasParsed(statIndex))
{
rec.MarkDirty(); // duplicate stat
}
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)
/// <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)
{
var rec = new StatParseResult();
for (int i = 0; i < Names.Length; i++)
{
if (message.Length == 0)
break;
var name = Names[i];
if (segment.StartsWith(name, StringComparison.OrdinalIgnoreCase))
{
length = name.Length;
return i;
}
}
length = 0;
return -1;
}
var statName = Names[i];
var index = message.IndexOf(statName, StringComparison.OrdinalIgnoreCase);
if (index == -1)
continue;
/// <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;
}
var value = message[..index].Trim();
var indexSeparator = value.LastIndexOf(separator);
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)
{
rec.MarkDirty(); // We have something before our stat name, so it isn't clean.
value = value[(indexSeparator + 1)..].TrimStart();
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];
value = value[..^valueGap.Length].TrimEnd();
if (value.Length != 0)
{
var amped = TryPeekAmp(ref value, ref rec, i);
var amped = TryPeekAmp(ref value, ref rec, statIndex);
if (amped && value.Length == 0)
rec.MarkParsed(index);
rec.MarkParsed(statIndex);
else
TryParse(result, ref rec, value, i);
TryParse(result, ref rec, value, statIndex);
}
else if (rec.WasParsed(statIndex))
{
rec.MarkDirty(); // duplicate stat
}
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;
}

View File

@ -456,4 +456,62 @@ Timid Nature
- Dark Pulse
""",
];
[Theory]
[InlineData("Gholdengo\nEVs: 8 Atk / 4 HP", 4, 8, 0, 0, 0, 0)] // Out of order: Atk before HP
[InlineData("Gholdengo\nEVs: 252 Spe / 4 SpD / 252 Atk", 0, 252, 0, 252, 0, 4)] // Speed first
[InlineData("Gholdengo\nEVs: 4 Def / 252 HP / 252 SpA", 252, 0, 4, 0, 252, 0)] // Def before HP
[InlineData("Gholdengo\nEVs: 252 HP / 4 SpD / 252 Spe", 252, 0, 0, 252, 0, 4)] // Standard order
public void SimulatorParseEVsOutOfOrder(string text, int hp, int atk, int def, int spe, int spa, int spd)
{
// EVs array is stored as: HP, Atk, Def, Spe, SpA, SpD (speed in the middle, not last)
var success = ShowdownParsing.TryParseAnyLanguage(text, out var set);
success.Should().BeTrue("Parsing should succeed");
set.Should().NotBeNull();
var evs = set!.EVs;
evs[0].Should().Be(hp, "HP EV should match");
evs[1].Should().Be(atk, "Atk EV should match");
evs[2].Should().Be(def, "Def EV should match");
evs[3].Should().Be(spe, "Spe EV should match");
evs[4].Should().Be(spa, "SpA EV should match");
evs[5].Should().Be(spd, "SpD EV should match");
}
[Theory]
[InlineData("Gholdengo\nIVs: 0 Atk / 31 Spe", 31, 0, 31, 31, 31, 31)] // Partial IVs, out of order
[InlineData("Gholdengo\nIVs: 0 Spe / 0 Atk", 31, 0, 31, 0, 31, 31)] // Both specified, reversed
public void SimulatorParseIVsOutOfOrder(string text, int hp, int atk, int def, int spe, int spa, int spd)
{
// IVs array is stored as: HP, Atk, Def, Spe, SpA, SpD (speed in the middle, not last)
var success = ShowdownParsing.TryParseAnyLanguage(text, out var set);
success.Should().BeTrue("Parsing should succeed");
set.Should().NotBeNull();
var ivs = set!.IVs;
ivs[0].Should().Be(hp, "HP IV should match");
ivs[1].Should().Be(atk, "Atk IV should match");
ivs[2].Should().Be(def, "Def IV should match");
ivs[3].Should().Be(spe, "Spe IV should match");
ivs[4].Should().Be(spa, "SpA IV should match");
ivs[5].Should().Be(spd, "SpD IV should match");
}
[Theory]
[InlineData("ja", "ゴルーグ\n努力値 252 素早さ / 4 特攻 / 252 攻撃")] // Japanese: Speed/SpA/Atk order (out of order)
public void SimulatorParseStatsLocalizedOutOfOrder(string language, string text)
{
var localization = BattleTemplateLocalization.GetLocalization(language);
var set = new ShowdownSet(text, localization);
set.Species.Should().NotBe(0, "Species should be parsed");
set.InvalidLines.Should().BeEmpty("All lines should be valid");
// EVs array is stored as: HP, Atk, Def, Spe, SpA, SpD
var evs = set.EVs;
// Verify all three EVs were parsed (HP=0, Atk=252, Def=0, Spe=252, SpA=4, SpD=0)
evs[1].Should().Be(252, "Atk EV should be 252");
evs[3].Should().Be(252, "Spe EV should be 252");
evs[4].Should().Be(4, "SpA EV should be 4");
}
}