namespace Blog.Models; /// Which of Digital Extremes' tables a source came out of. public enum DropCategory { /// A node on the star chart: Mercury/Apollodorus (Survival). Mission, /// A quest or key mission, run from the codex rather than the star chart. Key, /// Arbitrations, Kuva Siphons, Void Storms: activities without a fixed node. Dynamic, /// The daily sortie. Sortie, /// An open-world bounty stage. Bounty, /// Something you kill, or open: an enemy, a container, a crewship. Enemy } /// One place an item drops, and how likely it is there. /// The kind of activity this is. /// The node, bounty, activity or enemy. /// Which rotation or stage of it, when the location has more than one. /// DE's own word for the chance: Rare, Uncommon, and so on. /// /// Percent per roll. For enemies, the source's drop chance multiplied by the item's share of that /// drop: a 100% share of a 3% table is a 3% kill. /// public sealed record DropSource( DropCategory Category, string Location, string? Context, string Rarity, double Chance); /// /// One line of a relic's reward table. Only Intact and Radiant are kept; Exceptional and Flawless /// sit between them. /// public sealed record RelicLine(string Relic, string Item, string Rarity, double Intact, double Radiant); /// Everything the drop tables say about one item. public sealed class ItemDrops { public required string Name { get; init; } /// Where it drops directly, best chance first. public required IReadOnlyList Sources { get; init; } /// The relics that can contain it, best intact chance first. Empty for most items. public required IReadOnlyList InRelics { get; init; } /// What this item contains, when it is itself a relic. Empty otherwise. public required IReadOnlyList Contents { get; init; } public bool FromRelics => InRelics.Count > 0; public bool IsRelic => Contents.Count > 0; } /// A parsed copy of the drop tables, indexed for lookup by item name. public sealed class DropTables { public required string LastUpdate { get; init; } public required DateTimeOffset FetchedAt { get; init; } /// Every item, by name, case-insensitively. public required IReadOnlyDictionary Items { get; init; } /// Every item name, sorted. public required IReadOnlyList Names { get; init; } public ItemDrops? Find(string? name) => name is not null && Items.TryGetValue(name, out var item) ? item : null; /// Where an item drops. Empty for a vaulted relic. public IReadOnlyList SourcesFor(string itemName) => Find(itemName)?.Sources ?? []; /// /// Item names matching , most relevant first: exact name, then prefix, /// then substring, then names containing every word of it. The last rule matches /// "nikana blueprint" to "Nikana Prime Blueprint". /// public IReadOnlyList Search(string query, int limit) { query = query.Trim(); if (query.Length == 0) return []; var words = query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); return Names .Select(name => (name, rank: Rank(name, query, words))) .Where(match => match.rank < int.MaxValue) .OrderBy(match => match.rank) .ThenBy(match => match.name.Length) .ThenBy(match => match.name, StringComparer.OrdinalIgnoreCase) .Take(limit) .Select(match => match.name) .ToArray(); } private static int Rank(string name, string query, string[] words) { if (name.Equals(query, StringComparison.OrdinalIgnoreCase)) return 0; if (name.StartsWith(query, StringComparison.OrdinalIgnoreCase)) return 1; if (name.Contains(query, StringComparison.OrdinalIgnoreCase)) return 2; foreach (var word in words) { if (!name.Contains(word, StringComparison.OrdinalIgnoreCase)) return int.MaxValue; } return 3; } } /// The drop tables, or the reason there aren't any. public sealed record DropTableStatus(DropTables? Tables, string? Error) { public static DropTableStatus Ready(DropTables tables) => new(tables, null); public static DropTableStatus Failed(string error, DropTables? stale = null) => new(stale, error); }