Add a Warframe drop table search at /Warframe

Digital Extremes publish every drop in the game at warframe.com/droptables, but only as a table per source: to answer "where do I get this part" you have to read all twenty of them backwards. This indexes the page by item instead. A part shows where it drops sorted by chance, and a prime part shows the relics holding it with the best places to farm each relic. The source is 4 MB of machine-generated HTML, twenty h3 sections of one flat table each, with no classes or ids on the rows to select on. There is no structure to navigate, so DropTableParser walks rows with regexes rather than taking on an HTML parser. Three row grammars cover all twenty sections: two-column reward tables (missions, keys, dynamic locations, sorties, relics), three-column bounty tables with a stage header and a spacer cell, and three-column by-source tables for enemy drops. It reads 3,481 items in about 200ms, which matches a separate count of the distinct names in the page. Rows matching no grammar are skipped rather than thrown over, so a section DE add later costs coverage instead of the whole page. Enemy chances are stored as the enemy's own drop chance multiplied by the item's share of its table. Neither number means anything alone: a Very Common (100%) line off a 3% table is a 3% kill, and sorting on the 100% would put it above everything. modByDrop, blueprintByDrop and resourceByDrop are skipped. They hold the same rows as the *ByAvatar sections indexed the other way round, and the parser builds both indexes itself. WarframeDropService keeps one parsed copy in a field behind a semaphore rather than in HybridCache like the rest of the site, because HybridCache serializes what it stores and this is tens of thousands of records per page view. That only works while the service is a singleton: a typed AddHttpClient<WarframeDropService>() registration makes it transient, which downloaded and re-parsed the whole page for every request until it was registered explicitly, so it takes IHttpClientFactory and a named client. A failed refresh keeps the copy it already had and backs off five minutes; tables a few days stale still answer the question. The page is server-rendered off the query string, ?q= to search and ?item= to pick one of the matches, so results are linkable and work without script. Relics that still drop are listed above vaulted ones whatever their chance in the relic, since a 17% share of something nothing drops is not a farm. Warframe.razor.js only fills the search box's datalist from /api/warframe/names; it does not import common.module.js, which binds to a #log element this page has no use for. The match list is a grid rather than CSS columns. columns lets a list item fragment, so "Gyre Prime Neuroptics Blueprint" left Blueprint at the top of the next column. A grid item cannot break, a long name wraps inside its own cell, and filling row by row puts the closest match first in reading order. The 26ch track floor is wrapped in min(26ch, 100%) so a phone narrower than one track gets a single column instead of overflowing the page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-09-13 11:55 UTC
commit
e907671854505c7a4fb2d7451b29a5a676e5d5e8
parent
a273ce43a6
tree
browse at this commit

10 files changed +1087 -1

Blog/Components/Layout/SiteFooter.razor +1 -0

@@ -11,6 +11,7 @@
11 11 <li><NavLink href="/BRPTestData">BRP Test Data</NavLink></li>
12 12 <li><NavLink href="/Query">Query</NavLink></li>
13 13 <li><NavLink href="/Storage">Storage</NavLink></li>
14 + <li><NavLink href="/Warframe">Warframe drops</NavLink></li>
14 15 @* <li><NavLink href="/rvrb">rvrb bot stats</NavLink></li> *@
15 16 <li><NavLink href="https://git.bes.is">Git</NavLink></li>
16 17 <!-- <li><a href="/webrtc.html">WebRTC</a></li> -->

Blog/Components/Pages/Warframe.razor +197 -0

@@ -0,0 +1,197 @@
1 +@page "/Warframe"
2 +<PageTitle>Warframe drops</PageTitle>
3 +
4 +<main>
5 + <h1>Warframe drops</h1>
6 +
7 + <form method="get" action="/Warframe" class="flex-row">
8 + <label for="q">Search for a part</label>
9 + <input type="search" id="q" name="q" value="@Query" list="drop-names" autofocus
10 + placeholder="Nikana Prime Blade" autocomplete="off"/>
11 + <button type="submit">Search</button>
12 + </form>
13 +
14 + @* Filled in by script from /api/warframe/names. *@
15 + <datalist id="drop-names"></datalist>
16 +
17 + @if (Status.Error is { } error)
18 + {
19 + <p class="error">@error</p>
20 + }
21 +
22 + @if (Tables is not { } tables)
23 + {
24 + @if (Status.Error is null)
25 + {
26 + <p>Reading the drop tables…</p>
27 + }
28 + }
29 + else
30 + {
31 + @if (Matches.Count > 1)
32 + {
33 + <Panel Legend="@($"{Matches.Count} matches")">
34 + <ul class="matches">
35 + @foreach (var name in Matches)
36 + {
37 + <li>
38 + <a href="@SearchLink(name)" class="@(name == Item?.Name ? "active" : null)">@name</a>
39 + </li>
40 + }
41 + </ul>
42 + </Panel>
43 + }
44 +
45 + @if (Item is { } item)
46 + {
47 + <h2 class="item-name">@item.Name</h2>
48 +
49 + @if (item.Sources.Count == 0 && !item.FromRelics)
50 + {
51 + <p>Nothing in the drop tables gives this out.</p>
52 + }
53 +
54 + @if (item.FromRelics)
55 + {
56 + <Panel Legend="From relics">
57 + <p>
58 + <b>Intact</b> is the chance in an unrefined relic, <b>Radiant</b> after
59 + four upgrades. Relics you can still farm are listed first.
60 + </p>
61 +
62 + @foreach (var (line, relicSources) in RelicRoutes(item))
63 + {
64 + <div class="relic">
65 + <h3>
66 + <a href="@ItemLink(line.Relic)">@line.Relic</a>
67 + <span class="muted">@line.Rarity</span>
68 + <span>@Percent(line.Intact) intact → @Percent(line.Radiant) radiant</span>
69 + </h3>
70 +
71 + @if (relicSources.Count == 0)
72 + {
73 + <p class="muted">Vaulted: nothing currently drops this relic.</p>
74 + }
75 + else
76 + {
77 + <p class="muted relic-lead">Best places to farm this relic</p>
78 + <div class="table-scroll">
79 + <table>
80 + <tbody>
81 + @foreach (var source in relicSources)
82 + {
83 + <tr>
84 + <td>@source.Location</td>
85 + <td class="muted">@source.Context</td>
86 + <td class="muted">@CategoryLabel(source.Category)</td>
87 + <td class="chance">@Percent(source.Chance)</td>
88 + </tr>
89 + }
90 + </tbody>
91 + </table>
92 + </div>
93 + }
94 + </div>
95 + }
96 + </Panel>
97 + }
98 +
99 + @if (item.Sources.Count > 0)
100 + {
101 + <Panel Legend="@(item.FromRelics ? "Also drops directly from" : "Drops from")">
102 + <div class="table-scroll">
103 + <table>
104 + <thead>
105 + <tr>
106 + <th>Where</th>
107 + <th>Rotation / stage</th>
108 + <th>Kind</th>
109 + <th class="chance">Chance</th>
110 + </tr>
111 + </thead>
112 + <tbody>
113 + @foreach (var source in item.Sources.Take(MaxSources))
114 + {
115 + <tr>
116 + <td>@source.Location</td>
117 + <td class="muted">@source.Context</td>
118 + <td class="muted">@CategoryLabel(source.Category)</td>
119 + <td class="chance" title="@source.Rarity">@Percent(source.Chance)</td>
120 + </tr>
121 + }
122 + </tbody>
123 + </table>
124 + </div>
125 +
126 + @if (item.Sources.Count > MaxSources)
127 + {
128 + <p class="muted">
129 + @(item.Sources.Count - MaxSources) more places with a lower chance.
130 + </p>
131 + }
132 + </Panel>
133 + }
134 +
135 + @if (item.IsRelic)
136 + {
137 + <Panel Legend="Contains">
138 + <div class="table-scroll">
139 + <table>
140 + <thead>
141 + <tr>
142 + <th>Reward</th>
143 + <th>Rarity</th>
144 + <th class="chance">Intact</th>
145 + <th class="chance">Radiant</th>
146 + </tr>
147 + </thead>
148 + <tbody>
149 + @foreach (var line in item.Contents)
150 + {
151 + <tr>
152 + <td><a href="@ItemLink(line.Item)">@line.Item</a></td>
153 + <td class="muted">@line.Rarity</td>
154 + <td class="chance">@Percent(line.Intact)</td>
155 + <td class="chance">@Percent(line.Radiant)</td>
156 + </tr>
157 + }
158 + </tbody>
159 + </table>
160 + </div>
161 + </Panel>
162 + }
163 + }
164 + else if (Matches.Count == 0 && !string.IsNullOrWhiteSpace(Query))
165 + {
166 + <p>Nothing called “@Query” drops anywhere. Try part of the name.</p>
167 + }
168 + else if (Matches.Count == 0)
169 + {
170 + <Panel Legend="What this is">
171 + <p>
172 + @ItemCount(tables) items from Warframe's published drop tables. Search a
173 + part to see where it drops, sorted by chance; for prime parts, the relics
174 + holding it and where to farm those.
175 + </p>
176 + <p>
177 + Chances are per reward roll, as published. For enemies, the enemy's drop
178 + chance multiplied by the item's share of it.
179 + </p>
180 + <p>
181 + Try
182 + <a href="/Warframe?q=Gyre+Prime">Gyre Prime</a>,
183 + <a href="/Warframe?q=Tellurium">Tellurium</a>,
184 + <a href="/Warframe?q=Nitain+Extract">Nitain Extract</a> or
185 + <a href="/Warframe?q=Lith+Q3">Lith Q3</a>.
186 + </p>
187 + </Panel>
188 + }
189 +
190 + <p class="muted">
191 + <a href="https://www.warframe.com/droptables">Drop tables</a> by Digital Extremes,
192 + last updated @tables.LastUpdate; read @Age(tables.FetchedAt).
193 + </p>
194 + }
195 +</main>
196 +
197 +<script type="module" src="@Assets["Components/Pages/Warframe.razor.js"]"></script>

Blog/Components/Pages/Warframe.razor.cs +119 -0

@@ -0,0 +1,119 @@
1 +using System.Globalization;
2 +using Blog.Models;
3 +using Blog.Services;
4 +using Microsoft.AspNetCore.Components;
5 +
6 +namespace Blog.Components.Pages;
7 +
8 +/// <summary>
9 +/// Searches Digital Extremes' published drop tables and answers the question they don't: given a
10 +/// part, where is the best place to go and get it.
11 +/// </summary>
12 +public partial class Warframe : ComponentBase
13 +{
14 + private const int MaxSources = 20;
15 +
16 + private const int MaxRelicSources = 3;
17 +
18 + private const int MaxMatches = 40;
19 +
20 + [Inject]
21 + public required WarframeDropService Service { get; set; }
22 +
23 + [SupplyParameterFromQuery(Name = "q")]
24 + public string? Query { get; set; }
25 +
26 + [SupplyParameterFromQuery(Name = "item")]
27 + public string? ItemName { get; set; }
28 +
29 + private DropTableStatus Status { get; set; } = new(null, null);
30 +
31 + private DropTables? Tables => Status.Tables;
32 +
33 + private IReadOnlyList<string> Matches { get; set; } = [];
34 +
35 + /// <summary>The item the page is about, if the search settled on one.</summary>
36 + private ItemDrops? Item { get; set; }
37 +
38 + protected override async Task OnParametersSetAsync()
39 + {
40 + Status = await Service.GetTablesAsync().ConfigureAwait(true);
41 +
42 + Matches = [];
43 + Item = null;
44 +
45 + if (Tables is not { } tables) return;
46 +
47 + if (!string.IsNullOrWhiteSpace(Query))
48 + {
49 + Matches = tables.Search(Query, MaxMatches);
50 + }
51 +
52 + // An explicit ?item= wins; a search with one hit needs no second click.
53 + Item = tables.Find(ItemName)
54 + ?? (Matches.Count == 1 ? tables.Find(Matches[0]) : null);
55 +
56 + await base.OnParametersSetAsync().ConfigureAwait(true);
57 + }
58 +
59 + /// <summary>
60 + /// The relics holding this item, each with the best places to farm the relic itself. Relics
61 + /// that still drop come first, then the better chance within each group.
62 + /// </summary>
63 + private IReadOnlyList<(RelicLine Line, IReadOnlyList<DropSource> Sources)> RelicRoutes(ItemDrops item) =>
64 + item.InRelics
65 + .Select(line => (
66 + Line: line,
67 + Sources: (IReadOnlyList<DropSource>)Tables!.SourcesFor(line.Relic).Take(MaxRelicSources).ToArray()))
68 + .OrderByDescending(route => route.Sources.Count > 0)
69 + .ThenByDescending(route => route.Line.Intact)
70 + .ToArray();
71 +
72 + private string SearchLink(string name) =>
73 + $"/Warframe?q={Uri.EscapeDataString(Query ?? name)}&item={Uri.EscapeDataString(name)}";
74 +
75 + private string ItemLink(string name) => $"/Warframe?item={Uri.EscapeDataString(name)}";
76 +
77 + /// <summary>
78 + /// A chance without the trailing zeroes. Enemy chances are products of two percentages and can
79 + /// land under a tenth of a percent, so small numbers keep more digits.
80 + /// </summary>
81 + private static string Percent(double value) => value switch
82 + {
83 + 0 => "",
84 + < 0.01 => "<0.01%",
85 + < 1 => value.ToString("0.###", CultureInfo.InvariantCulture) + "%",
86 + _ => value.ToString("0.##", CultureInfo.InvariantCulture) + "%"
87 + };
88 +
89 + private static string CategoryLabel(DropCategory category) => category switch
90 + {
91 + DropCategory.Mission => "Mission",
92 + DropCategory.Key => "Quest / key",
93 + DropCategory.Dynamic => "Activity",
94 + DropCategory.Sortie => "Sortie",
95 + DropCategory.Bounty => "Bounty",
96 + DropCategory.Enemy => "Enemy",
97 + _ => category.ToString()
98 + };
99 +
100 + /// <summary>
101 + /// The size of the index. Invariant rather than the request's culture: the sentence around it
102 + /// is English, and "3.481 items" reads as a different number.
103 + /// </summary>
104 + private static string ItemCount(DropTables tables) =>
105 + tables.Names.Count.ToString("N0", CultureInfo.InvariantCulture);
106 +
107 + /// <summary>How stale the copy on screen is.</summary>
108 + private static string Age(DateTimeOffset fetchedAt)
109 + {
110 + var age = DateTimeOffset.UtcNow - fetchedAt;
111 + return age switch
112 + {
113 + { TotalMinutes: < 1 } => "just now",
114 + { TotalHours: < 1 } => $"{(int)age.TotalMinutes} min ago",
115 + { TotalDays: < 1 } => $"{(int)age.TotalHours} h ago",
116 + _ => $"{(int)age.TotalDays} d ago"
117 + };
118 + }
119 +}

Blog/Components/Pages/Warframe.razor.js +54 -0

@@ -0,0 +1,54 @@
1 +// Typeahead for the search box. Rather than ship 3,500 item names to every visitor, the input asks
2 +// for the handful matching what has been typed and drops them into its <datalist>.
3 +//
4 +// Not importing /common.module.js: that module binds to a #log element on load, which this page
5 +// does not have.
6 +
7 +const input = document.getElementById("q");
8 +const names = document.getElementById("drop-names");
9 +
10 +/** The request whose answer is still wanted; any earlier one is stale. */
11 +let inFlight = null;
12 +
13 +if (input && names) {
14 + input.addEventListener("input", debounce(suggest, 150));
15 +
16 + // Someone arriving on ?q=… already has a query typed; offer the list for it straight away.
17 + if (input.value.trim()) suggest();
18 +}
19 +
20 +async function suggest() {
21 + const query = input.value.trim();
22 + if (query.length < 2) {
23 + names.replaceChildren();
24 + return;
25 + }
26 +
27 + // A slow response for "ni" must not overwrite the suggestions for "nikana p".
28 + inFlight?.abort();
29 + const request = inFlight = new AbortController();
30 +
31 + try {
32 + const response = await fetch(`/api/warframe/names?q=${encodeURIComponent(query)}`, {signal: request.signal});
33 + if (!response.ok) return;
34 +
35 + const suggestions = await response.json();
36 + names.replaceChildren(...suggestions.map(name => {
37 + const option = document.createElement("option");
38 + option.value = name;
39 + return option;
40 + }));
41 + } catch (error) {
42 + // An abort is this function doing its job; any other failure just means no suggestions,
43 + // and the form still submits.
44 + if (error.name !== "AbortError") console.debug("suggest failed", error);
45 + }
46 +}
47 +
48 +function debounce(callback, wait) {
49 + let timeoutId = null;
50 + return (...args) => {
51 + window.clearTimeout(timeoutId);
52 + timeoutId = window.setTimeout(() => callback(...args), wait);
53 + };
54 +}

Blog/Models/WarframeDrops.cs +129 -0

@@ -0,0 +1,129 @@
1 +namespace Blog.Models;
2 +
3 +/// <summary>Which of Digital Extremes' tables a source came out of.</summary>
4 +public enum DropCategory
5 +{
6 + /// <summary>A node on the star chart: <c>Mercury/Apollodorus (Survival)</c>.</summary>
7 + Mission,
8 +
9 + /// <summary>A quest or key mission, run from the codex rather than the star chart.</summary>
10 + Key,
11 +
12 + /// <summary>Arbitrations, Kuva Siphons, Void Storms: activities without a fixed node.</summary>
13 + Dynamic,
14 +
15 + /// <summary>The daily sortie.</summary>
16 + Sortie,
17 +
18 + /// <summary>An open-world bounty stage.</summary>
19 + Bounty,
20 +
21 + /// <summary>Something you kill, or open: an enemy, a container, a crewship.</summary>
22 + Enemy
23 +}
24 +
25 +/// <summary>One place an item drops, and how likely it is there.</summary>
26 +/// <param name="Category">The kind of activity this is.</param>
27 +/// <param name="Location">The node, bounty, activity or enemy.</param>
28 +/// <param name="Context">Which rotation or stage of it, when the location has more than one.</param>
29 +/// <param name="Rarity">DE's own word for the chance: <c>Rare</c>, <c>Uncommon</c>, and so on.</param>
30 +/// <param name="Chance">
31 +/// Percent per roll. For enemies, the source's drop chance multiplied by the item's share of that
32 +/// drop: a 100% share of a 3% table is a 3% kill.
33 +/// </param>
34 +public sealed record DropSource(
35 + DropCategory Category,
36 + string Location,
37 + string? Context,
38 + string Rarity,
39 + double Chance);
40 +
41 +/// <summary>
42 +/// One line of a relic's reward table. Only Intact and Radiant are kept; Exceptional and Flawless
43 +/// sit between them.
44 +/// </summary>
45 +public sealed record RelicLine(string Relic, string Item, string Rarity, double Intact, double Radiant);
46 +
47 +/// <summary>Everything the drop tables say about one item.</summary>
48 +public sealed class ItemDrops
49 +{
50 + public required string Name { get; init; }
51 +
52 + /// <summary>Where it drops directly, best chance first.</summary>
53 + public required IReadOnlyList<DropSource> Sources { get; init; }
54 +
55 + /// <summary>The relics that can contain it, best intact chance first. Empty for most items.</summary>
56 + public required IReadOnlyList<RelicLine> InRelics { get; init; }
57 +
58 + /// <summary>What this item contains, when it is itself a relic. Empty otherwise.</summary>
59 + public required IReadOnlyList<RelicLine> Contents { get; init; }
60 +
61 + public bool FromRelics => InRelics.Count > 0;
62 +
63 + public bool IsRelic => Contents.Count > 0;
64 +}
65 +
66 +/// <summary>A parsed copy of the drop tables, indexed for lookup by item name.</summary>
67 +public sealed class DropTables
68 +{
69 + public required string LastUpdate { get; init; }
70 +
71 + public required DateTimeOffset FetchedAt { get; init; }
72 +
73 + /// <summary>Every item, by name, case-insensitively.</summary>
74 + public required IReadOnlyDictionary<string, ItemDrops> Items { get; init; }
75 +
76 + /// <summary>Every item name, sorted.</summary>
77 + public required IReadOnlyList<string> Names { get; init; }
78 +
79 + public ItemDrops? Find(string? name) =>
80 + name is not null && Items.TryGetValue(name, out var item) ? item : null;
81 +
82 + /// <summary>Where an item drops. Empty for a vaulted relic.</summary>
83 + public IReadOnlyList<DropSource> SourcesFor(string itemName) => Find(itemName)?.Sources ?? [];
84 +
85 + /// <summary>
86 + /// Item names matching <paramref name="query"/>, most relevant first: exact name, then prefix,
87 + /// then substring, then names containing every word of it. The last rule matches
88 + /// "nikana blueprint" to "Nikana Prime Blueprint".
89 + /// </summary>
90 + public IReadOnlyList<string> Search(string query, int limit)
91 + {
92 + query = query.Trim();
93 + if (query.Length == 0) return [];
94 +
95 + var words = query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
96 +
97 + return Names
98 + .Select(name => (name, rank: Rank(name, query, words)))
99 + .Where(match => match.rank < int.MaxValue)
100 + .OrderBy(match => match.rank)
101 + .ThenBy(match => match.name.Length)
102 + .ThenBy(match => match.name, StringComparer.OrdinalIgnoreCase)
103 + .Take(limit)
104 + .Select(match => match.name)
105 + .ToArray();
106 + }
107 +
108 + private static int Rank(string name, string query, string[] words)
109 + {
110 + if (name.Equals(query, StringComparison.OrdinalIgnoreCase)) return 0;
111 + if (name.StartsWith(query, StringComparison.OrdinalIgnoreCase)) return 1;
112 + if (name.Contains(query, StringComparison.OrdinalIgnoreCase)) return 2;
113 +
114 + foreach (var word in words)
115 + {
116 + if (!name.Contains(word, StringComparison.OrdinalIgnoreCase)) return int.MaxValue;
117 + }
118 +
119 + return 3;
120 + }
121 +}
122 +
123 +/// <summary>The drop tables, or the reason there aren't any.</summary>
124 +public sealed record DropTableStatus(DropTables? Tables, string? Error)
125 +{
126 + public static DropTableStatus Ready(DropTables tables) => new(tables, null);
127 +
128 + public static DropTableStatus Failed(string error, DropTables? stale = null) => new(stale, error);
129 +}

Blog/Program.cs +19 -0

@@ -14,6 +14,18 @@ builder.Services.AddHttpClient<BrpService>(
14 14 client.BaseAddress = new Uri("https://brp.bes.is/");
15 15 });
16 16
17 +// /Warframe searches Digital Extremes' published drop tables: 4 MB of HTML, updated a few times a
18 +// month, parsed once and held in memory - see WarframeDropService.
19 +builder.Services.Configure<WarframeOptions>(builder.Configuration.GetSection(WarframeOptions.Section));
20 +builder.Services.AddHttpClient(WarframeDropService.ClientName, client =>
21 +{
22 + // The page is served off a CDN; identify the caller rather than asking for 4 MB anonymously.
23 + client.DefaultRequestHeaders.UserAgent.ParseAdd("bes.is-droptables/1.0 (+https://bes.is/Warframe)");
24 +});
25 +
26 +// Singleton: the parsed tables live in the service, so a transient one would re-parse per request.
27 +builder.Services.AddSingleton<WarframeDropService>();
28 +
17 29 // /rvrb reads the rvrb bot's stats straight off its BEAM, over Erlang distribution. The node this
18 30 // site dials with is started on the first request, not here - see RvrbService.
19 31 builder.Services.Configure<RvrbOptions>(builder.Configuration.GetSection(RvrbOptions.Section));
@@ -39,4 +51,11 @@ app.UseAntiforgery();
39 51 app.MapStaticAssets();
40 52 app.MapRazorComponents<App>();
41 53
54 +// Item names matching what has been typed, for the search box's suggestion list.
55 +app.MapGet("/api/warframe/names", async (string? q, WarframeDropService drops, CancellationToken cancellationToken) =>
56 +{
57 + var status = await drops.GetTablesAsync(cancellationToken).ConfigureAwait(false);
58 + return status.Tables?.Search(q ?? "", 10) ?? [];
59 +});
60 +
42 61 app.Run();
No newline at end of file

Blog/Services/DropTableParser.cs +360 -0

@@ -0,0 +1,360 @@
1 +using System.Globalization;
2 +using System.Text.RegularExpressions;
3 +using Blog.Models;
4 +
5 +namespace Blog.Services;
6 +
7 +/// <summary>
8 +/// Turns warframe.com/droptables into <see cref="DropTables"/>.
9 +/// </summary>
10 +/// <remarks>
11 +/// The page is one 4 MB machine-generated document: twenty <c>&lt;h3 id&gt;</c> sections, each a
12 +/// single flat table whose rows carry no classes or ids. Three row grammars cover all twenty:
13 +///
14 +/// <list type="bullet">
15 +/// <item>Two-column reward tables (missions, keys, dynamic locations, sorties, relics): a
16 +/// full-width header names the place, an optional <c>Rotation X</c> header splits it, and every
17 +/// other row is <c>item | rarity (chance)</c>.</item>
18 +/// <item>Three-column bounty tables: as above, plus an indented header per stage and a leading
19 +/// spacer cell on every row.</item>
20 +/// <item>Three-column "by source" tables (mods, blueprints, resources, relics by enemy): a header
21 +/// naming the enemy and its overall drop chance, then that table's contents.</item>
22 +/// </list>
23 +///
24 +/// Rows that match no grammar are skipped rather than thrown over, so a section DE add later costs
25 +/// coverage instead of the whole page.
26 +/// </remarks>
27 +public static partial class DropTableParser
28 +{
29 + [GeneratedRegex("""<h3 id="(?<id>[^"]+)"[^>]*>""")]
30 + private static partial Regex SectionHeading { get; }
31 +
32 + [GeneratedRegex("""<tr(?<attrs>[^>]*)>(?<body>.*?)</tr>""", RegexOptions.Singleline)]
33 + private static partial Regex RowPattern { get; }
34 +
35 + [GeneratedRegex("""<(?<tag>th|td)(?<attrs>[^>]*)>(?<text>.*?)</\k<tag>>""", RegexOptions.Singleline)]
36 + private static partial Regex CellPattern { get; }
37 +
38 + /// <summary>DE write every chance as <c>Rare (7.69%)</c>.</summary>
39 + [GeneratedRegex(@"^(?<rarity>[A-Za-z ]+?)\s*\((?<pct>[0-9.]+)%\)$")]
40 + private static partial Regex ChancePattern { get; }
41 +
42 + /// <summary>The "by source" headers all end in <c>… Drop Chance: 3.00%</c>.</summary>
43 + [GeneratedRegex(@"Drop Chance:\s*(?<pct>[0-9.]+)%")]
44 + private static partial Regex SourceChancePattern { get; }
45 +
46 + [GeneratedRegex(@"<b>Last Update:</b>\s*(?<date>[^<\r\n]+)")]
47 + private static partial Regex LastUpdatePattern { get; }
48 +
49 + /// <summary>A relic appears four times, once per refinement: <c>Axi A1 Relic (Intact)</c>.</summary>
50 + [GeneratedRegex(@"^(?<relic>.+? Relic) \((?<refinement>Intact|Exceptional|Flawless|Radiant)\)$")]
51 + private static partial Regex RelicHeaderPattern { get; }
52 +
53 + /// <summary>Which grammar each section is written in, and what its sources should be called.</summary>
54 + private static readonly Dictionary<string, DropCategory> RewardSections = new(StringComparer.Ordinal)
55 + {
56 + ["missionRewards"] = DropCategory.Mission,
57 + ["keyRewards"] = DropCategory.Key,
58 + ["transientRewards"] = DropCategory.Dynamic,
59 + ["sortieRewards"] = DropCategory.Sortie
60 + };
61 +
62 + private static readonly string[] BountySections =
63 + [
64 + "cetusRewards", "solarisRewards", "deimosRewards",
65 + "zarimanRewards", "entratiLabRewards", "hexRewards"
66 + ];
67 +
68 + private static readonly string[] SourceSections =
69 + [
70 + "modByAvatar", "blueprintByAvatar", "resourceByAvatar",
71 + "sigilByAvatar", "additionalItemByAvatar", "relicByAvatar"
72 + ];
73 +
74 + public static DropTables Parse(string html, DateTimeOffset fetchedAt)
75 + {
76 + var builder = new Builder();
77 +
78 + foreach (var (id, section) in Sections(html))
79 + {
80 + if (RewardSections.TryGetValue(id, out var category))
81 + {
82 + ParseRewards(section, category, builder);
83 + }
84 + else if (BountySections.Contains(id, StringComparer.Ordinal))
85 + {
86 + ParseBounties(section, builder);
87 + }
88 + else if (SourceSections.Contains(id, StringComparer.Ordinal))
89 + {
90 + ParseBySource(section, builder);
91 + }
92 + else if (id == "relicRewards")
93 + {
94 + ParseRelics(section, builder);
95 + }
96 +
97 + // modByDrop, blueprintByDrop and resourceByDrop hold the *ByAvatar data indexed the
98 + // other way round. Builder indexes both ways, so reading them would only duplicate.
99 + }
100 +
101 + return builder.Build(LastUpdate(html), fetchedAt);
102 + }
103 +
104 + private static string LastUpdate(string html)
105 + {
106 + var match = LastUpdatePattern.Match(html);
107 + return match.Success ? match.Groups["date"].Value.Trim() : "unknown";
108 + }
109 +
110 + /// <summary>Each <c>&lt;h3 id&gt;</c> and everything up to the next one.</summary>
111 + private static IEnumerable<(string Id, string Html)> Sections(string html)
112 + {
113 + var headings = SectionHeading.Matches(html);
114 +
115 + for (var i = 0; i < headings.Count; i++)
116 + {
117 + var start = headings[i].Index;
118 + var end = i + 1 < headings.Count ? headings[i + 1].Index : html.Length;
119 + yield return (headings[i].Groups["id"].Value, html[start..end]);
120 + }
121 + }
122 +
123 + /// <summary>Missions, keys, dynamic locations and sorties: a place, maybe a rotation, then drops.</summary>
124 + private static void ParseRewards(string section, DropCategory category, Builder builder)
125 + {
126 + string? location = null;
127 + string? rotation = null;
128 +
129 + foreach (var row in Rows(section))
130 + {
131 + switch (row)
132 + {
133 + // `Rotation A` splits the place above it; anything else full-width is a new place.
134 + case [{ Header: true, FullWidth: true, Text: var header }]:
135 + if (IsRotation(header)) rotation = header;
136 + else (location, rotation) = (header, null);
137 + break;
138 +
139 + // Dynamic locations write their rotations as a header and an empty cell instead.
140 + case [{ Header: true, Text: var header }, { Text: "" }]:
141 + rotation = IsRotation(header) ? header : rotation;
142 + break;
143 +
144 + case [{ Header: false, Text: var item }, { Header: false, Text: var chance }]
145 + when location is not null && Chance(chance) is (var rarity, var pct):
146 + builder.AddSource(item, new DropSource(category, location, rotation, rarity, pct));
147 + break;
148 + }
149 + }
150 + }
151 +
152 + /// <summary>Open-world bounties: a bounty, a rotation and a stage, then drops behind a spacer cell.</summary>
153 + private static void ParseBounties(string section, Builder builder)
154 + {
155 + string? bounty = null;
156 + string? rotation = null;
157 + string? stage = null;
158 +
159 + foreach (var row in Rows(section))
160 + {
161 + switch (row)
162 + {
163 + case [{ Header: true, FullWidth: true, Text: var header }]:
164 + if (IsRotation(header)) rotation = header;
165 + else (bounty, rotation, stage) = (header, null, null);
166 + break;
167 +
168 + case [{ Spacer: true }, { Header: true, Text: var header }]:
169 + stage = header;
170 + break;
171 +
172 + case [_, { Header: false, Text: var item }, { Header: false, Text: var chance }]
173 + when bounty is not null && Chance(chance) is (var rarity, var pct):
174 + builder.AddSource(item, new DropSource(DropCategory.Bounty, bounty, Join(rotation, stage), rarity, pct));
175 + break;
176 + }
177 + }
178 + }
179 +
180 + /// <summary>
181 + /// Drops by enemy. The header carries how often the enemy drops from its table at all, so a
182 + /// Very Common (100%) line off a 3% table is a 3% kill.
183 + /// </summary>
184 + private static void ParseBySource(string section, Builder builder)
185 + {
186 + string? source = null;
187 + var sourceChance = 100.0;
188 +
189 + foreach (var row in Rows(section))
190 + {
191 + switch (row)
192 + {
193 + case [{ Header: true, Text: var name }, { Header: true, Text: var chance }]:
194 + source = name;
195 + var match = SourceChancePattern.Match(chance);
196 + sourceChance = match.Success ? Percent(match.Groups["pct"].Value) : 100.0;
197 + break;
198 +
199 + case [_, { Header: false, Text: var item }, { Header: false, Text: var chance }]
200 + when source is not null && Chance(chance) is (var rarity, var pct):
201 + builder.AddSource(item,
202 + new DropSource(DropCategory.Enemy, source, null, rarity, pct * sourceChance / 100.0));
203 + break;
204 + }
205 + }
206 + }
207 +
208 + /// <summary>
209 + /// Relic contents. Every relic is listed once per refinement; only Intact and Radiant are kept.
210 + /// </summary>
211 + private static void ParseRelics(string section, Builder builder)
212 + {
213 + string? relic = null;
214 + string? refinement = null;
215 +
216 + foreach (var row in Rows(section))
217 + {
218 + switch (row)
219 + {
220 + case [{ Header: true, FullWidth: true, Text: var header }]:
221 + var match = RelicHeaderPattern.Match(header);
222 + (relic, refinement) = match.Success
223 + ? (match.Groups["relic"].Value, match.Groups["refinement"].Value)
224 + : (null, null);
225 + break;
226 +
227 + case [{ Header: false, Text: var item }, { Header: false, Text: var chance }]
228 + when relic is not null && Chance(chance) is (var rarity, var pct):
229 + builder.AddRelicLine(relic, item, rarity, pct, refinement == "Radiant");
230 + break;
231 + }
232 + }
233 + }
234 +
235 + private static bool IsRotation(string header) =>
236 + header.StartsWith("Rotation", StringComparison.OrdinalIgnoreCase);
237 +
238 + private static string? Join(string? rotation, string? stage) =>
239 + (rotation, stage) switch
240 + {
241 + (null, null) => null,
242 + (null, var s) => s,
243 + (var r, null) => r,
244 + var (r, s) => $"{r} · {s}"
245 + };
246 +
247 + private static (string Rarity, double Percent)? Chance(string text)
248 + {
249 + var match = ChancePattern.Match(text);
250 + return match.Success
251 + ? (match.Groups["rarity"].Value, Percent(match.Groups["pct"].Value))
252 + : null;
253 + }
254 +
255 + private static double Percent(string text) =>
256 + double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : 0;
257 +
258 + /// <summary>One cell: what it says, and the two attributes the grammars branch on.</summary>
259 + private readonly record struct Cell(string Text, bool Header, bool FullWidth, bool Spacer);
260 +
261 + private static IEnumerable<Cell[]> Rows(string section)
262 + {
263 + foreach (Match row in RowPattern.Matches(section))
264 + {
265 + var cells = CellPattern.Matches(row.Groups["body"].Value);
266 + if (cells.Count == 0) continue;
267 +
268 + var parsed = new Cell[cells.Count];
269 + for (var i = 0; i < cells.Count; i++)
270 + {
271 + var attrs = cells[i].Groups["attrs"].Value;
272 + parsed[i] = new Cell(
273 + Text: cells[i].Groups["text"].Value.Trim(),
274 + Header: cells[i].Groups["tag"].Value == "th",
275 + FullWidth: attrs.Contains("colspan", StringComparison.Ordinal),
276 + Spacer: attrs.Contains("pad-cell", StringComparison.Ordinal));
277 + }
278 +
279 + yield return parsed;
280 + }
281 + }
282 +
283 + /// <summary>Collects rows as they are read, then sorts and indexes them.</summary>
284 + private sealed class Builder
285 + {
286 + private readonly Dictionary<string, List<DropSource>> _sources = new(StringComparer.OrdinalIgnoreCase);
287 + private readonly Dictionary<(string Relic, string Item), RelicLine> _relicLines = new();
288 +
289 + public void AddSource(string item, DropSource source)
290 + {
291 + if (item.Length == 0) return;
292 +
293 + if (!_sources.TryGetValue(item, out var list))
294 + {
295 + _sources[item] = list = [];
296 + }
297 +
298 + list.Add(source);
299 + }
300 +
301 + public void AddRelicLine(string relic, string item, string rarity, double chance, bool radiant)
302 + {
303 + if (item.Length == 0) return;
304 +
305 + var existing = _relicLines.GetValueOrDefault((relic, item))
306 + ?? new RelicLine(relic, item, rarity, Intact: 0, Radiant: 0);
307 +
308 + _relicLines[(relic, item)] = radiant
309 + ? existing with { Radiant = chance }
310 + : existing with { Rarity = rarity, Intact = chance };
311 + }
312 +
313 + public DropTables Build(string lastUpdate, DateTimeOffset fetchedAt)
314 + {
315 + var byRelic = _relicLines.Values
316 + .GroupBy(line => line.Relic, StringComparer.OrdinalIgnoreCase)
317 + .ToDictionary(
318 + group => group.Key,
319 + group => (IReadOnlyList<RelicLine>)group.OrderByDescending(line => line.Intact).ToArray(),
320 + StringComparer.OrdinalIgnoreCase);
321 +
322 + var byItem = _relicLines.Values
323 + .GroupBy(line => line.Item, StringComparer.OrdinalIgnoreCase)
324 + .ToDictionary(
325 + group => group.Key,
326 + group => (IReadOnlyList<RelicLine>)group.OrderByDescending(line => line.Intact).ToArray(),
327 + StringComparer.OrdinalIgnoreCase);
328 +
329 + // A relic is both an item you farm and a container of items, so both indexes and the
330 + // drop sources meet under one name.
331 + var names = _sources.Keys
332 + .Concat(byRelic.Keys)
333 + .Concat(byItem.Keys)
334 + .Distinct(StringComparer.OrdinalIgnoreCase)
335 + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
336 + .ToArray();
337 +
338 + var items = names.ToDictionary(
339 + name => name,
340 + name => new ItemDrops
341 + {
342 + Name = name,
343 + Sources = _sources.TryGetValue(name, out var sources)
344 + ? sources.OrderByDescending(source => source.Chance).ToArray()
345 + : [],
346 + InRelics = byItem.GetValueOrDefault(name, []),
347 + Contents = byRelic.GetValueOrDefault(name, [])
348 + },
349 + StringComparer.OrdinalIgnoreCase);
350 +
351 + return new DropTables
352 + {
353 + LastUpdate = lastUpdate,
354 + FetchedAt = fetchedAt,
355 + Items = items,
356 + Names = names
357 + };
358 + }
359 + }
360 +}

Blog/Services/WarframeDropService.cs +115 -0

@@ -0,0 +1,115 @@
1 +using System.Diagnostics;
2 +using Blog.Models;
3 +using Microsoft.Extensions.Options;
4 +
5 +namespace Blog.Services;
6 +
7 +/// <summary>Where the drop tables come from, and how long a copy of them is good for.</summary>
8 +public sealed class WarframeOptions
9 +{
10 + public const string Section = "Warframe";
11 +
12 + /// <summary>
13 + /// DE's drop table page. It redirects to a CDN copy whose path is a content hash, so the
14 + /// redirect is followed rather than hardcoded.
15 + /// </summary>
16 + public string DropTablesUrl { get; set; } = "https://www.warframe.com/droptables";
17 +
18 + /// <summary>
19 + /// How long a parsed copy is served for. DE republish the page a few times a month at most.
20 + /// </summary>
21 + public TimeSpan CacheFor { get; set; } = TimeSpan.FromHours(12);
22 +
23 + /// <summary>How long to wait on the download before giving up.</summary>
24 + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
25 +}
26 +
27 +/// <summary>
28 +/// Keeps one parsed copy of the Warframe drop tables in memory and hands it to whoever asks.
29 +/// </summary>
30 +/// <remarks>
31 +/// Not <c>HybridCache</c>, which the rest of this site uses: the parsed tables are tens of
32 +/// thousands of records, and HybridCache serializes what it stores. One value on a timer, so a
33 +/// field behind a gate is the whole mechanism.
34 +///
35 +/// That only works because the service is a singleton. A typed
36 +/// <c>AddHttpClient&lt;WarframeDropService&gt;()</c> registration would make it transient, and
37 +/// every request would download and parse 4 MB into a cache nobody reads twice.
38 +///
39 +/// A failed refresh keeps the copy it already had, and reports failure only with nothing at all.
40 +/// </remarks>
41 +public sealed class WarframeDropService(
42 + IHttpClientFactory clients,
43 + IOptions<WarframeOptions> options,
44 + ILogger<WarframeDropService> logger)
45 +{
46 + /// <summary>The named client Program.cs configures for this service.</summary>
47 + public const string ClientName = "warframe";
48 +
49 + private readonly WarframeOptions _options = options.Value;
50 + private readonly SemaphoreSlim _gate = new(1, 1);
51 +
52 + private DropTables? _tables;
53 + private DateTimeOffset _expiresAt = DateTimeOffset.MinValue;
54 +
55 + public async Task<DropTableStatus> GetTablesAsync(CancellationToken cancellationToken = default)
56 + {
57 + if (_tables is { } fresh && DateTimeOffset.UtcNow < _expiresAt)
58 + {
59 + return DropTableStatus.Ready(fresh);
60 + }
61 +
62 + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
63 + try
64 + {
65 + // Someone else may have refreshed while this request queued for the gate.
66 + if (_tables is { } current && DateTimeOffset.UtcNow < _expiresAt)
67 + {
68 + return DropTableStatus.Ready(current);
69 + }
70 +
71 + var tables = await FetchAsync(cancellationToken).ConfigureAwait(false);
72 + _tables = tables;
73 + _expiresAt = DateTimeOffset.UtcNow + _options.CacheFor;
74 + return DropTableStatus.Ready(tables);
75 + }
76 + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or TimeoutException)
77 + {
78 + logger.LogWarning(ex, "Could not read the Warframe drop tables from {Url}", _options.DropTablesUrl);
79 +
80 + // Back off well short of the normal life: a site that is down should not be hit on
81 + // every page view, and a blip should not cost half a day.
82 + _expiresAt = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(5);
83 +
84 + return DropTableStatus.Failed(
85 + $"Could not reach {_options.DropTablesUrl} ({ex.Message})",
86 + _tables);
87 + }
88 + finally
89 + {
90 + _gate.Release();
91 + }
92 + }
93 +
94 + private async Task<DropTables> FetchAsync(CancellationToken cancellationToken)
95 + {
96 + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
97 + timeout.CancelAfter(_options.Timeout);
98 +
99 + // A client per fetch rather than one held for the life of the singleton. The factory pools
100 + // the handler underneath; a long-lived HttpClient would pin a stale CDN address.
101 + using var client = clients.CreateClient(ClientName);
102 +
103 + var stopwatch = Stopwatch.StartNew();
104 + var html = await client.GetStringAsync(_options.DropTablesUrl, timeout.Token).ConfigureAwait(false);
105 + var downloaded = stopwatch.ElapsedMilliseconds;
106 +
107 + var tables = DropTableParser.Parse(html, DateTimeOffset.UtcNow);
108 +
109 + logger.LogInformation(
110 + "Read the Warframe drop tables ({Update}): {Bytes} bytes in {Downloaded} ms, {Items} items parsed in {Parsed} ms",
111 + tables.LastUpdate, html.Length, downloaded, tables.Names.Count, stopwatch.ElapsedMilliseconds - downloaded);
112 +
113 + return tables;
114 + }
115 +}

Blog/wwwroot/app.css +65 -0

@@ -713,6 +713,71 @@ progress {
713 713 gap: var(--s-1) var(--s0);
714 714 }
715 715
716 +/* -- Warframe drops ------------------------------------------------------- */
717 +
718 +/* Chances are compared down the column, so they align right and shrink to fit. */
719 +.chance {
720 + text-align: end;
721 + font-variant-numeric: tabular-nums;
722 + inline-size: 1%;
723 + white-space: nowrap;
724 +}
725 +
726 +/* Up to forty names, so columns rather than one long list. Grid rather than
727 + `columns`, which fragments: a name too long for its column carried its last
728 + word over into the next one. A grid item cannot break across columns, so a
729 + long name wraps inside its own cell instead, and filling row by row puts the
730 + closest match first in reading order. 26ch fits nine names in ten on one
731 + line while still leaving room for a second column; the min() caps that floor
732 + at the container, which a bare minmax() would overflow on a phone. */
733 +.matches {
734 + list-style: none;
735 + margin: 0;
736 + padding: 0;
737 + display: grid;
738 + grid-template-columns: repeat(auto-fill, minmax(min(26ch, 100%), 1fr));
739 + /* The row gap is what keeps two names apart once they are narrow enough to
740 + wrap onto a second line each. */
741 + gap: var(--s-2) var(--s0);
742 +}
743 +
744 +.matches a.active {
745 + font-weight: 700;
746 +}
747 +
748 +.matches a.active::before {
749 + content: "> ";
750 +}
751 +
752 +/* Follows the search box or the match list, neither of which leaves room under
753 + itself. */
754 +.item-name {
755 + margin-block-start: var(--s1);
756 +}
757 +
758 +/* Labels the table under it. Outside .table-scroll on purpose: inside, it
759 + scrolled out of sight on a narrow screen. */
760 +.relic-lead {
761 + margin-block: var(--s-2) 0;
762 +}
763 +
764 +/* Each relic is a heading and its own small table. */
765 +.relic + .relic {
766 + margin-block-start: var(--s0);
767 + padding-block-start: var(--s0);
768 + border-block-start: var(--rule);
769 +}
770 +
771 +/* Name, rarity and both chances on one line, stacking when there is no room. */
772 +.relic > h3 {
773 + display: flex;
774 + flex-wrap: wrap;
775 + gap: var(--s-1);
776 + align-items: baseline;
777 + font-size: 1em;
778 +}
779 +
780 +
716 781 /* -- Scroll shortcuts ------------------------------------------------------ */
717 782
718 783 /* Docked to the viewport edge rather than the page flow, so it stays

CLAUDE.md +28 -1

@@ -96,7 +96,7 @@ in `appsettings.json`, and `Cookie` — the shared Erlang cookie — from user s
96 96 (`dotnet user-secrets set "Rvrb:Cookie" ...`) or `Rvrb__Cookie` in the environment. The bot's own
97 97 side of this (enabling distribution on its release) is documented in the rvrb repo's README.
98 98
99 -### BRP feature (the one page with a real backend)
99 +### BRP feature (an external API behind a page)
100 100
101 101 `BRP.razor`/`.razor.cs` and `BrpTestData.razor` are backed by `Services/BrpService.cs`,
102 102 which calls an external "Haal Centraal BRP" lookup API (base address configured in
@@ -105,6 +105,33 @@ which calls an external "Haal Centraal BRP" lookup API (base address configured
105 105 `Models/RaadpleegMetBurgerservicenummer.cs` model the request/response shapes. Test/mock
106 106 data lives in `Resources/test-data.json` and `wwwroot/brp.json`.
107 107
108 +### Warframe drops feature (`/Warframe`)
109 +
110 +`Warframe.razor`/`.razor.cs`/`.razor.js` searches Digital Extremes' published drop tables: given a
111 +part it shows where it drops sorted by chance, and for prime parts the relics holding it plus the
112 +best places to farm those relics.
113 +
114 +`Services/DropTableParser.cs` turns <https://www.warframe.com/droptables> into
115 +`Models/WarframeDrops.cs`. The source is 4 MB of machine-generated HTML: twenty `<h3 id>` sections
116 +of one flat table each, no classes or ids on the rows. It walks rows with regexes instead of an HTML
117 +parser, using three row grammars (two-column reward tables, three-column bounty tables, three-column
118 +"by source" tables) that cover all twenty sections. Rows matching no grammar are skipped rather than
119 +thrown over. Current output: 3,481 items, about 200 ms to parse.
120 +
121 +`Services/WarframeDropService.cs` holds one parsed copy in a field behind a `SemaphoreSlim`, not in
122 +`HybridCache` like the rest of the site, because HybridCache serializes what it stores. This depends
123 +on the service being registered as a **singleton**: a typed `AddHttpClient<WarframeDropService>()`
124 +registration makes it transient and re-parses 4 MB per request, so it takes `IHttpClientFactory` and
125 +a named client instead. A failed refresh keeps the copy it already had.
126 +
127 +Configuration lives under `Warframe` (`WarframeOptions`): `DropTablesUrl`, `CacheFor` (12 h),
128 +`Timeout`.
129 +
130 +The page is server-rendered and driven by the query string (`?q=` search, `?item=` selection), so
131 +results are linkable and work without JS. `Warframe.razor.js` only fills the search box's
132 +`<datalist>` from `/api/warframe/names` (mapped in `Program.cs`). It does *not* import
133 +`/common.module.js`: that module binds to a `#log` element this page doesn't have.
134 +
108 135 ### Styling
109 136
110 137 `wwwroot/app.css` is one hand-maintained stylesheet (no CSS framework, no build step),