Blog/Components/Pages/Warframe.razor.cs 8.3 K · 212 lines · raw · history

1 using System.Globalization;
2 using System.Text.RegularExpressions;
3 using Blog.Models;
4 using Blog.Services;
5 using Microsoft.AspNetCore.Components;
6
7 namespace Blog.Components.Pages;
8
9 /// <summary>
10 /// Searches Digital Extremes' published drop tables and answers the question they don't: given a
11 /// part, where is the best place to go and get it.
12 /// </summary>
13 public partial class Warframe : ComponentBase
14 {
15 private const int MaxSources = 20;
16
17 private const int MaxRelicSources = 3;
18
19 private const int MaxMatches = 40;
20
21 [Inject]
22 public required WarframeDropService Service { get; set; }
23
24 [SupplyParameterFromQuery(Name = "q")]
25 public string? Query { get; set; }
26
27 [SupplyParameterFromQuery(Name = "item")]
28 public string? ItemName { get; set; }
29
30 private DropTableStatus Status { get; set; } = new(null, null);
31
32 private DropTables? Tables => Status.Tables;
33
34 private IReadOnlyList<string> Matches { get; set; } = [];
35
36 /// <summary>The item the page is about, if the search settled on one.</summary>
37 private ItemDrops? Item { get; set; }
38
39 protected override async Task OnParametersSetAsync()
40 {
41 Status = await Service.GetTablesAsync().ConfigureAwait(true);
42
43 Matches = [];
44 Item = null;
45
46 if (Tables is not { } tables) return;
47
48 if (!string.IsNullOrWhiteSpace(Query))
49 {
50 Matches = tables.Search(Query, MaxMatches);
51 }
52
53 // An explicit ?item= wins; a search with one hit needs no second click.
54 Item = tables.Find(ItemName)
55 ?? (Matches.Count == 1 ? tables.Find(Matches[0]) : null);
56
57 await base.OnParametersSetAsync().ConfigureAwait(true);
58 }
59
60 /// <summary>
61 /// The relics holding this item, each with the best places to farm the relic itself. Relics
62 /// that still drop come first, then the better chance within each group.
63 /// </summary>
64 private IReadOnlyList<(RelicLine Line, IReadOnlyList<DropSource> Sources)> RelicRoutes(ItemDrops item) =>
65 item.InRelics
66 .Select(line => (
67 Line: line,
68 Sources: (IReadOnlyList<DropSource>)Tables!.SourcesFor(line.Relic).Take(MaxRelicSources).ToArray()))
69 .OrderByDescending(route => route.Sources.Count > 0)
70 .ThenByDescending(route => route.Line.Intact)
71 .ToArray();
72
73 private string SearchLink(string name) =>
74 $"/Warframe?q={Uri.EscapeDataString(Query ?? name)}&item={Uri.EscapeDataString(name)}";
75
76 private string ItemLink(string name) => $"/Warframe?item={Uri.EscapeDataString(name)}";
77
78 /// <summary>
79 /// A chance without the trailing zeroes. Enemy chances are products of two percentages and can
80 /// land under a tenth of a percent, so small numbers keep more digits.
81 /// </summary>
82 private static string Percent(double value) => value switch
83 {
84 0 => "",
85 < 0.01 => "<0.01%",
86 < 1 => value.ToString("0.###", CultureInfo.InvariantCulture) + "%",
87 _ => value.ToString("0.##", CultureInfo.InvariantCulture) + "%"
88 };
89
90 private static string CategoryLabel(DropCategory category) => category switch
91 {
92 DropCategory.Mission => "Mission",
93 DropCategory.Key => "Quest / key",
94 DropCategory.Dynamic => "Activity",
95 DropCategory.Sortie => "Sortie",
96 DropCategory.Bounty => "Bounty",
97 DropCategory.Enemy => "Enemy",
98 _ => category.ToString()
99 };
100
101 /// <summary>
102 /// Components a non-prime item is built from. Only used to trim a part name back to the thing
103 /// the wiki has a page for, so it needs the common ones rather than all of them.
104 /// </summary>
105 private static readonly string[] Components =
106 [
107 "Chassis", "Systems", "Neuroptics", "Harness", "Wings", "Barrel", "Receiver", "Stock",
108 "Blade", "Blades", "Handle", "Hilt", "Grip", "String", "Limb", "Link", "Head", "Guard",
109 "Ornament", "Boot", "Buckle", "Pouch", "Gauntlet", "Bracket", "Carapace", "Cerebrum",
110 "Fuselage", "Engines", "Avionics", "Nose Cone", "Wing"
111 ];
112
113 /// <summary>
114 /// A leading amount the wiki page's title does not carry: <c>100X Oxium</c>, <c>900 Endo</c>,
115 /// <c>3 Day Affinity Booster</c>. A bare number only counts as one in front of Endo or Credits,
116 /// because most of the time it is part of the name.
117 /// </summary>
118 [GeneratedRegex(@"^(?:[0-9][0-9,]*X |[0-9][0-9,]* Day |[0-9][0-9,]* (?=Endo|Credits))")]
119 private static partial Regex AmountPrefix { get; }
120
121 /// <summary>
122 /// A relic handed out already refined: <c>Axi S20 Relic (Radiant)</c> is a reward in its own
123 /// right, but the wiki documents the relic, not the state it arrives in.
124 /// </summary>
125 [GeneratedRegex(@" \((?:Intact|Exceptional|Flawless|Radiant)\)$")]
126 private static partial Regex Refinement { get; }
127
128 /// <summary>
129 /// The mark on a Railjack component. DE write <c>Lavan Plating Mk Ii</c>; the wiki documents
130 /// every mark of it on one page, at <c>Lavan Plating</c>.
131 /// </summary>
132 [GeneratedRegex(@" Mk [IVXivx]+$")]
133 private static partial Regex ComponentMark { get; }
134
135 /// <summary>
136 /// The wiki page for an item, through MediaWiki's "go" search rather than a direct link.
137 /// </summary>
138 /// <remarks>
139 /// The wiki documents the thing, not the piece: there is a Gyre Prime page but no
140 /// "Gyre Prime Neuroptics Blueprint" one, and linking straight to the item name 404s. So the
141 /// name is trimmed back to what is likely to be a title, and handed to the search rather than
142 /// to /w/. An exact match redirects to the page, and anything the trimming gets wrong lands on
143 /// search results for it, which is still an answer. A direct link would be a dead end.
144 /// </remarks>
145 private static string WikiLink(string name) =>
146 "https://wiki.warframe.com/index.php?search=" + Uri.EscapeDataString(WikiTitle(name)) + "&go=Go";
147
148 private static string WikiTitle(string name)
149 {
150 var title = name;
151 title = AmountPrefix.Replace(title, "");
152 title = Refinement.Replace(title, "");
153 title = ComponentMark.Replace(title, "").Trim();
154
155 // Every part of a prime lives on the prime's own page, so "Akstiletto Prime Barrel" and
156 // "Nikana Prime Blueprint" both want everything up to and including the word Prime.
157 var prime = title.IndexOf(" Prime", StringComparison.Ordinal);
158 if (prime >= 0) return title[..(prime + " Prime".Length)];
159
160 if (title.EndsWith(" Relic", StringComparison.Ordinal))
161 {
162 return title[..^" Relic".Length];
163 }
164
165 // "2,000 Credits Cache" is the reward; Credits is the page.
166 if (title.EndsWith(" Cache", StringComparison.Ordinal))
167 {
168 return title[..^" Cache".Length];
169 }
170
171 if (title.EndsWith(" Blueprint", StringComparison.Ordinal))
172 {
173 title = title[..^" Blueprint".Length];
174
175 // "Gara Chassis Blueprint" is documented under Gara, but "Stahlta Blueprint" is already
176 // the title, so only a trailing component comes off.
177 foreach (var component in Components)
178 {
179 if (title.EndsWith(" " + component, StringComparison.Ordinal))
180 {
181 return title[..^(component.Length + 1)];
182 }
183 }
184 }
185
186 return title;
187 }
188
189 /// <summary>
190 /// The size of the index. Invariant rather than the request's culture: the sentence around it
191 /// is English, and "3.481 items" reads as a different number.
192 /// </summary>
193 private static string ItemCount(DropTables tables) =>
194 tables.Names.Count.ToString("N0", CultureInfo.InvariantCulture);
195
196 /// <summary>
197 /// How long ago this copy of the page was downloaded, taken from the file's own timestamp. Days
198 /// is the unit that matters here: the copy changes only when the site is deployed.
199 /// </summary>
200 private static string Age(DateTimeOffset fetchedAt)
201 {
202 var age = DateTimeOffset.UtcNow - fetchedAt;
203 return age switch
204 {
205 { TotalMinutes: < 1 } => "just now",
206 { TotalHours: < 1 } => $"{(int)age.TotalMinutes} min ago",
207 { TotalDays: < 1 } => $"{(int)age.TotalHours} h ago",
208 { TotalDays: < 2 } => "yesterday",
209 _ => $"{(int)age.TotalDays} days ago"
210 };
211 }
212 }