using System.Globalization; using System.Text.RegularExpressions; using Blog.Models; namespace Blog.Services; /// /// Turns warframe.com/droptables into . /// /// /// The page is one 4 MB machine-generated document: twenty <h3 id> sections, each a /// single flat table whose rows carry no classes or ids. Three row grammars cover all twenty: /// /// /// Two-column reward tables (missions, keys, dynamic locations, sorties, relics): a /// full-width header names the place, an optional Rotation X header splits it, and every /// other row is item | rarity (chance). /// Three-column bounty tables: as above, plus an indented header per stage and a leading /// spacer cell on every row. /// Three-column "by source" tables (mods, blueprints, resources, relics by enemy): a header /// naming the enemy and its overall drop chance, then that table's contents. /// /// /// Rows that match no grammar are skipped rather than thrown over, so a section DE add later costs /// coverage instead of the whole page. /// public static partial class DropTableParser { [GeneratedRegex("""

]*>""")] private static partial Regex SectionHeading { get; } [GeneratedRegex("""[^>]*)>(?.*?)""", RegexOptions.Singleline)] private static partial Regex RowPattern { get; } [GeneratedRegex("""<(?th|td)(?[^>]*)>(?.*?)>""", RegexOptions.Singleline)] private static partial Regex CellPattern { get; } /// DE write every chance as Rare (7.69%). [GeneratedRegex(@"^(?[A-Za-z ]+?)\s*\((?[0-9.]+)%\)$")] private static partial Regex ChancePattern { get; } /// The "by source" headers all end in … Drop Chance: 3.00%. [GeneratedRegex(@"Drop Chance:\s*(?[0-9.]+)%")] private static partial Regex SourceChancePattern { get; } [GeneratedRegex(@"Last Update:\s*(?[^<\r\n]+)")] private static partial Regex LastUpdatePattern { get; } /// A relic appears four times, once per refinement: Axi A1 Relic (Intact). [GeneratedRegex(@"^(?.+? Relic) \((?Intact|Exceptional|Flawless|Radiant)\)$")] private static partial Regex RelicHeaderPattern { get; } /// Which grammar each section is written in, and what its sources should be called. private static readonly Dictionary RewardSections = new(StringComparer.Ordinal) { ["missionRewards"] = DropCategory.Mission, ["keyRewards"] = DropCategory.Key, ["transientRewards"] = DropCategory.Dynamic, ["sortieRewards"] = DropCategory.Sortie }; private static readonly string[] BountySections = [ "cetusRewards", "solarisRewards", "deimosRewards", "zarimanRewards", "entratiLabRewards", "hexRewards" ]; private static readonly string[] SourceSections = [ "modByAvatar", "blueprintByAvatar", "resourceByAvatar", "sigilByAvatar", "additionalItemByAvatar", "relicByAvatar" ]; public static DropTables Parse(string html, DateTimeOffset fetchedAt) { var builder = new Builder(); foreach (var (id, section) in Sections(html)) { if (RewardSections.TryGetValue(id, out var category)) { ParseRewards(section, category, builder); } else if (BountySections.Contains(id, StringComparer.Ordinal)) { ParseBounties(section, builder); } else if (SourceSections.Contains(id, StringComparer.Ordinal)) { ParseBySource(section, builder); } else if (id == "relicRewards") { ParseRelics(section, builder); } // modByDrop, blueprintByDrop and resourceByDrop hold the *ByAvatar data indexed the // other way round. Builder indexes both ways, so reading them would only duplicate. } return builder.Build(LastUpdate(html), fetchedAt); } private static string LastUpdate(string html) { var match = LastUpdatePattern.Match(html); return match.Success ? match.Groups["date"].Value.Trim() : "unknown"; } /// Each <h3 id> and everything up to the next one. private static IEnumerable<(string Id, string Html)> Sections(string html) { var headings = SectionHeading.Matches(html); for (var i = 0; i < headings.Count; i++) { var start = headings[i].Index; var end = i + 1 < headings.Count ? headings[i + 1].Index : html.Length; yield return (headings[i].Groups["id"].Value, html[start..end]); } } /// Missions, keys, dynamic locations and sorties: a place, maybe a rotation, then drops. private static void ParseRewards(string section, DropCategory category, Builder builder) { string? location = null; string? rotation = null; foreach (var row in Rows(section)) { switch (row) { // `Rotation A` splits the place above it; anything else full-width is a new place. case [{ Header: true, FullWidth: true, Text: var header }]: if (IsRotation(header)) rotation = header; else (location, rotation) = (header, null); break; // Dynamic locations write their rotations as a header and an empty cell instead. case [{ Header: true, Text: var header }, { Text: "" }]: rotation = IsRotation(header) ? header : rotation; break; case [{ Header: false, Text: var item }, { Header: false, Text: var chance }] when location is not null && Chance(chance) is (var rarity, var pct): builder.AddSource(item, new DropSource(category, location, rotation, rarity, pct)); break; } } } /// Open-world bounties: a bounty, a rotation and a stage, then drops behind a spacer cell. private static void ParseBounties(string section, Builder builder) { string? bounty = null; string? rotation = null; string? stage = null; foreach (var row in Rows(section)) { switch (row) { case [{ Header: true, FullWidth: true, Text: var header }]: if (IsRotation(header)) rotation = header; else (bounty, rotation, stage) = (header, null, null); break; case [{ Spacer: true }, { Header: true, Text: var header }]: stage = header; break; case [_, { Header: false, Text: var item }, { Header: false, Text: var chance }] when bounty is not null && Chance(chance) is (var rarity, var pct): builder.AddSource(item, new DropSource(DropCategory.Bounty, bounty, Join(rotation, stage), rarity, pct)); break; } } } /// /// Drops by enemy. The header carries how often the enemy drops from its table at all, so a /// Very Common (100%) line off a 3% table is a 3% kill. /// private static void ParseBySource(string section, Builder builder) { string? source = null; var sourceChance = 100.0; foreach (var row in Rows(section)) { switch (row) { case [{ Header: true, Text: var name }, { Header: true, Text: var chance }]: source = name; var match = SourceChancePattern.Match(chance); sourceChance = match.Success ? Percent(match.Groups["pct"].Value) : 100.0; break; case [_, { Header: false, Text: var item }, { Header: false, Text: var chance }] when source is not null && Chance(chance) is (var rarity, var pct): builder.AddSource(item, new DropSource(DropCategory.Enemy, source, null, rarity, pct * sourceChance / 100.0)); break; } } } /// /// Relic contents. Every relic is listed once per refinement; only Intact and Radiant are kept. /// private static void ParseRelics(string section, Builder builder) { string? relic = null; string? refinement = null; foreach (var row in Rows(section)) { switch (row) { case [{ Header: true, FullWidth: true, Text: var header }]: var match = RelicHeaderPattern.Match(header); (relic, refinement) = match.Success ? (match.Groups["relic"].Value, match.Groups["refinement"].Value) : (null, null); break; case [{ Header: false, Text: var item }, { Header: false, Text: var chance }] when relic is not null && Chance(chance) is (var rarity, var pct): builder.AddRelicLine(relic, item, rarity, pct, refinement == "Radiant"); break; } } } private static bool IsRotation(string header) => header.StartsWith("Rotation", StringComparison.OrdinalIgnoreCase); private static string? Join(string? rotation, string? stage) => (rotation, stage) switch { (null, null) => null, (null, var s) => s, (var r, null) => r, var (r, s) => $"{r} · {s}" }; private static (string Rarity, double Percent)? Chance(string text) { var match = ChancePattern.Match(text); return match.Success ? (match.Groups["rarity"].Value, Percent(match.Groups["pct"].Value)) : null; } private static double Percent(string text) => double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : 0; /// One cell: what it says, and the two attributes the grammars branch on. private readonly record struct Cell(string Text, bool Header, bool FullWidth, bool Spacer); private static IEnumerable Rows(string section) { foreach (Match row in RowPattern.Matches(section)) { var cells = CellPattern.Matches(row.Groups["body"].Value); if (cells.Count == 0) continue; var parsed = new Cell[cells.Count]; for (var i = 0; i < cells.Count; i++) { var attrs = cells[i].Groups["attrs"].Value; parsed[i] = new Cell( Text: cells[i].Groups["text"].Value.Trim(), Header: cells[i].Groups["tag"].Value == "th", FullWidth: attrs.Contains("colspan", StringComparison.Ordinal), Spacer: attrs.Contains("pad-cell", StringComparison.Ordinal)); } yield return parsed; } } /// Collects rows as they are read, then sorts and indexes them. private sealed class Builder { private readonly Dictionary> _sources = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<(string Relic, string Item), RelicLine> _relicLines = new(); public void AddSource(string item, DropSource source) { if (item.Length == 0) return; if (!_sources.TryGetValue(item, out var list)) { _sources[item] = list = []; } list.Add(source); } public void AddRelicLine(string relic, string item, string rarity, double chance, bool radiant) { if (item.Length == 0) return; var existing = _relicLines.GetValueOrDefault((relic, item)) ?? new RelicLine(relic, item, rarity, Intact: 0, Radiant: 0); _relicLines[(relic, item)] = radiant ? existing with { Radiant = chance } : existing with { Rarity = rarity, Intact = chance }; } public DropTables Build(string lastUpdate, DateTimeOffset fetchedAt) { var byRelic = _relicLines.Values .GroupBy(line => line.Relic, StringComparer.OrdinalIgnoreCase) .ToDictionary( group => group.Key, group => (IReadOnlyList)group.OrderByDescending(line => line.Intact).ToArray(), StringComparer.OrdinalIgnoreCase); var byItem = _relicLines.Values .GroupBy(line => line.Item, StringComparer.OrdinalIgnoreCase) .ToDictionary( group => group.Key, group => (IReadOnlyList)group.OrderByDescending(line => line.Intact).ToArray(), StringComparer.OrdinalIgnoreCase); // A relic is both an item you farm and a container of items, so both indexes and the // drop sources meet under one name. var names = _sources.Keys .Concat(byRelic.Keys) .Concat(byItem.Keys) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) .ToArray(); var items = names.ToDictionary( name => name, name => new ItemDrops { Name = name, Sources = _sources.TryGetValue(name, out var sources) ? sources.OrderByDescending(source => source.Chance).ToArray() : [], InRelics = byItem.GetValueOrDefault(name, []), Contents = byRelic.GetValueOrDefault(name, []) }, StringComparer.OrdinalIgnoreCase); return new DropTables { LastUpdate = lastUpdate, FetchedAt = fetchedAt, Items = items, Names = names }; } } }