final
This commit is contained in:
197
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraphBuilder.cs
Normal file
197
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraphBuilder.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user