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"
};
}
}