diff --git a/src/MoogleAPI.Web/Infrastructure/Battle/BattlePool.cs b/src/MoogleAPI.Web/Infrastructure/Battle/BattlePool.cs index 66ec89b..6187b17 100644 --- a/src/MoogleAPI.Web/Infrastructure/Battle/BattlePool.cs +++ b/src/MoogleAPI.Web/Infrastructure/Battle/BattlePool.cs @@ -91,16 +91,45 @@ internal static List FillGapsWithGameMedians(List raw) var magicDefense = Median(game, r => r.MagicDefense, r => r.MagicAttack); var speed = Median(game, r => r.Speed, r => r.Speed); + var defenseScale = Commensurate(attack, defense); + var magicDefenseScale = Commensurate(magicAttack, magicDefense); + fighters.AddRange(game.Select(r => new Fighter( r.Id, r.Name, r.GameId, r.GameName, r.Category, r.HitPoints, - r.Attack ?? attack, r.Defense ?? defense, - r.MagicAttack ?? magicAttack, r.MagicDefense ?? magicDefense, r.Speed ?? speed, + r.Attack ?? attack, Rescale(r.Defense ?? defense, defenseScale), + r.MagicAttack ?? magicAttack, Rescale(r.MagicDefense ?? magicDefense, magicDefenseScale), + r.Speed ?? speed, r.Weaknesses, r.Absorbs, r.Abilities, r.ImageUrl, r.Popularity))); } return fighters.OrderBy(f => f.Id).ToList(); } + /// + /// What to multiply a game's guard stat by to put it on the same scale as the offence it is + /// measured against, so that the median pairing in every game sits at parity. + /// + /// + /// divides offence by guard, which assumes the two are + /// comparable numbers. Across this series they frequently aren't, because they are not the + /// same stat in each game. Final Fantasy XV's articles give strength a median of 4,080 and + /// vitality a median of 107, so a median enemy reads as having a forty-to-one advantage over + /// another median enemy and kills it in two turns. Final Fantasy VI runs the other way — a + /// small attack against a 0–255 defence — and pinned every fight at the + /// floor for seventeen turns. + /// + /// Scaling is monotonic, so it changes nothing about which monsters are tougher than which + /// within a game; it only fixes where the pair as a whole sits. A game that publishes no + /// guard stat at all already borrows the offence median, which makes this factor exactly 1 + /// and leaves that behaviour untouched. + /// + /// + internal static double Commensurate(int offenceMedian, int guardMedian) => + guardMedian <= 0 ? 1 : offenceMedian / (double)guardMedian; + + private static int Rescale(int value, double factor) => + Math.Max(1, (int)Math.Round(value * factor)); + /// /// Median of the values a game publishes for a stat, falling back to its opposing stat's /// median, and to 10 only when the game publishes neither. diff --git a/src/MoogleAPI.Web/Infrastructure/SphereHunter/SpherePool.cs b/src/MoogleAPI.Web/Infrastructure/SphereHunter/SpherePool.cs index ce214c9..ca002ab 100644 --- a/src/MoogleAPI.Web/Infrastructure/SphereHunter/SpherePool.cs +++ b/src/MoogleAPI.Web/Infrastructure/SphereHunter/SpherePool.cs @@ -19,8 +19,8 @@ public class SpherePool(BattlePool pool, HybridCache cache) /// /// Every sphere has at least two — the basic attack and its Limit — because both are granted /// rather than scraped. A sphere on exactly two has no abilities on its article at all, which - /// is 685 of the 2,959 in the pool, and it is a bad thing to hand a player: two buttons, one of - /// which is unavailable until a gauge fills. Three leaves 2,274 to choose from. + /// is 548 of the 2,961 in the pool, and it is a bad thing to hand a player: two buttons, one of + /// which is unavailable until a gauge fills. Three leaves 2,413 to choose from. /// /// Opponents are not filtered this way. A two-button monster is dull to pilot and perfectly /// fine to fight, and excluding them would thin some bestiaries badly. diff --git a/src/MoogleAPI.Web/Infrastructure/SphereHunter/SphereScale.cs b/src/MoogleAPI.Web/Infrastructure/SphereHunter/SphereScale.cs index f6bc5ec..ca75cc9 100644 --- a/src/MoogleAPI.Web/Infrastructure/SphereHunter/SphereScale.cs +++ b/src/MoogleAPI.Web/Infrastructure/SphereHunter/SphereScale.cs @@ -46,7 +46,7 @@ public sealed class SphereScale /// between two median monsters deals about 28, and a median health rating of 55 gives 143 /// health — a little over five hits. The band that produces runs from roughly two hits for the /// frailest thing in a bestiary to fifteen for the bulkiest, which is the spread that makes - /// bulk worth drafting for. Measured against the real bestiary: 2,959 monsters rate a median + /// bulk worth drafting for. Measured against the real bestiary: 2,961 monsters rate a median /// health of 143, and near-parity matchups run a median of four turns and a p90 of nine. /// public const double HealthPerRating = 2.6; diff --git a/src/MoogleAPI.Web/Infrastructure/Wiki/WikiClient.cs b/src/MoogleAPI.Web/Infrastructure/Wiki/WikiClient.cs index bb17a7a..82a8931 100644 --- a/src/MoogleAPI.Web/Infrastructure/Wiki/WikiClient.cs +++ b/src/MoogleAPI.Web/Infrastructure/Wiki/WikiClient.cs @@ -408,6 +408,7 @@ public async Task GetMonsterDetailsAsync(string title, Cancellat public static MonsterStats ParseMonsterStats(string wikitext) { var (weaknesses, absorbs) = ParseElementalAffinities(wikitext); + var release = ParseReleaseTag(wikitext); return new MonsterStats( HitPoints: ParseStatNumber(wikitext, "hp", "hp min"), @@ -417,30 +418,106 @@ public static MonsterStats ParseMonsterStats(string wikitext) Gil: ParseStatNumber(wikitext, "gil"), Weaknesses: weaknesses, Absorbs: absorbs, - Attack: ParseStatNumber(wikitext, "attack", "attack power", "strength", "str"), - Defense: ParseStatNumber(wikitext, "defense", "defence"), - MagicAttack: ParseStatNumber(wikitext, "magic", "magic atk", "magick power", "magic power"), - MagicDefense: ParseStatNumber(wikitext, "magic defense", "magic def", "magick resist", "magic defence"), + Attack: ParseStatNumber(wikitext, "attack", "attack power", "strength", "str", + "attack min", "attack power min", "strength min"), + Defense: ParseStatNumber(wikitext, DefenseFields(release)), + MagicAttack: ParseStatNumber(wikitext, "magic", "magic atk", "magick power", "magic power", + "magic power min", "magick power min", "magic min"), + MagicDefense: ParseStatNumber(wikitext, MagicDefenseFields(release)), Speed: ParseStatNumber(wikitext, "speed", "agility", "dexterity"), Evasion: ParseStatNumber(wikitext, "evasion", "evade"), - Abilities: ParseFieldList(wikitext, AbilityFields), + Abilities: ParseAbilities(wikitext), Drops: ParseFieldList(wikitext, DropFields), Steals: ParseFieldList(wikitext, StealFields) ); } + // The bare field name comes first in every list so that an article which states the stat + // outright still wins; the "min" forms are the level-banded fallback, matching the + // convention "hp min"/"level min"/"exp min" already set above. + private static readonly string[] BaseDefenseFields = + ["defense", "defence", "defense min", "defence min"]; + + private static readonly string[] BaseMagicDefenseFields = + ["magic defense", "magic def", "magick resist", "magic defence", + "magick resist min", "magic defense min", "magic resistance", "magic resist"]; + + /// + /// Which field names mean "defence" for the game this article is about. + /// + /// + /// These two stats are the reason the alias lists cannot be entirely global. Final Fantasy + /// XV's enemy template carries only strength/vitality/spirit, so there vitality is + /// the defence stat and spirit the magic defence — but Final Fantasy XII lists both + /// of those as stats in their own right *alongside* a real defense min, and Final + /// Fantasy IX does the same next to magic defense. Reading them everywhere gives an + /// FFXII Banshee a defence of 46 when the article says 17. + /// + private static string[] DefenseFields(string? release) => + release == "FFXV" ? [.. BaseDefenseFields, "vitality"] : BaseDefenseFields; + + private static string[] MagicDefenseFields(string? release) => + release == "FFXV" ? [.. BaseMagicDefenseFields, "spirit"] : BaseMagicDefenseFields; + + // Which game the article is about, from the infobox's own "| release = FFXV" line. Read off + // the page rather than passed in so that the parser stays a pure function of the wikitext. + private static string? ParseReleaseTag(string wikitext) + { + var match = Regex.Match(wikitext, @"^\|\s*release\s*=\s*(.+)$", + RegexOptions.IgnoreCase | RegexOptions.Multiline); + + return match.Success + ? Regex.Match(match.Groups[1].Value.ToUpperInvariant(), @"FF[IVX]+").Value is { Length: > 0 } code + ? code + : null + : null; + } + // What the enemy does in battle. The FFX pair "weapon abilities" / "armor abilities" is // deliberately absent: those are the abilities the *player* can customize onto gear using // this enemy's drops, not moves the enemy has — and since StatPrefix admits only version // numbers and platforms, "weapon"/"armor" can never sneak in as a prefix either. private static readonly string[] AbilityFields = - ["abilities", "special attack", "other abilities", "technicks", "magicks", "attacks"]; + ["abilities", "special attack", "other abilities", "technicks", "magicks", "attacks", + "blue magic", "special technique", "enemy abilities", "spells", "skills"]; + // "element drop" is deliberately absent. Final Fantasy XV's is the elemental deposit an + // enemy yields for magic crafting — "Ice", "Fire" — not an item, and reading it as one put + // "Garula Sirloin, Ice" in the column. private static readonly string[] DropFields = - ["drop 1", "drop 2", "drop", "common drop", "rare drop", "item dropped"]; + ["drop 1", "drop 2", "drop", "common drop", "rare drop", "item dropped", + "drop 3", "drop 4", "uncommon drop", "special drop", "primary drop", "item drop", "drops"]; private static readonly string[] StealFields = - ["steal 1", "steal 2", "steal", "common steal", "rare steal"]; + ["steal 1", "steal 2", "steal", "common steal", "rare steal", + "steal 3", "steal 4", "steals"]; + + // Final Fantasy XII numbers its moves one field at a time — "| magickname1 = Aero", + // "| technickname3 = Souleater" — rather than listing them in one field. + private static readonly Regex NumberedAbility = new( + @"^\|\s*(?:magickname|technickname|abilityname|attackname)\s*\d+\s*=\s*(.+)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline); + + private static string? ParseAbilities(string wikitext) + { + var listed = ParseFieldList(wikitext, AbilityFields); + var numbered = NumberedAbility.Matches(wikitext) + .Select(m => CleanFieldValue(m.Groups[1].Value)) + .OfType() + .ToList(); + + if (numbered.Count == 0) return listed; + + var all = new List(); + foreach (var entry in (listed?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []) + .Concat(numbered)) + { + if (entry.Length < 2 || IsPlaceholder(entry)) continue; + if (!all.Contains(entry, StringComparer.OrdinalIgnoreCase)) all.Add(entry); + } + + return all.Count == 0 ? null : string.Join(", ", all); + } /// /// Collects every value across a set of related fields into one comma-separated list. @@ -504,7 +581,7 @@ public static MonsterStats ParseMonsterStats(string wikitext) foreach (var field in fieldNames) { foreach (Match match in Regex.Matches(wikitext, - $@"^\|\s*{prefixPattern}{Regex.Escape(field)}\s*=\s*(.+)$", + $@"^\|\s*{prefixPattern}{FieldPattern(field)}\s*=\s*(.+)$", RegexOptions.IgnoreCase | RegexOptions.Multiline)) { var cleaned = CleanFieldValue(match.Groups[1].Value); @@ -535,14 +612,27 @@ private static bool IsPlaceholder(string entry) => // the article covers more than one version, and the two can stack ("| 1 max hp ="). Anything // else in front of the field name means it's a different stat ("| bribe gil = 17,000" is // what an FFX enemy costs to bribe), so the prefix list stays closed. - private const string StatPrefix = @"(?:(?:\d+|snes|nes|ps|psx|psp|gba|ios|android|pc|pr|3d|mobile|max)\s+){0,2}"; + // A tag may be joined by a hyphen rather than a space: the Final Fantasy V template writes + // "| ps-abilities =", not "| ps abilities =". + private const string StatPrefix = + @"(?:(?:\d+|snes|nes|ps|psx|psp|gba|ios|android|pc|pr|3d|mobile|max" + + @"|dsop|ds|wsc|gb|n3ds|switch|steam)[\s\-_]+){0,2}"; + + /// + /// A field name as it may actually be written: the space in "magic defense" is a hyphen or + /// an underscore on some templates. Final Fantasy V's enemy infobox was migrated to + /// "magic-defense" / "drop-1" / "steal-1" in 2026, which is why every one of its enemies + /// read as having no magic defence, no drops and nothing to steal. + /// + private static string FieldPattern(string field) => + string.Join(@"[\s\-_]+", field.Split(' ').Select(Regex.Escape)); private static int? ParseStatNumber(string wikitext, params string[] fieldNames) { foreach (var field in fieldNames) { var match = Regex.Match(wikitext, - $@"^\|\s*{StatPrefix}{Regex.Escape(field)}\s*=\s*([\d,]+)", + $@"^\|\s*{StatPrefix}{FieldPattern(field)}\s*=\s*([\d,]+)", RegexOptions.IgnoreCase | RegexOptions.Multiline); if (match.Success && int.TryParse(match.Groups[1].Value.Replace(",", ""), out var value)) @@ -581,7 +671,7 @@ internal static (string? Weaknesses, string? Absorbs) ParseElementalAffinities(s foreach (var (field, element) in ElementFields) { var match = Regex.Match(wikitext, - $@"^\|\s*{StatPrefix}{Regex.Escape(field)}\s*=\s*(.+)$", + $@"^\|\s*{StatPrefix}{FieldPattern(field)}\s*=\s*(.+)$", RegexOptions.IgnoreCase | RegexOptions.Multiline); if (!match.Success) continue; @@ -842,9 +932,13 @@ private static bool ShouldRetry(HttpRequestException ex) => // "File:Tripletriad-fire.png Fire"). value = Regex.Replace(value, @"\[\[\s*(?:File|Image)\s*:[^\]]*\]\]", " ", RegexOptions.IgnoreCase); - // [[Link|Display]] → Display, [[Link]] → Link (including unclosed links cut at EOL) - value = Regex.Replace(value, @"\[\[(?:[^\]|]+\|)?([^\]|]+)\]\]", "$1"); - value = Regex.Replace(value, @"\[\[(?:[^\]|]+\|)?([^\]|]+)", "$1"); + // [[Link|Display]] → Display, [[Link]] → Link (including unclosed links cut at EOL). + // The target is matched greedily up to the *last* pipe so that a malformed link still + // reduces to its display text: the Final Fantasy VI article for Abaddon writes + // "[[Hi-Ether (Final Fantasy VI)||Hi-Ether]]" with a doubled pipe, and a pattern that + // allowed only one left the whole thing in the value, brackets stripped but pipes intact. + value = Regex.Replace(value, @"\[\[(?:[^\]]*\|)?([^\]|]*)\]\]", "$1"); + value = Regex.Replace(value, @"\[\[(?:[^\]\[]*\|)?([^\]|]*)", "$1"); value = value.Replace("[[", "").Replace("]]", ""); // Strip single-bracket content: [external links] and [editorial placeholder notes] @@ -857,7 +951,10 @@ private static bool ShouldRetry(HttpRequestException ex) => // far more often than "|gba drop =". Without allowing it this rule never fired, and an // FFII enemy's abilities came through as "| gba drop = Wing Sword, Ice Shield": its // equipment drops, presented to a battle as moves. - value = Regex.Replace(value, @"\s*\|\s*[a-zA-Z_][a-zA-Z_ ]*\s*=.*", "").Trim(); + // Digits and hyphens have to be admitted for the same reason: a platform-tagged name + // does not start with a letter, so Final Fantasy III's "| 3d common drop =" survived + // this rule and came out the far side as a stolen item called "| 3d common drop =". + value = Regex.Replace(value, @"\s*\|\s*[a-zA-Z0-9_][a-zA-Z0-9_ \-]*\s*=.*", "").Trim(); // Strip refs and HTML tags value = Regex.Replace(value, @"]*/?>.*?", "", RegexOptions.Singleline); diff --git a/tests/MoogleAPI.Tests/BattlePoolTests.cs b/tests/MoogleAPI.Tests/BattlePoolTests.cs new file mode 100644 index 0000000..f943180 --- /dev/null +++ b/tests/MoogleAPI.Tests/BattlePoolTests.cs @@ -0,0 +1,127 @@ +using MoogleAPI.Web.Infrastructure.Battle; + +namespace MoogleAPI.Tests; + +/// +/// Offence and guard are divided by each other, so they have to be comparable numbers. They +/// are not the same underlying stat in every game, and these cover the games where the raw +/// figures are on wildly different scales. +/// +public class BattlePoolTests +{ + private static BattlePool.RawFighter Monster( + int id, int gameId, int? attack = null, int? defense = null, + int? magicAttack = null, int? magicDefense = null) => + new(id, $"Monster {id}", gameId, "Game", "Enemy", HitPoints: 100, + attack, defense, magicAttack, magicDefense, Speed: 10, + Weaknesses: null, Absorbs: null, Abilities: null, ImageUrl: "x.png", Popularity: 50); + + /// + /// Final Fantasy XV in miniature: strength in the thousands against a vitality in the + /// hundreds. Left alone, a median enemy reads as forty times stronger than another median + /// enemy of the same game. + /// + [Fact] + public void PutsAGuardStatOnTheSameScaleAsTheOffenceItIsMeasuredAgainst() + { + List raw = + [ + Monster(1, 15, attack: 4000, defense: 100), + Monster(2, 15, attack: 4080, defense: 107), + Monster(3, 15, attack: 4200, defense: 110), + ]; + + var pool = BattlePool.FillGapsWithGameMedians(raw); + var median = pool.Single(f => f.Id == 2); + + // The median pairing now sits at parity rather than at a rout. + Assert.Equal(0.5, BattleMath.Ratio(median.Attack, median.Defense), 2); + } + + /// + /// Final Fantasy VI runs the other way — a small attack against a 0–255 defence — which + /// pinned every fight at the ratio floor. + /// + [Fact] + public void LiftsAGuardStatThatSitsFarAboveItsOffence() + { + List raw = + [ + Monster(1, 6, attack: 10, defense: 90), + Monster(2, 6, attack: 13, defense: 110), + Monster(3, 6, attack: 20, defense: 150), + ]; + + var median = BattlePool.FillGapsWithGameMedians(raw).Single(f => f.Id == 2); + + Assert.Equal(0.5, BattleMath.Ratio(median.Attack, median.Defense), 2); + Assert.True(BattleMath.Ratio(median.Attack, median.Defense) > BattleMath.MinRatio); + } + + /// + /// Rescaling is monotonic, so it settles where a bestiary sits as a whole without disturbing + /// which of its monsters is the tougher. + /// + [Fact] + public void KeepsTheOrderOfAGamesGuardStat() + { + List raw = + [ + Monster(1, 15, attack: 4000, defense: 100), + Monster(2, 15, attack: 4080, defense: 107), + Monster(3, 15, attack: 4200, defense: 6060), + ]; + + var pool = BattlePool.FillGapsWithGameMedians(raw).OrderBy(f => f.Id).ToList(); + + Assert.True(pool[0].Defense < pool[1].Defense); + Assert.True(pool[1].Defense < pool[2].Defense); + } + + /// + /// A game that publishes no guard stat already borrows the offence median, so there is + /// nothing to correct and the fighters come out exactly as they did before. + /// + [Fact] + public void LeavesAGameThatPublishesNoGuardStatAlone() + { + List raw = + [ + Monster(1, 15, attack: 4000), + Monster(2, 15, attack: 4080), + Monster(3, 15, attack: 4200), + ]; + + var pool = BattlePool.FillGapsWithGameMedians(raw); + + Assert.All(pool, f => Assert.Equal(4080, f.Defense)); + } + + [Fact] + public void ScalesMagicDefenceAgainstMagicAttackIndependently() + { + List raw = + [ + Monster(1, 5, attack: 40, defense: 8, magicAttack: 30, magicDefense: 4), + Monster(2, 5, attack: 48, defense: 10, magicAttack: 32, magicDefense: 5), + Monster(3, 5, attack: 50, defense: 12, magicAttack: 34, magicDefense: 6), + ]; + + var median = BattlePool.FillGapsWithGameMedians(raw).Single(f => f.Id == 2); + + Assert.Equal(0.5, BattleMath.Ratio(median.Attack, median.Defense), 2); + Assert.Equal(0.5, BattleMath.Ratio(median.MagicAttack, median.MagicDefense), 2); + } + + [Fact] + public void NeverProducesAGuardOfZero() + { + List raw = + [ + Monster(1, 6, attack: 1, defense: 200), + Monster(2, 6, attack: 2, defense: 250), + ]; + + Assert.All(BattlePool.FillGapsWithGameMedians(raw), f => Assert.True(f.Defense >= 1)); + } +} diff --git a/tests/MoogleAPI.Tests/WikiParsingTests.cs b/tests/MoogleAPI.Tests/WikiParsingTests.cs index b5a1b0c..0b74734 100644 --- a/tests/MoogleAPI.Tests/WikiParsingTests.cs +++ b/tests/MoogleAPI.Tests/WikiParsingTests.cs @@ -489,6 +489,181 @@ public void ReadsDropsAndStealsSeparately() Assert.Equal("Tonic, Hi-Potion", stats.Steals); } + // Final Fantasy V's enemy infobox was rewritten in 2026 to hyphenate its field names and + // to tag per-platform values with a hyphen too ("ps-abilities"). Nothing in the template + // changed meaning — but every FFV enemy read as having no magic defence, no drops and + // nothing to steal until the field matcher accepted the hyphen. + private const string GoblinV = """ + {{infobox enemy + | name = Goblin + | release = FFV + }} + ==Stats== + {{Infobox enemy info FFV + |lv=6 + |hp=16 + |strength=5 + |defense=0 + |magic=0 + |magic-defense=5 + |magic-evasion=0 + |agility=10 + |attack-multiplier=1 + |gil=20 + |exp=10 + |steal-1=[[Potion (Final Fantasy V)|Potion]] + |steal-2=Nothing + |drop-1=Nothing + |drop-2=[[Leather Shoes (Final Fantasy V)|Leather Shoes]] + |abilities=!Attack + |ps-abilities=!Fight + }} + """; + + [Fact] + public void ReadsHyphenatedFieldNames() + { + var stats = WikiClient.ParseMonsterStats(GoblinV); + + Assert.Equal(5, stats.MagicDefense); + Assert.Equal("Leather Shoes", stats.Drops); + Assert.Equal("Potion", stats.Steals); + } + + [Fact] + public void ReadsHyphenatedPlatformTags() + { + Assert.Equal("!Attack, !Fight", WikiClient.ParseMonsterStats(GoblinV).Abilities); + } + + /// + /// The level-banded games state defence and attack the same way they state HP, so the + /// "min" form is read for those too. + /// + [Fact] + public void FallsBackToMinimumValuesForBandedCombatStats() + { + var stats = WikiClient.ParseMonsterStats(""" + {{infobox enemy stats FFXII + | release = FFXII + | attack power min = 63 + | attack power max = 69 + | defense min = 17 + | defense max = 21 + | magick power min = 21 + | magick resist min = 30 + }} + """); + + Assert.Equal(63, stats.Attack); + Assert.Equal(17, stats.Defense); + Assert.Equal(21, stats.MagicAttack); + Assert.Equal(30, stats.MagicDefense); + } + + /// + /// Vitality and spirit are only the defence stats in Final Fantasy XV, whose enemy template + /// carries nothing else. Final Fantasy XII lists both as stats of their own next to a real + /// defence, and reading them there reports 46 where the article says 17. + /// + [Fact] + public void ReadsVitalityAndSpiritAsDefencesOnlyForFinalFantasyXV() + { + var xv = WikiClient.ParseMonsterStats(""" + {{infobox enemy stats FFXV + | release = FFXV + | 1 strength = 700 + | 1 vitality = 66 + | 1 spirit = 51 + }} + """); + + Assert.Equal(66, xv.Defense); + Assert.Equal(51, xv.MagicDefense); + + var xii = WikiClient.ParseMonsterStats(""" + {{infobox enemy stats FFXII + | release = FFXII + | defense min = 17 + | magick resist min = 30 + | vitality = 46 + }} + """); + + Assert.Equal(17, xii.Defense); + Assert.Equal(30, xii.MagicDefense); + } + + [Fact] + public void ReadsFinalFantasyXIIsNumberedMoveFields() + { + var stats = WikiClient.ParseMonsterStats(""" + {{infobox enemy stats FFXII + | release = FFXII + | magickname1 = Aero + | magickcond1 = HP <50% + | magickname2 = Cura + | technickname1 = Souleater + }} + """); + + Assert.Equal("Aero, Cura, Souleater", stats.Abilities); + } + + /// + /// Final Fantasy XV's "element drop" is the elemental deposit an enemy yields for magic + /// crafting, not an item it leaves behind. + /// + [Fact] + public void DoesNotReadAnElementalDepositAsAnItemDrop() + { + var stats = WikiClient.ParseMonsterStats(""" + {{infobox enemy stats FFXV + | release = FFXV + | 1 primary drop = [[Garula Sirloin]] + | 1 element drop = Ice + | 1 element drop quantity = 4 + }} + """); + + Assert.Equal("Garula Sirloin", stats.Drops); + } + + /// + /// A field assignment that leaks onto the value's line is stripped even when its name + /// starts with a platform tag rather than a letter. + /// + [Fact] + public void StripsLeakedFieldAssignmentsNamedWithADigit() + { + var stats = WikiClient.ParseMonsterStats(""" + {{infobox enemy stats FFIII + | release = FFIII + | 3d steal = | 3d common drop = | 3d uncommon drop = + | nes steal = [[Potion (item)|Potion]] + }} + """); + + Assert.Equal("Potion", stats.Steals); + Assert.Null(stats.Drops); + } + + /// + /// Articles are hand-written and occasionally malformed. A wikilink with a doubled pipe + /// still reduces to its display text rather than leaving markup in the stored value. + /// + [Theory] + [InlineData("[[Hi-Ether (Final Fantasy VI)||Hi-Ether]]", "Hi-Ether")] + [InlineData("[[Hi-Ether (Final Fantasy VI)|Hi-Ether]]", "Hi-Ether")] + [InlineData("[[Hi-Ether]]", "Hi-Ether")] + public void ReducesAMalformedWikilinkToItsDisplayText(string link, string expected) + { + var stats = WikiClient.ParseMonsterStats( + "{{infobox enemy stats FFVI\n| gba steal 2 = " + link + "\n}}"); + + Assert.Equal(expected, stats.Steals); + } + [Theory] [InlineData("| abilities = None")] [InlineData("| abilities = N/A")]