using System.Globalization; using System.Text.RegularExpressions; using Blog.Models; using Blog.Services; using Microsoft.AspNetCore.Components; namespace Blog.Components.Pages; /// /// Searches Digital Extremes' published drop tables and answers the question they don't: given a /// part, where is the best place to go and get it. /// public partial class Warframe : ComponentBase { private const int MaxSources = 20; private const int MaxRelicSources = 3; private const int MaxMatches = 40; [Inject] public required WarframeDropService Service { get; set; } [SupplyParameterFromQuery(Name = "q")] public string? Query { get; set; } [SupplyParameterFromQuery(Name = "item")] public string? ItemName { get; set; } private DropTableStatus Status { get; set; } = new(null, null); private DropTables? Tables => Status.Tables; private IReadOnlyList Matches { get; set; } = []; /// The item the page is about, if the search settled on one. private ItemDrops? Item { get; set; } protected override async Task OnParametersSetAsync() { Status = await Service.GetTablesAsync().ConfigureAwait(true); Matches = []; Item = null; if (Tables is not { } tables) return; if (!string.IsNullOrWhiteSpace(Query)) { Matches = tables.Search(Query, MaxMatches); } // An explicit ?item= wins; a search with one hit needs no second click. Item = tables.Find(ItemName) ?? (Matches.Count == 1 ? tables.Find(Matches[0]) : null); await base.OnParametersSetAsync().ConfigureAwait(true); } /// /// The relics holding this item, each with the best places to farm the relic itself. Relics /// that still drop come first, then the better chance within each group. /// private IReadOnlyList<(RelicLine Line, IReadOnlyList Sources)> RelicRoutes(ItemDrops item) => item.InRelics .Select(line => ( Line: line, Sources: (IReadOnlyList)Tables!.SourcesFor(line.Relic).Take(MaxRelicSources).ToArray())) .OrderByDescending(route => route.Sources.Count > 0) .ThenByDescending(route => route.Line.Intact) .ToArray(); private string SearchLink(string name) => $"/Warframe?q={Uri.EscapeDataString(Query ?? name)}&item={Uri.EscapeDataString(name)}"; private string ItemLink(string name) => $"/Warframe?item={Uri.EscapeDataString(name)}"; /// /// A chance without the trailing zeroes. Enemy chances are products of two percentages and can /// land under a tenth of a percent, so small numbers keep more digits. /// private static string Percent(double value) => value switch { 0 => "", < 0.01 => "<0.01%", < 1 => value.ToString("0.###", CultureInfo.InvariantCulture) + "%", _ => value.ToString("0.##", CultureInfo.InvariantCulture) + "%" }; private static string CategoryLabel(DropCategory category) => category switch { DropCategory.Mission => "Mission", DropCategory.Key => "Quest / key", DropCategory.Dynamic => "Activity", DropCategory.Sortie => "Sortie", DropCategory.Bounty => "Bounty", DropCategory.Enemy => "Enemy", _ => category.ToString() }; /// /// Components a non-prime item is built from. Only used to trim a part name back to the thing /// the wiki has a page for, so it needs the common ones rather than all of them. /// private static readonly string[] Components = [ "Chassis", "Systems", "Neuroptics", "Harness", "Wings", "Barrel", "Receiver", "Stock", "Blade", "Blades", "Handle", "Hilt", "Grip", "String", "Limb", "Link", "Head", "Guard", "Ornament", "Boot", "Buckle", "Pouch", "Gauntlet", "Bracket", "Carapace", "Cerebrum", "Fuselage", "Engines", "Avionics", "Nose Cone", "Wing" ]; /// /// A leading amount the wiki page's title does not carry: 100X Oxium, 900 Endo, /// 3 Day Affinity Booster. A bare number only counts as one in front of Endo or Credits, /// because most of the time it is part of the name. /// [GeneratedRegex(@"^(?:[0-9][0-9,]*X |[0-9][0-9,]* Day |[0-9][0-9,]* (?=Endo|Credits))")] private static partial Regex AmountPrefix { get; } /// /// A relic handed out already refined: Axi S20 Relic (Radiant) is a reward in its own /// right, but the wiki documents the relic, not the state it arrives in. /// [GeneratedRegex(@" \((?:Intact|Exceptional|Flawless|Radiant)\)$")] private static partial Regex Refinement { get; } /// /// The mark on a Railjack component. DE write Lavan Plating Mk Ii; the wiki documents /// every mark of it on one page, at Lavan Plating. /// [GeneratedRegex(@" Mk [IVXivx]+$")] private static partial Regex ComponentMark { get; } /// /// The wiki page for an item, through MediaWiki's "go" search rather than a direct link. /// /// /// The wiki documents the thing, not the piece: there is a Gyre Prime page but no /// "Gyre Prime Neuroptics Blueprint" one, and linking straight to the item name 404s. So the /// name is trimmed back to what is likely to be a title, and handed to the search rather than /// to /w/. An exact match redirects to the page, and anything the trimming gets wrong lands on /// search results for it, which is still an answer. A direct link would be a dead end. /// private static string WikiLink(string name) => "https://wiki.warframe.com/index.php?search=" + Uri.EscapeDataString(WikiTitle(name)) + "&go=Go"; private static string WikiTitle(string name) { var title = name; title = AmountPrefix.Replace(title, ""); title = Refinement.Replace(title, ""); title = ComponentMark.Replace(title, "").Trim(); // Every part of a prime lives on the prime's own page, so "Akstiletto Prime Barrel" and // "Nikana Prime Blueprint" both want everything up to and including the word Prime. var prime = title.IndexOf(" Prime", StringComparison.Ordinal); if (prime >= 0) return title[..(prime + " Prime".Length)]; if (title.EndsWith(" Relic", StringComparison.Ordinal)) { return title[..^" Relic".Length]; } // "2,000 Credits Cache" is the reward; Credits is the page. if (title.EndsWith(" Cache", StringComparison.Ordinal)) { return title[..^" Cache".Length]; } if (title.EndsWith(" Blueprint", StringComparison.Ordinal)) { title = title[..^" Blueprint".Length]; // "Gara Chassis Blueprint" is documented under Gara, but "Stahlta Blueprint" is already // the title, so only a trailing component comes off. foreach (var component in Components) { if (title.EndsWith(" " + component, StringComparison.Ordinal)) { return title[..^(component.Length + 1)]; } } } return title; } /// /// The size of the index. Invariant rather than the request's culture: the sentence around it /// is English, and "3.481 items" reads as a different number. /// private static string ItemCount(DropTables tables) => tables.Names.Count.ToString("N0", CultureInfo.InvariantCulture); /// /// How long ago this copy of the page was downloaded, taken from the file's own timestamp. Days /// is the unit that matters here: the copy changes only when the site is deployed. /// private static string Age(DateTimeOffset fetchedAt) { var age = DateTimeOffset.UtcNow - fetchedAt; return age switch { { TotalMinutes: < 1 } => "just now", { TotalHours: < 1 } => $"{(int)age.TotalMinutes} min ago", { TotalDays: < 1 } => $"{(int)age.TotalHours} h ago", { TotalDays: < 2 } => "yesterday", _ => $"{(int)age.TotalDays} days ago" }; } }