From 0cbc6d2c7f9569e7c90eaf1fd3c58fad5b91dd88 Mon Sep 17 00:00:00 2001 From: Jack Date: Tue, 11 Aug 2026 19:24:13 -0500 Subject: [PATCH] Read past an empty release block, and let II and VIII fight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ParseElementalAffinities took the literal first match for each element. An article repeats every element per release and the earliest block is routinely the emptiest: every Final Fantasy II enemy opens with an unfilled PlayStation Portable set, often several assignments collapsed onto one line as "| psp fire = | psp ice =", and states the real affinity further down under the Game Boy Advance tag. Reading the blank and stopping left the whole game with one listed weakness across 166 enemies. It now takes the first value that says something, cleaned rather than trimmed — a field sharing its line with the next assignment is not blank on the page but carries nothing. That finds 66 weaknesses in II and one more in III, and moves nothing in any other game. Which removes the reason both II and VIII were kept out of the battle pool, neither of which was ever really about 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, which was this bug. Both fight at the same median as everywhere else — the guard normalisation added in feature set 174 puts them at parity without any tuning. The pool goes from 2,961 to 3,212, Battle Square and Sphere Hunter each gain two games, and 20 more characters reach the roster. Neither is as rich as the rest on moves: 14 of VIII's 85 list an ability. That is not new — Final Fantasy XV has been in the pool from the start with 0 of 218, because its articles publish a scan entry and break effects but never a movelist. Co-Authored-By: Claude Opus 5 --- .../Infrastructure/Battle/BattlePool.cs | 23 ++++++----- .../Infrastructure/Wiki/WikiClient.cs | 33 +++++++++++----- tests/MoogleAPI.Tests/BattleTests.cs | 18 +++++---- tests/MoogleAPI.Tests/HuntBuilderTests.cs | 2 +- tests/MoogleAPI.Tests/WikiParsingTests.cs | 39 +++++++++++++++++++ 5 files changed, 85 insertions(+), 30 deletions(-) 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")]