diff --git a/src/MoogleAPI.Web/Infrastructure/Battle/BattlePool.cs b/src/MoogleAPI.Web/Infrastructure/Battle/BattlePool.cs index 6187b17..5718fdf 100644 --- a/src/MoogleAPI.Web/Infrastructure/Battle/BattlePool.cs +++ b/src/MoogleAPI.Web/Infrastructure/Battle/BattlePool.cs @@ -20,8 +20,19 @@ public class BattlePool(AppDbContext db, HybridCache cache) /// only playable when both sides come from the same one. /// /// - /// Absent for want of stats: VIII, whose enemy articles give HP as level-scaling - /// coefficients rather than a number, and XI, XIV and XVI, which publish almost none. + /// Absent for want of stats: XI, XIV and XVI. The first two publish none at all — not one + /// of their 419 articles carries an enemy stats infobox, because most of those rows are + /// 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 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 diff --git a/src/MoogleAPI.Web/Infrastructure/Wiki/WikiClient.cs b/src/MoogleAPI.Web/Infrastructure/Wiki/WikiClient.cs index 82a8931..dcd1f28 100644 --- a/src/MoogleAPI.Web/Infrastructure/Wiki/WikiClient.cs +++ b/src/MoogleAPI.Web/Infrastructure/Wiki/WikiClient.cs @@ -529,10 +529,21 @@ private static string[] MagicDefenseFields(string? release) => internal static string? ParseFieldList(string wikitext, params string[] fieldNames) => ParseFieldList(wikitext, StatPrefix, fieldNames); - // Character infoboxes name an ability field per release — "ffviir abilities", - // "ffviir2 abilities" — so the closed platform list that guards the enemy stat fields is - // too strict here. Character infoboxes have no equivalent of "bribe gil" to guard against. - private const string ReleasePrefix = @"(?:[a-z0-9]+\s+){0,2}"; + /// + /// A tag naming a numbered entry, or a remake of one: ffvii, ffviir, + /// ffviir2. Written longest-first so that ffxiii cannot match as ffxi. + /// + private const string MainlineTag = + @"ff(?:xvi|xv|xiv|xiii|xii|xi|x|ix|viii|vii|vi|v|iv|iii|ii|i)r?2?"; + + // Character infoboxes name a field per release — "ffviir abilities", "ffviir2 abilities" — + // so the closed platform list that guards the enemy stat fields is too strict here. It + // cannot be an open wildcard either: one article covers a character's whole career, and a + // spin-off's fields sit right beside the numbered entry's. Reading any tag is what gave + // Ashe and Penelo their Revenant Wings jobs ("xiirw job"), Auron the ability list from + // X-2's Creature Creator ("x2 cc abilities"), and Zack a weapon out of Crisis Core + // ("viicc weapon") for a game he is not playable in. + private const string ReleasePrefix = $@"(?:{MainlineTag}\s+)?"; internal static string? ParseCharacterFieldList(string wikitext, params string[] fieldNames) => ParseFieldList(wikitext, ReleasePrefix, fieldNames); @@ -549,7 +560,8 @@ private static string[] MagicDefenseFields(string? release) => /// The prefix cannot be allowed to swallow a qualifier, though — every one of these /// articles also carries |ultimate weapon=, naming a specific late-game item rather /// than the class of arms the character uses. Matching it would call Cloud's weapon "Ultima - /// Weapon" instead of "Broadswords". + /// Weapon" instead of "Broadswords". Admitting only rejects it + /// along with every other title's fields. /// /// internal static string? ParseCharacterField(string wikitext, params string[] fieldNames) @@ -557,13 +569,13 @@ private static string[] MagicDefenseFields(string? release) => foreach (var field in fieldNames) { foreach (Match match in Regex.Matches(wikitext, - $@"^\|\s*((?:[a-z0-9]+\s+){{0,2}}){Regex.Escape(field)}\s*=\s*(.+)$", + $@"^\|\s*({ReleasePrefix}){Regex.Escape(field)}\s*=\s*(.+)$", RegexOptions.IgnoreCase | RegexOptions.Multiline)) { if (QualifiedField.IsMatch(match.Groups[1].Value)) continue; var cleaned = CleanFieldValue(match.Groups[2].Value); - if (cleaned is not null) return cleaned; + if (cleaned is not null && !IsPlaceholder(cleaned)) return cleaned; } } @@ -959,6 +971,12 @@ private static bool ShouldRetry(HttpRequestException ex) => // Strip refs and HTML tags value = Regex.Replace(value, @"]*/?>.*?", "", RegexOptions.Singleline); value = Regex.Replace(value, @"]*/?>", ""); + // Editor notes. The unterminated form is not a typo to tolerate but the normal case + // here: the comment opens on the field's line and closes several lines later, and this + // value was cut at the line end long before it got here. Final Fantasy XV's Y'jhimei + // had an occupation of "", "", RegexOptions.Singleline); + value = Regex.Replace(value, @" Florist", "Florist")] + [InlineData("|occupation=Florist", "Florist")] + public void StripsEditorComments(string line, string? expected) + { + Assert.Equal(expected, + WikiClient.ParseInfoboxField("{{infobox character\n" + line + "\n}}", "occupation")); + } + + [Fact] + public void TreatsAStatedNoneAsNoValue() + { + Assert.Null(WikiClient.ParseCharacterField("{{infobox character\n|job=None\n}}", "job")); + } + [Fact] public void UnwrapsWikilinksToTheirDisplayText() {