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()
{
]