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")]