From ad96b048b212f1bd916e9e2408aa094b2fce1b4c Mon Sep 17 00:00:00 2001 From: Kurt Date: Fri, 23 Jan 2026 16:41:16 -0600 Subject: [PATCH] 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}" --- .../BattleTemplate/Showdown/ShowdownSet.cs | 2 + .../BattleTemplate/StatDisplayConfig.cs | 173 ++++++++++++------ .../Simulator/ShowdownSetTests.cs | 58 ++++++ 3 files changed, 179 insertions(+), 54 deletions(-) diff --git a/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownSet.cs b/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownSet.cs index d67013510..22f13cb5d 100644 --- a/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownSet.cs +++ b/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownSet.cs @@ -142,6 +142,8 @@ private void ParseLines(SpanLineEnumerator lines, BattleTemplateLocalization loc first = false; continue; } + if (trim.Length == 0) + break; LogError(LineLength, line); continue; } diff --git a/PKHeX.Core/Editing/BattleTemplate/StatDisplayConfig.cs b/PKHeX.Core/Editing/BattleTemplate/StatDisplayConfig.cs index 631434229..7aba86e00 100644 --- a/PKHeX.Core/Editing/BattleTemplate/StatDisplayConfig.cs +++ b/PKHeX.Core/Editing/BattleTemplate/StatDisplayConfig.cs @@ -176,95 +176,160 @@ public StatParseResult TryParse(ReadOnlySpan message, Span result) 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(); - 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 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 message, Span result, char separator, ReadOnlySpan valueGap) + /// + /// 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) { - 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; + /// + /// 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; + } - var value = message[..index].Trim(); - var indexSeparator = value.LastIndexOf(separator); + 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) { - 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; } diff --git a/Tests/PKHeX.Core.Tests/Simulator/ShowdownSetTests.cs b/Tests/PKHeX.Core.Tests/Simulator/ShowdownSetTests.cs index 25c0e60c7..cc2c98061 100644 --- a/Tests/PKHeX.Core.Tests/Simulator/ShowdownSetTests.cs +++ b/Tests/PKHeX.Core.Tests/Simulator/ShowdownSetTests.cs @@ -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"); + } }