using BlueLaminate.EFCore.Data; using BlueLaminate.EFCore.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace BlueLaminate.Core.Tradeups; /// /// Derives the from the synced catalogue /// ( + + ) with no /// pricing, no listings, and no new tables. A single query loads the catalogue; the /// graph is assembled in memory. Because the catalogue changes only when /// SkinSyncService runs (monthly), callers can build this once and cache it for /// the process lifetime. /// public sealed class TradeupGraphBuilder { /// /// Pseudo-collections that are not real tradeup collections. "Limited Edition Item" /// holds armory/non-tradeable skins (e.g. AK-47 Aphrodite) that must never be treated /// as tradeup inputs or outputs. /// private static readonly HashSet SkipCollectionNames = new(StringComparer.Ordinal) { "Limited Edition Item", }; /// /// Weapon categories that carry weapon-tier rarities but are never weapon tradeup /// outputs: knives are stored as Covert and gloves as Extraordinary. /// Excluded defensively even though the rarity/float filters already drop most. /// private static readonly HashSet ExcludedWeaponTypes = new(StringComparer.Ordinal) { "Knives", "Gloves", }; private const string CollectionType = "Collection"; private readonly SkinTrackerDbContext _db; private readonly ILogger _logger; public TradeupGraphBuilder(SkinTrackerDbContext db, ILogger logger) { _db = db; _logger = logger; } public async Task BuildAsync(CancellationToken ct = default) { var skins = await _db.Skins .Include(s => s.Collections) .Include(s => s.Weapon) .AsNoTracking() .ToListAsync(ct); // collectionId -> (collection, rarity -> eligible skins). Only Type='Collection' // sources outside the skip-list participate; one skin can be filed under several // collections, so it is added to each. var byCollection = new Dictionary(); foreach (var skin in skins) { if (!IsEligibleSkin(skin, out var rarity)) { continue; } foreach (var collection in skin.Collections) { if (collection.Type != CollectionType || SkipCollectionNames.Contains(collection.Name)) { continue; } if (!byCollection.TryGetValue(collection.Id, out var bucket)) { bucket = new CollectionBucket(collection); byCollection[collection.Id] = bucket; } bucket.Add(rarity, skin); } } var groups = new List(); foreach (var bucket in byCollection.Values) { // Tiers that actually have eligible skins in this collection, ascending. var presentTiers = bucket.SkinsByRarity.Keys.OrderBy(r => (int)r).ToList(); foreach (var inputRarity in presentTiers) { // Covert is the v1 ceiling: it can be an output but never a 10-input source. if (inputRarity >= WeaponRarity.Covert) { continue; } var outputRarity = NextPresentTier(presentTiers, inputRarity); if (outputRarity is null) { // Collection tops out at this tier (or caps below Covert) — no tradeup. continue; } var inputSkinIds = bucket.SkinsByRarity[inputRarity] .Select(s => s.Id) .ToList(); var outputSkins = bucket.SkinsByRarity[outputRarity.Value] .Select(s => new TradeupOutputSkin( s.Id, s.Name, s.FloatMin!.Value, s.FloatMax!.Value, s.StatTrakAvailable)) .ToList(); groups.Add(new TradeupInputGroup( bucket.Collection.Id, bucket.Collection.Name, inputRarity, outputRarity.Value, inputSkinIds, outputSkins)); } } _logger.LogInformation( "Built tradeup graph: {Groups} input groups across {Collections} collections " + "from {Skins} catalogue skins.", groups.Count, byCollection.Count, skins.Count); return new TradeupGraph(groups); } /// /// A skin is eligible to appear in the graph (as input or output) iff it parses to a /// weapon tier, is not a knife/glove, and has both float bounds. The skip-list and /// Type='Collection' filter are applied per-collection by the caller. /// private static bool IsEligibleSkin(Skin skin, out WeaponRarity rarity) { rarity = default; if (skin.FloatMin is null || skin.FloatMax is null) { return false; } if (ExcludedWeaponTypes.Contains(skin.Weapon.Type)) { return false; } // Throws on an unknown literal (catalogue rename); returns false for the // non-weapon rarities (Contraband/Extraordinary). return WeaponRarityExtensions.TryParse(skin.Rarity, out rarity); } /// The smallest present tier strictly greater than , or null. private static WeaponRarity? NextPresentTier(List presentTiers, WeaponRarity tier) { foreach (var candidate in presentTiers) { if (candidate > tier) { return candidate; } } return null; } private sealed class CollectionBucket { public CollectionBucket(Collection collection) => Collection = collection; public Collection Collection { get; } public Dictionary> SkinsByRarity { get; } = new(); public void Add(WeaponRarity rarity, Skin skin) { if (!SkinsByRarity.TryGetValue(rarity, out var list)) { list = new List(); SkinsByRarity[rarity] = list; } list.Add(skin); } } }