198 lines
6.7 KiB
C#
198 lines
6.7 KiB
C#
using BlueLaminate.EFCore.Data;
|
|
using BlueLaminate.EFCore.Entities;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace BlueLaminate.Core.Tradeups;
|
|
|
|
/// <summary>
|
|
/// Derives the <see cref="TradeupGraph"/> from the synced catalogue
|
|
/// (<see cref="Skin"/> + <see cref="Collection"/> + <see cref="Weapon"/>) 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
|
|
/// <c>SkinSyncService</c> runs (monthly), callers can build this once and cache it for
|
|
/// the process lifetime.
|
|
/// </summary>
|
|
public sealed class TradeupGraphBuilder
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static readonly HashSet<string> SkipCollectionNames = new(StringComparer.Ordinal)
|
|
{
|
|
"Limited Edition Item",
|
|
};
|
|
|
|
/// <summary>
|
|
/// Weapon categories that carry weapon-tier rarities but are never weapon tradeup
|
|
/// outputs: knives are stored as <c>Covert</c> and gloves as <c>Extraordinary</c>.
|
|
/// Excluded defensively even though the rarity/float filters already drop most.
|
|
/// </summary>
|
|
private static readonly HashSet<string> ExcludedWeaponTypes = new(StringComparer.Ordinal)
|
|
{
|
|
"Knives",
|
|
"Gloves",
|
|
};
|
|
|
|
private const string CollectionType = "Collection";
|
|
|
|
private readonly SkinTrackerDbContext _db;
|
|
private readonly ILogger<TradeupGraphBuilder> _logger;
|
|
|
|
public TradeupGraphBuilder(SkinTrackerDbContext db, ILogger<TradeupGraphBuilder> logger)
|
|
{
|
|
_db = db;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<TradeupGraph> 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<int, CollectionBucket>();
|
|
|
|
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<TradeupInputGroup>();
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>The smallest present tier strictly greater than <paramref name="tier"/>, or null.</summary>
|
|
private static WeaponRarity? NextPresentTier(List<WeaponRarity> 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<WeaponRarity, List<Skin>> SkinsByRarity { get; } = new();
|
|
|
|
public void Add(WeaponRarity rarity, Skin skin)
|
|
{
|
|
if (!SkinsByRarity.TryGetValue(rarity, out var list))
|
|
{
|
|
list = new List<Skin>();
|
|
SkinsByRarity[rarity] = list;
|
|
}
|
|
|
|
list.Add(skin);
|
|
}
|
|
}
|
|
}
|