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