Skip to content

Commit ad96b04

Browse files
committed
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}"
1 parent fe32739 commit ad96b04

3 files changed

Lines changed: 175 additions & 50 deletions

File tree

PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownSet.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ private void ParseLines(SpanLineEnumerator lines, BattleTemplateLocalization loc
142142
first = false;
143143
continue;
144144
}
145+
if (trim.Length == 0)
146+
break;
145147
LogError(LineLength, line);
146148
continue;
147149
}

PKHeX.Core/Editing/BattleTemplate/StatDisplayConfig.cs

Lines changed: 115 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -176,95 +176,160 @@ public StatParseResult TryParse(ReadOnlySpan<char> message, Span<int> result)
176176

177177
private StatParseResult TryParseIsLeft(ReadOnlySpan<char> message, Span<int> result, char separator, ReadOnlySpan<char> valueGap)
178178
{
179+
// Parse left-to-right by splitting on separator, then identifying which stat each segment contains.
180+
// Format: "StatName Value / StatName Value / ..."
179181
var rec = new StatParseResult();
180182

181-
for (int i = 0; i < Names.Length; i++)
183+
while (message.Length != 0)
182184
{
183-
if (message.Length == 0)
184-
break;
185+
// Get the next segment
186+
ReadOnlySpan<char> segment;
187+
var indexSeparator = message.IndexOf(separator);
188+
if (indexSeparator != -1)
189+
{
190+
segment = message[..indexSeparator].Trim();
191+
message = message[(indexSeparator + 1)..].TrimStart();
192+
}
193+
else
194+
{
195+
segment = message.Trim();
196+
message = default;
197+
}
185198

186-
var statName = Names[i];
187-
var index = message.IndexOf(statName, StringComparison.OrdinalIgnoreCase);
188-
if (index == -1)
199+
if (segment.Length == 0)
200+
{
201+
rec.MarkDirty(); // empty segment
189202
continue;
203+
}
190204

191-
if (index != 0)
192-
rec.MarkDirty(); // We have something before our stat name, so it isn't clean.
193-
194-
message = message[statName.Length..].TrimStart();
195-
if (valueGap.Length > 0 && message.StartsWith(valueGap))
196-
message = message[valueGap.Length..].TrimStart();
197-
198-
var value = message;
205+
// Find which stat name this segment contains (should be at the start for IsLeft)
206+
var statIndex = TryFindStatNameAtStart(segment, out var statNameLength);
207+
if (statIndex == -1)
208+
{
209+
rec.MarkDirty(); // unrecognized stat
210+
continue;
211+
}
199212

200-
var indexSeparator = value.IndexOf(separator);
201-
if (indexSeparator != -1)
202-
value = value[..indexSeparator].Trim();
203-
else
204-
message = default; // everything remaining belongs in the value we are going to parse.
213+
// Extract the value after the stat name
214+
var value = segment[statNameLength..].TrimStart();
215+
if (valueGap.Length > 0 && value.StartsWith(valueGap))
216+
value = value[valueGap.Length..].TrimStart();
205217

206218
if (value.Length != 0)
207219
{
208-
var amped = TryPeekAmp(ref value, ref rec, i);
220+
var amped = TryPeekAmp(ref value, ref rec, statIndex);
209221
if (amped && value.Length == 0)
210-
rec.MarkParsed(index);
222+
rec.MarkParsed(statIndex);
211223
else
212-
TryParse(result, ref rec, value, i);
224+
TryParse(result, ref rec, value, statIndex);
225+
}
226+
else if (rec.WasParsed(statIndex))
227+
{
228+
rec.MarkDirty(); // duplicate stat
213229
}
214-
215-
if (indexSeparator != -1)
216-
message = message[(indexSeparator+1)..].TrimStart();
217-
else
218-
break;
219230
}
220231

221-
if (!message.IsWhiteSpace()) // shouldn't be anything left in the message to parse
222-
rec.MarkDirty();
223232
rec.FinishParse(Names.Length);
224233
return rec;
225234
}
226235

236+
/// <summary>
237+
/// Tries to find a stat name at the start of the segment.
238+
/// </summary>
239+
/// <param name="segment">Segment to search</param>
240+
/// <param name="length">Length of the matched stat name</param>
241+
/// <returns>Stat index if found, -1 otherwise</returns>
242+
private int TryFindStatNameAtStart(ReadOnlySpan<char> segment, out int length)
243+
{
244+
for (int i = 0; i < Names.Length; i++)
245+
{
246+
var name = Names[i];
247+
if (segment.StartsWith(name, StringComparison.OrdinalIgnoreCase))
248+
{
249+
length = name.Length;
250+
return i;
251+
}
252+
}
253+
length = 0;
254+
return -1;
255+
}
256+
257+
/// <summary>
258+
/// Tries to find a stat name at the end of the segment.
259+
/// </summary>
260+
/// <param name="segment">Segment to search</param>
261+
/// <param name="length">Length of the matched stat name</param>
262+
/// <returns>Stat index if found, -1 otherwise</returns>
263+
private int TryFindStatNameAtEnd(ReadOnlySpan<char> segment, out int length)
264+
{
265+
for (int i = 0; i < Names.Length; i++)
266+
{
267+
var name = Names[i];
268+
if (segment.EndsWith(name, StringComparison.OrdinalIgnoreCase))
269+
{
270+
length = name.Length;
271+
return i;
272+
}
273+
}
274+
length = 0;
275+
return -1;
276+
}
277+
227278
private StatParseResult TryParseRight(ReadOnlySpan<char> message, Span<int> result, char separator, ReadOnlySpan<char> valueGap)
228279
{
280+
// Parse left-to-right by splitting on separator, then identifying which stat each segment contains.
281+
// Format: "Value StatName / Value StatName / ..."
229282
var rec = new StatParseResult();
230283

231-
for (int i = 0; i < Names.Length; i++)
284+
while (message.Length != 0)
232285
{
233-
if (message.Length == 0)
234-
break;
286+
// Get the next segment
287+
ReadOnlySpan<char> segment;
288+
var indexSeparator = message.IndexOf(separator);
289+
if (indexSeparator != -1)
290+
{
291+
segment = message[..indexSeparator].Trim();
292+
message = message[(indexSeparator + 1)..].TrimStart();
293+
}
294+
else
295+
{
296+
segment = message.Trim();
297+
message = default;
298+
}
235299

236-
var statName = Names[i];
237-
var index = message.IndexOf(statName, StringComparison.OrdinalIgnoreCase);
238-
if (index == -1)
300+
if (segment.Length == 0)
301+
{
302+
rec.MarkDirty(); // empty segment
239303
continue;
304+
}
240305

241-
var value = message[..index].Trim();
242-
var indexSeparator = value.LastIndexOf(separator);
243-
if (indexSeparator != -1)
306+
// Find which stat name this segment contains (should be at the end for Right/English style)
307+
var statIndex = TryFindStatNameAtEnd(segment, out var statNameLength);
308+
if (statIndex == -1)
244309
{
245-
rec.MarkDirty(); // We have something before our stat name, so it isn't clean.
246-
value = value[(indexSeparator + 1)..].TrimStart();
310+
rec.MarkDirty(); // unrecognized stat
311+
continue;
247312
}
248313

314+
// Extract the value before the stat name
315+
var value = segment[..^statNameLength].TrimEnd();
249316
if (valueGap.Length > 0 && value.EndsWith(valueGap))
250-
value = value[..^valueGap.Length];
317+
value = value[..^valueGap.Length].TrimEnd();
251318

252319
if (value.Length != 0)
253320
{
254-
var amped = TryPeekAmp(ref value, ref rec, i);
321+
var amped = TryPeekAmp(ref value, ref rec, statIndex);
255322
if (amped && value.Length == 0)
256-
rec.MarkParsed(index);
323+
rec.MarkParsed(statIndex);
257324
else
258-
TryParse(result, ref rec, value, i);
325+
TryParse(result, ref rec, value, statIndex);
326+
}
327+
else if (rec.WasParsed(statIndex))
328+
{
329+
rec.MarkDirty(); // duplicate stat
259330
}
260-
261-
message = message[(index + statName.Length)..].TrimStart();
262-
if (message.StartsWith(separator))
263-
message = message[1..].TrimStart();
264331
}
265332

266-
if (!message.IsWhiteSpace()) // shouldn't be anything left in the message to parse
267-
rec.MarkDirty();
268333
rec.FinishParse(Names.Length);
269334
return rec;
270335
}

Tests/PKHeX.Core.Tests/Simulator/ShowdownSetTests.cs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,4 +456,62 @@ Timid Nature
456456
- Dark Pulse
457457
""",
458458
];
459+
460+
[Theory]
461+
[InlineData("Gholdengo\nEVs: 8 Atk / 4 HP", 4, 8, 0, 0, 0, 0)] // Out of order: Atk before HP
462+
[InlineData("Gholdengo\nEVs: 252 Spe / 4 SpD / 252 Atk", 0, 252, 0, 252, 0, 4)] // Speed first
463+
[InlineData("Gholdengo\nEVs: 4 Def / 252 HP / 252 SpA", 252, 0, 4, 0, 252, 0)] // Def before HP
464+
[InlineData("Gholdengo\nEVs: 252 HP / 4 SpD / 252 Spe", 252, 0, 0, 252, 0, 4)] // Standard order
465+
public void SimulatorParseEVsOutOfOrder(string text, int hp, int atk, int def, int spe, int spa, int spd)
466+
{
467+
// EVs array is stored as: HP, Atk, Def, Spe, SpA, SpD (speed in the middle, not last)
468+
var success = ShowdownParsing.TryParseAnyLanguage(text, out var set);
469+
success.Should().BeTrue("Parsing should succeed");
470+
set.Should().NotBeNull();
471+
472+
var evs = set!.EVs;
473+
evs[0].Should().Be(hp, "HP EV should match");
474+
evs[1].Should().Be(atk, "Atk EV should match");
475+
evs[2].Should().Be(def, "Def EV should match");
476+
evs[3].Should().Be(spe, "Spe EV should match");
477+
evs[4].Should().Be(spa, "SpA EV should match");
478+
evs[5].Should().Be(spd, "SpD EV should match");
479+
}
480+
481+
[Theory]
482+
[InlineData("Gholdengo\nIVs: 0 Atk / 31 Spe", 31, 0, 31, 31, 31, 31)] // Partial IVs, out of order
483+
[InlineData("Gholdengo\nIVs: 0 Spe / 0 Atk", 31, 0, 31, 0, 31, 31)] // Both specified, reversed
484+
public void SimulatorParseIVsOutOfOrder(string text, int hp, int atk, int def, int spe, int spa, int spd)
485+
{
486+
// IVs array is stored as: HP, Atk, Def, Spe, SpA, SpD (speed in the middle, not last)
487+
var success = ShowdownParsing.TryParseAnyLanguage(text, out var set);
488+
success.Should().BeTrue("Parsing should succeed");
489+
set.Should().NotBeNull();
490+
491+
var ivs = set!.IVs;
492+
ivs[0].Should().Be(hp, "HP IV should match");
493+
ivs[1].Should().Be(atk, "Atk IV should match");
494+
ivs[2].Should().Be(def, "Def IV should match");
495+
ivs[3].Should().Be(spe, "Spe IV should match");
496+
ivs[4].Should().Be(spa, "SpA IV should match");
497+
ivs[5].Should().Be(spd, "SpD IV should match");
498+
}
499+
500+
[Theory]
501+
[InlineData("ja", "ゴルーグ\n努力値 252 素早さ / 4 特攻 / 252 攻撃")] // Japanese: Speed/SpA/Atk order (out of order)
502+
public void SimulatorParseStatsLocalizedOutOfOrder(string language, string text)
503+
{
504+
var localization = BattleTemplateLocalization.GetLocalization(language);
505+
var set = new ShowdownSet(text, localization);
506+
507+
set.Species.Should().NotBe(0, "Species should be parsed");
508+
set.InvalidLines.Should().BeEmpty("All lines should be valid");
509+
510+
// EVs array is stored as: HP, Atk, Def, Spe, SpA, SpD
511+
var evs = set.EVs;
512+
// Verify all three EVs were parsed (HP=0, Atk=252, Def=0, Spe=252, SpA=4, SpD=0)
513+
evs[1].Should().Be(252, "Atk EV should be 252");
514+
evs[3].Should().Be(252, "Spe EV should be 252");
515+
evs[4].Should().Be(4, "SpA EV should be 4");
516+
}
459517
}

0 commit comments

Comments
 (0)