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);
}