From 69adedabe5b838ffe731d4af752f8322db7096f4 Mon Sep 17 00:00:00 2001 From: Jack Date: Tue, 11 Aug 2026 16:25:57 -0500 Subject: [PATCH 1/2] Read the per-game enemy stat fields the parser was missing Each game's enemy infobox names its fields differently and the alias lists only spoke a subset, so whole columns read as empty for whole games. Final Fantasy V's template was migrated to hyphenated names ("magic-defense", "drop-1", "ps-abilities"), which left every one of its enemies with no magic defence, no drops and nothing to steal; Final Fantasy XII states its combat stats only as a level band and numbers its moves one field at a time. Field names now match a hyphen or underscore wherever they carry a space, the platform tags admit the same separator, the "min" forms of attack and defence are read the way "hp min" already was, and magickname/technickname are collected. vitality and spirit are scoped per game rather than added to the global lists. They are the defence stats in Final Fantasy XV, whose template carries nothing else, but Final Fantasy XII and IX list them beside a real defence, where reading them reports a Banshee's defence as 46 when the article says 17. The game comes from the article's own "| release =" line so the parser stays a pure function of the wikitext. Two robustness fixes in CleanFieldValue come with it, both found as markup sitting in stored values: - the leaked-assignment strip took a name of [a-zA-Z_][a-zA-Z_ ]*, and a platform-tagged name does not start with a letter, so Final Fantasy III's "| 3d common drop =" survived and was stored as a stolen item. - the wikilink pattern allowed exactly one pipe, so Abaddon's "[[Hi-Ether (Final Fantasy VI)||Hi-Ether]]" failed to match and kept its pipes. The target is now matched greedily to the last pipe. Co-Authored-By: Claude Opus 5 --- .../Infrastructure/Wiki/WikiClient.cs | 129 +++++++++++-- tests/MoogleAPI.Tests/WikiParsingTests.cs | 175 ++++++++++++++++++ 2 files changed, 288 insertions(+), 16 deletions(-) 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/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")] From dec62cdb00b444b4d0f8596c8badda6d19bab1ec Mon Sep 17 00:00:00 2001 From: Jack Date: Tue, 11 Aug 2026 16:26:09 -0500 Subject: [PATCH 2/2] Put each game's guard stat on the same scale as the offence it meets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BattleMath.Ratio divides offence by guard, which assumes the two are comparable numbers. They are not the same underlying stat in every game, and the enemy stat backfill made that visible: Final Fantasy XV publishes strength with a median of 4,080 and vitality with a median of 107, so a median enemy read as forty times stronger than another median enemy of its own bestiary and killed it in two turns instead of seven. It had been hidden only because XV published no defence at all, and the median-borrow happened to hand it the attack scale. Final Fantasy VI has the same fault in the other direction — a small attack against a 0-255 defence — and had every fight pinned at the MinRatio floor for seventeen turns, which predates the backfill. FillGapsWithGameMedians now rescales the guard stat so its median matches the offence it is measured against, per game and per stat pair. Scaling is monotonic, so which monsters are tougher than which is untouched; only where the bestiary as a whole sits changes. A game that publishes no guard stat already borrowed the offence median, which makes the factor exactly 1 and leaves that path alone. Every game's median pairing now sits at parity and runs about seven turns, and matchups still spread from four turns to seventeen depending on who meets whom. Sphere Hunter is unaffected either way: it rates stats as percentiles within a game, so scale cannot reach it. Its two counts are refreshed for the pool the backfill produced. Co-Authored-By: Claude Opus 5 --- .../Infrastructure/Battle/BattlePool.cs | 33 ++++- .../Infrastructure/SphereHunter/SpherePool.cs | 4 +- .../SphereHunter/SphereScale.cs | 2 +- tests/MoogleAPI.Tests/BattlePoolTests.cs | 127 ++++++++++++++++++ 4 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 tests/MoogleAPI.Tests/BattlePoolTests.cs 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/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)); + } +}