Blog/Models/WarframeDrops.cs 4.9 K · 129 lines · raw · history

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 }