diff --git a/src/MoogleAPI.Web/Infrastructure/Battle/BattlePool.cs b/src/MoogleAPI.Web/Infrastructure/Battle/BattlePool.cs index 5718fdf..1bf9d3a 100644 --- a/src/MoogleAPI.Web/Infrastructure/Battle/BattlePool.cs +++ b/src/MoogleAPI.Web/Infrastructure/Battle/BattlePool.cs @@ -25,22 +25,21 @@ public class BattlePool(AppDbContext db, HybridCache cache) /// races and family pages rather than single enemies, and the specialist wikis leave the /// same fields blank. /// - /// VIII is a different case and no longer a data one. Its articles give stats as - /// coefficients of a level curve rather than numbers, so nothing could be read off the - /// wikitext; 85 of its 118 enemies now hold the values the wiki's own table renders at - /// level 50, and 68% of those have an elemental weakness — between VI and V, so the bar - /// II fails is comfortably cleared. What it does not have is moves: 14 of the 85 list an - /// ability, against a pool where most do. Adding it is a gameplay decision rather than a - /// blocked one. + /// II and VIII were both here for years on reasons that turned out to be about the parser + /// rather than the games. VIII states its stats as coefficients of a level curve, so there + /// was no number in the wikitext to read; 85 of its 118 enemies now carry what the wiki's + /// own table renders at level 50. II looked like a bestiary with no elemental play in it — + /// one listed weakness across 166 enemies — because every one of its articles opens with an + /// empty PlayStation Portable block, and the affinity reader stopped at the first match + /// rather than the first that said anything. Reading past it finds 66. /// /// - /// II is absent for a different reason. It has HP and art for 166 monsters, so it looks - /// playable — but exactly one of them has a listed elemental weakness, against 31–91% in - /// every other game here. Elemental choice is the whole decision in a battle, so a fight in - /// II is a damage race with nothing to think about. + /// Both are thinner on moves than the rest: 14 of VIII's 85 list an ability where most of + /// the pool does. A fighter with none still gets its basic attack, so they fight — they + /// just fight plainly. /// /// - public static readonly int[] GameIds = [1, 3, 4, 5, 6, 7, 9, 10, 12, 13, 15]; + public static readonly int[] GameIds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 15]; /// /// Loaded whole and cached: it is a few thousand small rows, and holding it in memory keeps diff --git a/src/MoogleAPI.Web/Infrastructure/Wiki/WikiClient.cs b/src/MoogleAPI.Web/Infrastructure/Wiki/WikiClient.cs index dcd1f28..7e70b87 100644 --- a/src/MoogleAPI.Web/Infrastructure/Wiki/WikiClient.cs +++ b/src/MoogleAPI.Web/Infrastructure/Wiki/WikiClient.cs @@ -675,6 +675,15 @@ private static readonly (string Field, string Element)[] ElementFields = /// Everything else an affinity field can say — Immune, Halve, Nullify, a resistance /// percentage at or below 100 — is not interesting enough to store. /// + /// + /// The first value an element has that says anything wins, rather than simply the first + /// one. An article repeats each element per release, and the earliest block is routinely + /// the emptiest: every Final Fantasy II enemy leads with an unfilled PlayStation Portable + /// set — often several assignments collapsed onto one line, as "| psp fire = | psp ice =" — + /// before giving the real affinity under the Game Boy Advance tag. Taking the literal first + /// match read the blank and stopped, which left the whole game with one listed weakness + /// between 166 enemies and made it look like a bestiary with no elemental play in it. + /// internal static (string? Weaknesses, string? Absorbs) ParseElementalAffinities(string wikitext) { var weak = new List(); @@ -682,17 +691,23 @@ internal static (string? Weaknesses, string? Absorbs) ParseElementalAffinities(s foreach (var (field, element) in ElementFields) { - var match = Regex.Match(wikitext, - $@"^\|\s*{StatPrefix}{FieldPattern(field)}\s*=\s*(.+)$", - RegexOptions.IgnoreCase | RegexOptions.Multiline); - if (!match.Success) continue; + foreach (Match match in Regex.Matches(wikitext, + $@"^\|\s*{StatPrefix}{FieldPattern(field)}\s*=\s*(.+)$", + RegexOptions.IgnoreCase | RegexOptions.Multiline)) + { + // Cleaned rather than trimmed, because a blank field is not always blank on the + // page: the next assignment can share its line, and "| psp poison =" read as a + // value is neither a weakness nor an absorption but is still not nothing. + var value = CleanFieldValue(match.Groups[1].Value); + if (value is null) continue; - var value = match.Groups[1].Value.Trim(); + if (IsWeakness(value) && !weak.Contains(element)) + weak.Add(element); + else if (IsAbsorption(value) && !absorb.Contains(element)) + absorb.Add(element); - if (IsWeakness(value) && !weak.Contains(element)) - weak.Add(element); - else if (IsAbsorption(value) && !absorb.Contains(element)) - absorb.Add(element); + break; + } } return (Join(weak), Join(absorb)); diff --git a/tests/MoogleAPI.Tests/BattleTests.cs b/tests/MoogleAPI.Tests/BattleTests.cs index 00a327a..8fa1218 100644 --- a/tests/MoogleAPI.Tests/BattleTests.cs +++ b/tests/MoogleAPI.Tests/BattleTests.cs @@ -529,27 +529,29 @@ public void TheBasicAttackAlwaysLeavesAWayToFinishAFight() public class LadderTests { /// - /// The ladder deliberately omits VIII, XI, XIV and XVI: their enemy articles publish no - /// usable HP, so no battle can be staged there. If a future scrape fills those in, this - /// test is the reminder to reconsider the list. + /// The ladder omits XI, XIV and XVI: not one of their enemy articles carries a stats + /// infobox, so no battle can be staged there. If a future source fills those in, this test + /// is the reminder to reconsider the list. /// [Fact] public void SkipsTheGamesWithoutPublishedEnemyStats() { - Assert.DoesNotContain(8, ClimbBuilder.LadderGameIds); Assert.DoesNotContain(11, ClimbBuilder.LadderGameIds); Assert.DoesNotContain(14, ClimbBuilder.LadderGameIds); Assert.DoesNotContain(16, ClimbBuilder.LadderGameIds); } /// - /// II is excluded on playability rather than missing stats: 1 of its 166 battle-ready - /// monsters carries an elemental weakness, so nothing there rewards choosing a move. + /// II and VIII were both excluded on what turned out to be parser limits rather than the + /// games: VIII states its stats as coefficients of a level curve, and II opens every + /// article with an empty release block that the affinity reader used to stop at. Both are + /// readable now, so both fight. /// [Fact] - public void SkipsTheGameWithNoElementalCounterplay() + public void IncludesTheGamesWhoseStatsAreNowReadable() { - Assert.DoesNotContain(2, ClimbBuilder.LadderGameIds); + Assert.Contains(2, ClimbBuilder.LadderGameIds); + Assert.Contains(8, ClimbBuilder.LadderGameIds); } [Fact] diff --git a/tests/MoogleAPI.Tests/HuntBuilderTests.cs b/tests/MoogleAPI.Tests/HuntBuilderTests.cs index 866a6cc..813f84c 100644 --- a/tests/MoogleAPI.Tests/HuntBuilderTests.cs +++ b/tests/MoogleAPI.Tests/HuntBuilderTests.cs @@ -89,7 +89,7 @@ public void A_party_is_parsed_leniently_and_deduplicated(string? value, int expe [Fact] public void The_tower_has_one_floor_per_battle_ready_game() { - Assert.Equal(11, HuntBuilder.HuntGameIds.Length); + Assert.Equal(13, HuntBuilder.HuntGameIds.Length); } /// diff --git a/tests/MoogleAPI.Tests/WikiParsingTests.cs b/tests/MoogleAPI.Tests/WikiParsingTests.cs index b60d7d6..002bea2 100644 --- a/tests/MoogleAPI.Tests/WikiParsingTests.cs +++ b/tests/MoogleAPI.Tests/WikiParsingTests.cs @@ -740,6 +740,45 @@ public void ReducesAMalformedWikilinkToItsDisplayText(string link, string expect Assert.Equal(expected, stats.Steals); } + /// + /// Final Fantasy II leads every enemy with an unfilled PlayStation Portable block, often + /// several assignments collapsed onto one line, and states the real affinity further down. + /// + [Fact] + public void ReadsThroughAnEmptyReleaseBlockToTheStatedAffinity() + { + var stats = WikiClient.ParseMonsterStats(""" + {{infobox enemy stats FFII + | release = FFII + | psp fire = | psp ice = + | psp lightning = + | gba fire = Absorb + | gba ice = Weak + | gba lightning = Resist + }} + """); + + Assert.Equal("Ice", stats.Weaknesses); + Assert.Equal("Fire", stats.Absorbs); + } + + /// + /// The first block that says something wins — a later one does not override it. + /// + [Fact] + public void KeepsTheEarliestStatedAffinity() + { + var stats = WikiClient.ParseMonsterStats(""" + {{infobox enemy stats FFVI + | snes fire = Weak + | gba fire = Absorb + }} + """); + + Assert.Equal("Fire", stats.Weaknesses); + Assert.Null(stats.Absorbs); + } + [Theory] [InlineData("| abilities = None")] [InlineData("| abilities = N/A")]