Add a Warframe drop table search at /Warframe
- 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 | |
| Blog/Components/Pages/Warframe.razor | added | +197 -0 |
| Blog/Components/Pages/Warframe.razor.cs | added | +119 -0 |
| Blog/Components/Pages/Warframe.razor.js | added | +54 -0 |
| Blog/Models/WarframeDrops.cs | added | +129 -0 |
| Blog/Program.cs | +19 -0 | |
| Blog/Services/DropTableParser.cs | added | +360 -0 |
| Blog/Services/WarframeDropService.cs | added | +115 -0 |
| Blog/wwwroot/app.css | +65 -0 | |
| CLAUDE.md | +28 -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 | + "/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 | + (Status.Error is { } error) | |
| 18 | + { | |
| 19 | + <p class="error"></p> | |
| 20 | + } | |
| 21 | + | |
| 22 | + (Tables is not { } tables) | |
| 23 | + { | |
| 24 | + (Status.Error is null) | |
| 25 | + { | |
| 26 | + <p>Reading the drop tables…</p> | |
| 27 | + } | |
| 28 | + } | |
| 29 | + else | |
| 30 | + { | |
| 31 | + (Matches.Count > 1) | |
| 32 | + { | |
| 33 | + <Panel Legend="@($"{Matches.Count} matches")"> | |
| 34 | + <ul class="matches"> | |
| 35 | + (var name in Matches) | |
| 36 | + { | |
| 37 | + <li> | |
| 38 | + <a href="@SearchLink(name)" class="@(name == Item?.Name ? "active" : null)"></a> | |
| 39 | + </li> | |
| 40 | + } | |
| 41 | + </ul> | |
| 42 | + </Panel> | |
| 43 | + } | |
| 44 | + | |
| 45 | + (Item is { } item) | |
| 46 | + { | |
| 47 | + <h2 class="item-name"></h2> | |
| 48 | + | |
| 49 | + (item.Sources.Count == 0 && !item.FromRelics) | |
| 50 | + { | |
| 51 | + <p>Nothing in the drop tables gives this out.</p> | |
| 52 | + } | |
| 53 | + | |
| 54 | + (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 | + (var (line, relicSources) in RelicRoutes(item)) | |
| 63 | + { | |
| 64 | + <div class="relic"> | |
| 65 | + <h3> | |
| 66 | + <a href="@ItemLink(line.Relic)"></a> | |
| 67 | + <span class="muted"></span> | |
| 68 | + <span>(line.Intact) intact → (line.Radiant) radiant</span> | |
| 69 | + </h3> | |
| 70 | + | |
| 71 | + (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 | + (var source in relicSources) | |
| 82 | + { | |
| 83 | + <tr> | |
| 84 | + <td></td> | |
| 85 | + <td class="muted"></td> | |
| 86 | + <td class="muted">(source.Category)</td> | |
| 87 | + <td class="chance">(source.Chance)</td> | |
| 88 | + </tr> | |
| 89 | + } | |
| 90 | + </tbody> | |
| 91 | + </table> | |
| 92 | + </div> | |
| 93 | + } | |
| 94 | + </div> | |
| 95 | + } | |
| 96 | + </Panel> | |
| 97 | + } | |
| 98 | + | |
| 99 | + (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 | + (var source in item.Sources.Take(MaxSources)) | |
| 114 | + { | |
| 115 | + <tr> | |
| 116 | + <td></td> | |
| 117 | + <td class="muted"></td> | |
| 118 | + <td class="muted">(source.Category)</td> | |
| 119 | + <td class="chance" title="@source.Rarity">(source.Chance)</td> | |
| 120 | + </tr> | |
| 121 | + } | |
| 122 | + </tbody> | |
| 123 | + </table> | |
| 124 | + </div> | |
| 125 | + | |
| 126 | + (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 | + (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 | + (var line in item.Contents) | |
| 150 | + { | |
| 151 | + <tr> | |
| 152 | + <td><a href="@ItemLink(line.Item)"></a></td> | |
| 153 | + <td class="muted"></td> | |
| 154 | + <td class="chance">(line.Intact)</td> | |
| 155 | + <td class="chance">(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 “” 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 | + (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 ; read (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 | + | |
| 21 | + public required WarframeDropService Service { get; set; } | |
| 22 | + | |
| 23 | + | |
| 24 | + public string? Query { get; set; } | |
| 25 | + | |
| 26 | + | |
| 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><h3 id></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 | + | |
| 30 | + private static partial Regex SectionHeading { get; } | |
| 31 | + | |
| 32 | + | |
| 33 | + private static partial Regex RowPattern { get; } | |
| 34 | + | |
| 35 | + | |
| 36 | + private static partial Regex CellPattern { get; } | |
| 37 | + | |
| 38 | + /// <summary>DE write every chance as <c>Rare (7.69%)</c>.</summary> | |
| 39 | + | |
| 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 | + | |
| 44 | + private static partial Regex SourceChancePattern { get; } | |
| 45 | + | |
| 46 | + | |
| 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 | + | |
| 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><h3 id></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<WarframeDropService>()</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() var(); |
| 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() var(); | |
| 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(); | |
| 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() 0; | |
| 762 | +} | |
| 763 | + | |
| 764 | +/* Each relic is a heading and its own small table. */ | |
| 765 | +.relic + .relic { | |
| 766 | + margin-block-start: var(); | |
| 767 | + padding-block-start: var(); | |
| 768 | + border-block-start: var(); | |
| 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(); | |
| 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), |