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
15 changes: 13 additions & 2 deletions src/MoogleAPI.Web/Infrastructure/Battle/BattlePool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,19 @@ public class BattlePool(AppDbContext db, HybridCache cache)
/// only playable when both sides come from the same one.
/// </summary>
/// <remarks>
/// 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.
/// <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.
/// </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
Expand Down
32 changes: 25 additions & 7 deletions src/MoogleAPI.Web/Infrastructure/Wiki/WikiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}";
/// <summary>
/// A tag naming a numbered entry, or a remake of one: <c>ffvii</c>, <c>ffviir</c>,
/// <c>ffviir2</c>. Written longest-first so that <c>ffxiii</c> cannot match as <c>ffxi</c>.
/// </summary>
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);
Expand All @@ -549,21 +560,22 @@ private static string[] MagicDefenseFields(string? release) =>
/// The prefix cannot be allowed to swallow a qualifier, though — every one of these
/// articles also carries <c>|ultimate weapon=</c>, 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 <see cref="MainlineTag"/> rejects it
/// along with every other title's fields.
/// </para>
/// </remarks>
internal static string? ParseCharacterField(string wikitext, params string[] fieldNames)
{
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;
}
}

Expand Down Expand Up @@ -959,6 +971,12 @@ private static bool ShouldRetry(HttpRequestException ex) =>
// Strip refs and HTML tags
value = Regex.Replace(value, @"<ref\b[^>]*/?>.*?</ref>", "", RegexOptions.Singleline);
value = Regex.Replace(value, @"<ref\b[^>]*/?>", "");
// 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 "<!--Physical desc." for want of this.
value = Regex.Replace(value, @"<!--.*?-->", "", RegexOptions.Singleline);
value = Regex.Replace(value, @"<!--.*", "");
// <br> separates multiple values in an infobox field. Dropping it like any other tag
// fuses them together ("First Shield of RosariaMarquess of Rosaria"), so it has to
// become a real separator before the generic tag strip runs.
Expand Down
76 changes: 76 additions & 0 deletions tests/MoogleAPI.Tests/WikiParsingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,82 @@ public void ParsesCharacterAbilityLists(string line, string expected)
WikiClient.ParseCharacterFieldList("{{infobox character\n" + line + "\n}}", "abilities", "ability"));
}

/// <summary>
/// One article covers a character's whole career, so a spin-off's fields sit right beside
/// the numbered entry's. Only a mainline tag counts.
/// </summary>
[Fact]
public void IgnoresFieldsBelongingToADifferentTitle()
{
const string vaan = """
{{infobox character
|name=Vaan
|xiirw job=Sky Pirate
|ffta2 job=Sky Pirate
}}
""";

Assert.Null(WikiClient.ParseCharacterField(vaan, "job", "class"));

const string auron = """
{{infobox character
|name=Auron
|ffx limit break=[[Bushido (Final Fantasy X)|Bushido]]
|x2 cc abilities=[[Full Break]], [[Dragon Fang (Bushido)|Dragon Fang]]
}}
""";

Assert.Equal("Bushido",
WikiClient.ParseCharacterFieldList(auron, "abilities", "ability", "limit break"));
}

/// <summary>
/// A remake of the numbered entry is still that entry — Cloud's arms are stated only under
/// the per-release tags.
/// </summary>
[Theory]
[InlineData("ffvii")]
[InlineData("ffviir")]
[InlineData("ffviir2")]
[InlineData("")]
public void ReadsAMainlineOrRemakeTag(string tag)
{
var line = $"|{tag}{(tag.Length > 0 ? " " : "")}weapon=Broadswords";

Assert.Equal("Broadswords",
WikiClient.ParseCharacterField("{{infobox character\n" + line + "\n}}", "weapon"));
}

[Theory]
[InlineData("|ultimate weapon=Ultima Weapon")]
[InlineData("|ffvii ultimate weapon=Ultima Weapon")]
[InlineData("|viicc weapon=SOLDIER Sword")]
[InlineData("|ivtay weapon=Spears")]
public void RejectsAQualifiedOrSpinOffWeapon(string line)
{
Assert.Null(WikiClient.ParseCharacterField("{{infobox character\n" + line + "\n}}", "weapon"));
}

/// <summary>
/// An editor's comment usually opens on the field's line and closes further down the
/// article, so the value arrives here with the comment unterminated.
/// </summary>
[Theory]
[InlineData("|occupation=<!--Physical desc.", null)]
[InlineData("|occupation=<!-- fill this in --> Florist", "Florist")]
[InlineData("|occupation=Florist<!--per the ultimania-->", "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()
{
Expand Down