Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 11 additions & 12 deletions src/MoogleAPI.Web/Infrastructure/Battle/BattlePool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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];

/// <summary>
/// Loaded whole and cached: it is a few thousand small rows, and holding it in memory keeps
Expand Down
33 changes: 24 additions & 9 deletions src/MoogleAPI.Web/Infrastructure/Wiki/WikiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -675,24 +675,39 @@ 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal static (string? Weaknesses, string? Absorbs) ParseElementalAffinities(string wikitext)
{
var weak = new List<string>();
var absorb = new List<string>();

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));
Expand Down
18 changes: 10 additions & 8 deletions tests/MoogleAPI.Tests/BattleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -529,27 +529,29 @@ public void TheBasicAttackAlwaysLeavesAWayToFinishAFight()
public class LadderTests
{
/// <summary>
/// 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.
/// </summary>
[Fact]
public void SkipsTheGamesWithoutPublishedEnemyStats()
{
Assert.DoesNotContain(8, ClimbBuilder.LadderGameIds);
Assert.DoesNotContain(11, ClimbBuilder.LadderGameIds);
Assert.DoesNotContain(14, ClimbBuilder.LadderGameIds);
Assert.DoesNotContain(16, ClimbBuilder.LadderGameIds);
}

/// <summary>
/// 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.
/// </summary>
[Fact]
public void SkipsTheGameWithNoElementalCounterplay()
public void IncludesTheGamesWhoseStatsAreNowReadable()
{
Assert.DoesNotContain(2, ClimbBuilder.LadderGameIds);
Assert.Contains(2, ClimbBuilder.LadderGameIds);
Assert.Contains(8, ClimbBuilder.LadderGameIds);
}

[Fact]
Expand Down
2 changes: 1 addition & 1 deletion tests/MoogleAPI.Tests/HuntBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>
Expand Down
39 changes: 39 additions & 0 deletions tests/MoogleAPI.Tests/WikiParsingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,45 @@ public void ReducesAMalformedWikilinkToItsDisplayText(string link, string expect
Assert.Equal(expected, stats.Steals);
}

/// <summary>
/// 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.
/// </summary>
[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);
}

/// <summary>
/// The first block that says something wins — a later one does not override it.
/// </summary>
[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")]
Expand Down