final
This commit is contained in:
409
BlueLaminate/BlueLaminate.Core/Tradeups/MultiCollectionSearch.cs
Normal file
409
BlueLaminate/BlueLaminate.Core/Tradeups/MultiCollectionSearch.cs
Normal file
@@ -0,0 +1,409 @@
|
||||
using System.Collections.Concurrent;
|
||||
using BlueLaminate.Core.Options;
|
||||
|
||||
namespace BlueLaminate.Core.Tradeups;
|
||||
|
||||
/// <summary>
|
||||
/// The multi-collection tradeup search. Where the single-collection pass keeps all ten
|
||||
/// inputs in one collection, this mixes inputs from any collections sharing a rarity tier —
|
||||
/// the pattern behind most genuinely profitable contracts (cheap inputs from one collection,
|
||||
/// a valuable output rolled from another).
|
||||
/// <para>
|
||||
/// It exploits two facts: an output's probability is linear in how many inputs came from its
|
||||
/// collection (<c>n_C / size·k_C</c>), and the produced float depends only on the single
|
||||
/// global average input fraction. So for a FIXED output-float target F, each input copy has an
|
||||
/// independent reward — its collection's average output value share minus its price — and one
|
||||
/// max-reward knapsack over the whole tier's pool finds the optimal mix without enumerating
|
||||
/// collection subsets. The search sweeps F on a grid; each grid point is an independent chunk
|
||||
/// run in parallel. Because the reward is a linear (expected-value) function, this optimises
|
||||
/// EXPECTED profit; each winning selection is then evaluated exactly.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MultiCollectionSearch
|
||||
{
|
||||
private sealed record CollectionInfo(
|
||||
int CollectionId,
|
||||
string CollectionName,
|
||||
WeaponRarity OutputRarity,
|
||||
IReadOnlyList<TradeupOutputSkin> Outputs);
|
||||
|
||||
// An input copy already reduced to its collection, float bucket and price.
|
||||
private readonly record struct PoolItem(int CollectionId, int Bucket, decimal Fraction, InputListing Listing);
|
||||
|
||||
public static List<TradeupCandidate> Evaluate(
|
||||
TradeupGraph graph,
|
||||
TradeupListingData listingData,
|
||||
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
|
||||
TradeupOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var step = options.MultiCollectionFloatGrid;
|
||||
var size = options.ContractSize;
|
||||
var maxBucketPerItem = (int)Math.Ceiling(1m / step);
|
||||
|
||||
var results = new List<TradeupCandidate>();
|
||||
|
||||
// All recipes sharing an input rarity can be mixed (each collection still rolls into
|
||||
// its own next tier). Group by (input rarity, ST universe).
|
||||
var byTier = graph.Groups.GroupBy(g => g.InputRarity);
|
||||
|
||||
foreach (var tier in byTier)
|
||||
{
|
||||
var tierGroups = tier.ToList();
|
||||
foreach (var statTrak in StatTrakUniverses(options.StatTrak))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
EvaluateTier(tier.Key, tierGroups, statTrak, listingData, floatBounds, options, step, size,
|
||||
maxBucketPerItem, results, ct);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static void EvaluateTier(
|
||||
WeaponRarity inputRarity,
|
||||
List<TradeupInputGroup> tierGroups,
|
||||
bool statTrak,
|
||||
TradeupListingData listingData,
|
||||
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
|
||||
TradeupOptions options,
|
||||
decimal step,
|
||||
int size,
|
||||
int maxBucketPerItem,
|
||||
List<TradeupCandidate> results,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var collections = tierGroups.ToDictionary(
|
||||
g => g.CollectionId,
|
||||
g => new CollectionInfo(g.CollectionId, g.CollectionName, g.OutputRarity, g.OutputSkins));
|
||||
var skinCollection = new Dictionary<int, int>();
|
||||
foreach (var g in tierGroups)
|
||||
{
|
||||
foreach (var skinId in g.InputSkinIds)
|
||||
{
|
||||
skinCollection[skinId] = g.CollectionId;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the tier's input pool once, then trim to the cheapest `size` copies per
|
||||
// (collection, float bucket) — within a cell every copy has the same float and value,
|
||||
// so only the cheapest can ever be optimal. Bucketing/trim don't depend on the target,
|
||||
// so this is done once and reused across all grid chunks.
|
||||
var trimmed = BuildTrimmedPool(tierGroups, statTrak, listingData, floatBounds, step, maxBucketPerItem, size);
|
||||
if (trimmed.Count < size)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var priceBook = listingData.OutputPrices;
|
||||
|
||||
// One chunk per float-target grid point, run in parallel.
|
||||
var grid = new List<decimal>();
|
||||
for (var f = step; f <= 1m + 1e-9m; f += step)
|
||||
{
|
||||
grid.Add(Math.Min(f, 1m));
|
||||
}
|
||||
|
||||
var chunkResults = new ConcurrentBag<TradeupCandidate>();
|
||||
|
||||
Parallel.ForEach(
|
||||
grid,
|
||||
new ParallelOptions { CancellationToken = ct },
|
||||
target =>
|
||||
{
|
||||
var candidate = EvaluateChunk(
|
||||
inputRarity, target, trimmed, collections, skinCollection, floatBounds, priceBook,
|
||||
options, step, size, statTrak);
|
||||
if (candidate is not null)
|
||||
{
|
||||
chunkResults.Add(candidate);
|
||||
}
|
||||
});
|
||||
|
||||
// Only genuine mixes belong here — single-collection selections are the dedicated
|
||||
// pass's job, and emitting them too just duplicates rows. De-duplicate by collection
|
||||
// mix (different float targets often converge on the same set), keep the best expected
|
||||
// profit, then take the top few.
|
||||
var deduped = chunkResults
|
||||
.Where(c => c.CollectionCount >= 2)
|
||||
.GroupBy(MixSignature)
|
||||
.Select(grp => grp.MaxBy(c => c.ExpectedProfit)!)
|
||||
.OrderByDescending(c => c.ExpectedProfit)
|
||||
.Take(options.MultiCollectionPerTier);
|
||||
|
||||
lock (results)
|
||||
{
|
||||
results.AddRange(deduped);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<PoolItem> BuildTrimmedPool(
|
||||
List<TradeupInputGroup> tierGroups,
|
||||
bool statTrak,
|
||||
TradeupListingData listingData,
|
||||
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
|
||||
decimal step,
|
||||
int maxBucketPerItem,
|
||||
int size)
|
||||
{
|
||||
// (collection, bucket) -> cheapest copies.
|
||||
var cells = new Dictionary<(int Collection, int Bucket), List<PoolItem>>();
|
||||
|
||||
foreach (var group in tierGroups)
|
||||
{
|
||||
foreach (var skinId in group.InputSkinIds)
|
||||
{
|
||||
if (!floatBounds.TryGetValue(skinId, out var bounds))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var listing in listingData.InputsFor(skinId, statTrak))
|
||||
{
|
||||
var fraction = TradeupMath.NormalizedFraction(listing.FloatValue, bounds.Min, bounds.Max);
|
||||
var bucket = Math.Clamp((int)Math.Ceiling(fraction / step), 0, maxBucketPerItem);
|
||||
var key = (group.CollectionId, bucket);
|
||||
if (!cells.TryGetValue(key, out var cell))
|
||||
{
|
||||
cell = new List<PoolItem>();
|
||||
cells[key] = cell;
|
||||
}
|
||||
|
||||
cell.Add(new PoolItem(group.CollectionId, bucket, fraction, listing));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var trimmed = new List<PoolItem>();
|
||||
foreach (var cell in cells.Values)
|
||||
{
|
||||
cell.Sort(static (a, b) => a.Listing.Price.CompareTo(b.Listing.Price));
|
||||
for (var i = 0; i < Math.Min(size, cell.Count); i++)
|
||||
{
|
||||
trimmed.Add(cell[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static TradeupCandidate? EvaluateChunk(
|
||||
WeaponRarity inputRarity,
|
||||
decimal target,
|
||||
List<PoolItem> trimmed,
|
||||
IReadOnlyDictionary<int, CollectionInfo> collections,
|
||||
IReadOnlyDictionary<int, int> skinCollection,
|
||||
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
|
||||
OutputPriceBook priceBook,
|
||||
TradeupOptions options,
|
||||
decimal step,
|
||||
int size,
|
||||
bool statTrak)
|
||||
{
|
||||
// Each collection's average output value if the produced float averages `target`.
|
||||
var valueByCollection = new Dictionary<int, decimal>(collections.Count);
|
||||
foreach (var (id, info) in collections)
|
||||
{
|
||||
valueByCollection[id] = AverageOutputValue(info, target, priceBook, options, statTrak);
|
||||
}
|
||||
|
||||
var capBucket = (int)Math.Floor(target * size / step);
|
||||
|
||||
var items = new List<TradeupSelector.RewardItem>(trimmed.Count);
|
||||
foreach (var item in trimmed)
|
||||
{
|
||||
if (item.Bucket > capBucket)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reward = this copy's share of expected output value, minus what it costs.
|
||||
var reward = (double)(valueByCollection[item.CollectionId] / size - item.Listing.Price);
|
||||
items.Add(new TradeupSelector.RewardItem(item.Bucket, reward, item.Listing));
|
||||
}
|
||||
|
||||
var picks = TradeupSelector.SolveMaxReward(items, size, capBucket);
|
||||
return picks is null
|
||||
? null
|
||||
: BuildCandidate(inputRarity, picks, collections, skinCollection, floatBounds, priceBook, options, size, statTrak);
|
||||
}
|
||||
|
||||
// The conservative average output value of a collection at a given input-float average:
|
||||
// each next-tier skin is equally likely; an output with no comparable listing contributes 0.
|
||||
private static decimal AverageOutputValue(
|
||||
CollectionInfo info, decimal averageFraction, OutputPriceBook priceBook,
|
||||
TradeupOptions options, bool statTrak)
|
||||
{
|
||||
if (info.Outputs.Count == 0)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
decimal total = 0m;
|
||||
foreach (var output in info.Outputs)
|
||||
{
|
||||
var outputFloat = TradeupMath.OutputFloat(averageFraction, output.FloatMin, output.FloatMax);
|
||||
var resolved = priceBook.Resolve(output.SkinId, statTrak, outputFloat, options.CsFloatThinOutputThreshold);
|
||||
if (resolved.LowestAsk is { } ask)
|
||||
{
|
||||
total += NetSell(ask, options);
|
||||
}
|
||||
}
|
||||
|
||||
return total / info.Outputs.Count;
|
||||
}
|
||||
|
||||
private static TradeupCandidate BuildCandidate(
|
||||
WeaponRarity inputRarity,
|
||||
PickNode picks,
|
||||
IReadOnlyDictionary<int, CollectionInfo> collections,
|
||||
IReadOnlyDictionary<int, int> skinCollection,
|
||||
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
|
||||
OutputPriceBook priceBook,
|
||||
TradeupOptions options,
|
||||
int size,
|
||||
bool statTrak)
|
||||
{
|
||||
var inputs = picks.ToList();
|
||||
|
||||
// Realised average fraction from the actual copies (exact, not bucketed).
|
||||
decimal fractionSum = 0m;
|
||||
var counts = new Dictionary<int, int>();
|
||||
decimal cost = 0m;
|
||||
foreach (var input in inputs)
|
||||
{
|
||||
cost += input.Price;
|
||||
if (floatBounds.TryGetValue(input.SkinId, out var bounds))
|
||||
{
|
||||
fractionSum += TradeupMath.NormalizedFraction(input.FloatValue, bounds.Min, bounds.Max);
|
||||
}
|
||||
|
||||
var collectionId = skinCollection[input.SkinId];
|
||||
counts[collectionId] = counts.GetValueOrDefault(collectionId) + 1;
|
||||
}
|
||||
|
||||
var averageFraction = fractionSum / size;
|
||||
|
||||
var outcomes = new List<TradeupOutcome>();
|
||||
var composition = new List<TradeupContribution>();
|
||||
|
||||
foreach (var (collectionId, n) in counts.OrderByDescending(kv => kv.Value))
|
||||
{
|
||||
var info = collections[collectionId];
|
||||
composition.Add(new TradeupContribution(collectionId, info.CollectionName, info.OutputRarity, n));
|
||||
|
||||
var k = info.Outputs.Count;
|
||||
if (k == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var probability = (decimal)n / (size * k);
|
||||
foreach (var output in info.Outputs)
|
||||
{
|
||||
var outputFloat = TradeupMath.OutputFloat(averageFraction, output.FloatMin, output.FloatMax);
|
||||
var band = WearBands.FromFloat(outputFloat);
|
||||
var resolved = priceBook.Resolve(output.SkinId, statTrak, outputFloat, options.CsFloatThinOutputThreshold);
|
||||
outcomes.Add(new TradeupOutcome(
|
||||
output.SkinId,
|
||||
output.Name,
|
||||
outputFloat,
|
||||
band,
|
||||
probability,
|
||||
resolved.LowestAsk is { } ask ? NetSell(ask, options) : null,
|
||||
resolved.BandLiquidity,
|
||||
resolved.Basis == OutputPriceBasis.Floor ? "market-floor" : "market"));
|
||||
}
|
||||
}
|
||||
|
||||
var (expectedNet, worstCaseNet, guaranteed) = Economics(outcomes, cost);
|
||||
|
||||
var primary = composition[0];
|
||||
return new TradeupCandidate(
|
||||
primary.CollectionId,
|
||||
SummariseMix(composition),
|
||||
inputRarity,
|
||||
primary.OutputRarity,
|
||||
statTrak,
|
||||
averageFraction,
|
||||
cost,
|
||||
expectedNet,
|
||||
worstCaseNet,
|
||||
guaranteed,
|
||||
inputs,
|
||||
outcomes,
|
||||
composition);
|
||||
}
|
||||
|
||||
private static string SummariseMix(IReadOnlyList<TradeupContribution> composition)
|
||||
{
|
||||
if (composition.Count == 1)
|
||||
{
|
||||
return composition[0].CollectionName;
|
||||
}
|
||||
|
||||
var parts = composition.Take(3).Select(c => $"{Shorten(c.CollectionName)} ×{c.InputCount}");
|
||||
var summary = string.Join(" + ", parts);
|
||||
return composition.Count > 3 ? $"{summary} +{composition.Count - 3}" : summary;
|
||||
}
|
||||
|
||||
private static string Shorten(string name)
|
||||
{
|
||||
// "The 2021 Mirage Collection" -> "Mirage" style trimming for compact summaries.
|
||||
var trimmed = name;
|
||||
if (trimmed.StartsWith("The ", StringComparison.Ordinal))
|
||||
{
|
||||
trimmed = trimmed[4..];
|
||||
}
|
||||
|
||||
const string suffix = " Collection";
|
||||
if (trimmed.EndsWith(suffix, StringComparison.Ordinal))
|
||||
{
|
||||
trimmed = trimmed[..^suffix.Length];
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static string MixSignature(TradeupCandidate candidate)
|
||||
=> string.Join(',', candidate.Composition
|
||||
.OrderBy(c => c.CollectionId)
|
||||
.Select(c => $"{c.CollectionId}:{c.InputCount}"));
|
||||
|
||||
private static decimal NetSell(decimal lowestAsk, TradeupOptions options)
|
||||
=> lowestAsk * (1m - options.UndercutRate) * (1m - options.SellFeeRate);
|
||||
|
||||
private static (decimal Expected, decimal Worst, bool Guaranteed) Economics(
|
||||
IReadOnlyList<TradeupOutcome> outcomes, decimal cost)
|
||||
{
|
||||
decimal expected = 0m;
|
||||
decimal worst = decimal.MaxValue;
|
||||
var allPriced = true;
|
||||
|
||||
foreach (var outcome in outcomes)
|
||||
{
|
||||
var realised = outcome.NetSellPrice ?? 0m;
|
||||
expected += outcome.Probability * realised;
|
||||
worst = Math.Min(worst, realised);
|
||||
if (outcome.NetSellPrice is null)
|
||||
{
|
||||
allPriced = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (outcomes.Count == 0)
|
||||
{
|
||||
worst = 0m;
|
||||
}
|
||||
|
||||
return (expected, worst, allPriced && worst > cost);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<bool> StatTrakUniverses(StatTrakMode mode) => mode switch
|
||||
{
|
||||
StatTrakMode.NonStatTrakOnly => new[] { false },
|
||||
StatTrakMode.StatTrakOnly => new[] { true },
|
||||
_ => new[] { false, true },
|
||||
};
|
||||
}
|
||||
67
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupCandidate.cs
Normal file
67
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupCandidate.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
namespace BlueLaminate.Core.Tradeups;
|
||||
|
||||
/// <summary>One possible result of a contract and what it would net if it lands.</summary>
|
||||
/// <param name="Probability">Chance this specific output is produced (single-collection: 1/k).</param>
|
||||
/// <param name="NetSellPrice">
|
||||
/// Realisable sale value after undercut + sell fee, or null when nothing comparable is
|
||||
/// listed (treated as unsellable for the worst-case test).
|
||||
/// </param>
|
||||
/// <param name="Liquidity">Active listings backing the price, in the same wear band.</param>
|
||||
/// <param name="PriceSource">Where the price came from: "market" (our stored listings) or
|
||||
/// "csfloat-live" (re-priced from the CSFloat API because the stored liquidity was thin).</param>
|
||||
public sealed record TradeupOutcome(
|
||||
int SkinId,
|
||||
string Name,
|
||||
decimal OutputFloat,
|
||||
WearBand Band,
|
||||
decimal Probability,
|
||||
decimal? NetSellPrice,
|
||||
int Liquidity,
|
||||
string PriceSource = "market");
|
||||
|
||||
/// <summary>
|
||||
/// One collection's share of a (possibly multi-collection) contract: how many of the ten
|
||||
/// inputs came from it, and which output tier those inputs roll into. Single-collection
|
||||
/// contracts have exactly one of these.
|
||||
/// </summary>
|
||||
public sealed record TradeupContribution(
|
||||
int CollectionId,
|
||||
string CollectionName,
|
||||
WeaponRarity OutputRarity,
|
||||
int InputCount);
|
||||
|
||||
/// <summary>
|
||||
/// A concrete, actionable tradeup: which ten copies to buy, what they cost, the output
|
||||
/// distribution, and the resulting economics. The finder returns these ranked; a frontend
|
||||
/// only formats them.
|
||||
/// <para>
|
||||
/// A contract may mix several collections (all inputs share the input rarity, but each
|
||||
/// collection rolls into its own next tier). <see cref="Composition"/> records the per-
|
||||
/// collection split; <see cref="CollectionCount"/> is its length. <see cref="OutputRarity"/>
|
||||
/// is the tier of the largest contributor (a display convenience for the common case).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record TradeupCandidate(
|
||||
int CollectionId,
|
||||
string CollectionName,
|
||||
WeaponRarity InputRarity,
|
||||
WeaponRarity OutputRarity,
|
||||
bool StatTrak,
|
||||
decimal AverageFraction,
|
||||
decimal InputCost,
|
||||
decimal ExpectedNet,
|
||||
decimal WorstCaseNet,
|
||||
bool Guaranteed,
|
||||
IReadOnlyList<InputListing> Inputs,
|
||||
IReadOnlyList<TradeupOutcome> Outcomes,
|
||||
IReadOnlyList<TradeupContribution> Composition)
|
||||
{
|
||||
/// <summary>Number of distinct collections the inputs are drawn from (1 = single-collection).</summary>
|
||||
public int CollectionCount => Composition.Count;
|
||||
|
||||
/// <summary>Expected profit across the output distribution, net of cost.</summary>
|
||||
public decimal ExpectedProfit => ExpectedNet - InputCost;
|
||||
|
||||
/// <summary>Profit if the worst (lowest-value) output lands — negative unless guaranteed.</summary>
|
||||
public decimal WorstCaseProfit => WorstCaseNet - InputCost;
|
||||
}
|
||||
476
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupFinder.cs
Normal file
476
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupFinder.cs
Normal file
@@ -0,0 +1,476 @@
|
||||
using BlueLaminate.Core.Options;
|
||||
using BlueLaminate.EFCore.Data;
|
||||
using BlueLaminate.EFCore.Entities;
|
||||
using BlueLaminate.Scraper.CsFloat;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace BlueLaminate.Core.Tradeups;
|
||||
|
||||
/// <summary>
|
||||
/// Finds profitable 10-input CS2 tradeup contracts over the live listings. It joins three
|
||||
/// things: the catalogue-derived <see cref="TradeupGraph"/> (which collections produce
|
||||
/// what), the active <see cref="MarketListing"/>s (what inputs cost and what outputs sell
|
||||
/// for), and the exact <see cref="TradeupMath"/>. For each (collection-recipe, StatTrak)
|
||||
/// universe it runs the cardinality-constrained selection DP and values every resulting
|
||||
/// output distribution, keeping the best contract per recipe and ranking them.
|
||||
/// <para>
|
||||
/// When a proposed contract's output is thinly listed in our data, its stored lowest-ask is
|
||||
/// fragile, so a follow-up pass re-prices that output from the live CSFloat API and
|
||||
/// recomputes the economics (see <see cref="EnrichThinOutputsAsync"/>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All economics live here, never in a frontend: the CLI and the future web UI both call
|
||||
/// <see cref="FindAsync"/> and only format the returned candidates.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class TradeupFinder
|
||||
{
|
||||
private readonly SkinTrackerDbContext _db;
|
||||
private readonly TradeupGraphBuilder _graphBuilder;
|
||||
private readonly TradeupOptions _options;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<TradeupFinder> _logger;
|
||||
|
||||
public TradeupFinder(
|
||||
SkinTrackerDbContext db,
|
||||
TradeupGraphBuilder graphBuilder,
|
||||
IOptions<TradeupOptions> options,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<TradeupFinder> logger)
|
||||
{
|
||||
_db = db;
|
||||
_graphBuilder = graphBuilder;
|
||||
_options = options.Value;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the search and returns candidates ranked best-first. <paramref name="maxResults"/>
|
||||
/// caps the returned list; pass 0 or negative for "all".
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<TradeupCandidate>> FindAsync(
|
||||
int maxResults = 50,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var graph = await _graphBuilder.BuildAsync(ct);
|
||||
|
||||
// Float bounds for input skins, needed to normalise each input copy's float to its
|
||||
// own range before averaging. (Output bounds already live on the graph.)
|
||||
var floatBounds = await _db.Skins
|
||||
.Where(s => s.FloatMin != null && s.FloatMax != null)
|
||||
.Select(s => new { s.Id, Min = s.FloatMin!.Value, Max = s.FloatMax!.Value })
|
||||
.AsNoTracking()
|
||||
.ToDictionaryAsync(s => s.Id, s => (s.Min, s.Max), ct);
|
||||
|
||||
// def_index/paint_index for output skins, so a thin output can be looked up live on
|
||||
// CSFloat (which identifies items by these two indexes).
|
||||
var indexes = await _db.Skins
|
||||
.Where(s => s.DefIndex != null && s.PaintIndex != null)
|
||||
.Select(s => new { s.Id, Def = s.DefIndex!.Value, Paint = s.PaintIndex!.Value })
|
||||
.AsNoTracking()
|
||||
.ToDictionaryAsync(s => s.Id, s => (s.Def, s.Paint), ct);
|
||||
|
||||
var listingData = await LoadListingsAsync(ct);
|
||||
|
||||
var universes = StatTrakUniverses(_options.StatTrak);
|
||||
var candidates = new List<TradeupCandidate>();
|
||||
|
||||
foreach (var group in graph.Groups)
|
||||
{
|
||||
foreach (var statTrak in universes)
|
||||
{
|
||||
var candidate = EvaluateRecipe(group, statTrak, listingData, floatBounds);
|
||||
if (candidate is not null)
|
||||
{
|
||||
candidates.Add(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-collection contracts (expected-profit optimised) on top of the single-collection
|
||||
// pass. Skipped under StatTrak filters? No — it respects the same universes internally.
|
||||
if (_options.MultiCollection)
|
||||
{
|
||||
var multi = MultiCollectionSearch.Evaluate(graph, listingData, floatBounds, _options, ct);
|
||||
_logger.LogInformation("Multi-collection search produced {Count} candidate contracts.", multi.Count);
|
||||
candidates.AddRange(multi);
|
||||
}
|
||||
|
||||
var ranked = candidates.OrderByDescending(RankingMetric).ToList();
|
||||
|
||||
// Re-price thin outputs from the live CSFloat API. Only the top window is enriched
|
||||
// (it's where results are shown, and it bounds the live lookups); the rest keep their
|
||||
// stored pricing. Then re-filter and re-rank with the refreshed economics.
|
||||
var window = maxResults > 0 ? Math.Max(maxResults * 3, 60) : ranked.Count;
|
||||
var head = await EnrichThinOutputsAsync(ranked.Take(window).ToList(), indexes, ct);
|
||||
|
||||
ranked = head.Concat(ranked.Skip(window))
|
||||
.Where(c => !_options.GuaranteedOnly || c.Guaranteed)
|
||||
.Where(c => RankingMetric(c) >= _options.MinProfit)
|
||||
.OrderByDescending(RankingMetric)
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Tradeup search complete: {Surviving} qualifying contracts (guaranteedOnly={Guaranteed}, "
|
||||
+ "minProfit={MinProfit}, statTrak={StatTrak}).",
|
||||
ranked.Count, _options.GuaranteedOnly, _options.MinProfit, _options.StatTrak);
|
||||
|
||||
return maxResults > 0 ? ranked.Take(maxResults).ToList() : ranked;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates one (recipe, StatTrak) universe: builds the input pool, solves the
|
||||
/// selection DP, and returns the best qualifying contract — or null if the recipe can't
|
||||
/// be filled or nothing clears the filters.
|
||||
/// </summary>
|
||||
private TradeupCandidate? EvaluateRecipe(
|
||||
TradeupInputGroup group,
|
||||
bool statTrak,
|
||||
TradeupListingData listingData,
|
||||
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds)
|
||||
{
|
||||
var pool = BuildPool(group, statTrak, listingData, floatBounds);
|
||||
if (pool.Count < _options.ContractSize)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var selection = TradeupSelector.Solve(pool, _options.ContractSize, _options.FractionBucket);
|
||||
|
||||
TradeupCandidate? best = null;
|
||||
decimal bestMetric = decimal.MinValue;
|
||||
|
||||
foreach (var (averageFraction, cost, picks) in selection.Selections())
|
||||
{
|
||||
var candidate = BuildCandidate(
|
||||
group, statTrak, averageFraction, cost, picks, listingData.OutputPrices);
|
||||
|
||||
if (_options.GuaranteedOnly && !candidate.Guaranteed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var metric = RankingMetric(candidate);
|
||||
if (metric < _options.MinProfit)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (metric > bestMetric)
|
||||
{
|
||||
bestMetric = metric;
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private List<SelectableInput> BuildPool(
|
||||
TradeupInputGroup group,
|
||||
bool statTrak,
|
||||
TradeupListingData listingData,
|
||||
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds)
|
||||
{
|
||||
var pool = new List<SelectableInput>();
|
||||
|
||||
foreach (var skinId in group.InputSkinIds)
|
||||
{
|
||||
if (!floatBounds.TryGetValue(skinId, out var bounds))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var listing in listingData.InputsFor(skinId, statTrak))
|
||||
{
|
||||
var fraction = TradeupMath.NormalizedFraction(listing.FloatValue, bounds.Min, bounds.Max);
|
||||
pool.Add(new SelectableInput(fraction, listing));
|
||||
}
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
private TradeupCandidate BuildCandidate(
|
||||
TradeupInputGroup group,
|
||||
bool statTrak,
|
||||
decimal averageFraction,
|
||||
decimal cost,
|
||||
PickNode picks,
|
||||
OutputPriceBook priceBook)
|
||||
{
|
||||
var probability = 1m / group.OutputSkins.Count; // single-collection v1: equally likely.
|
||||
|
||||
var outcomes = new List<TradeupOutcome>(group.OutputSkins.Count);
|
||||
foreach (var output in group.OutputSkins)
|
||||
{
|
||||
var outputFloat = TradeupMath.OutputFloat(averageFraction, output.FloatMin, output.FloatMax);
|
||||
var band = WearBands.FromFloat(outputFloat);
|
||||
var resolved = priceBook.Resolve(
|
||||
output.SkinId, statTrak, outputFloat, _options.CsFloatThinOutputThreshold);
|
||||
|
||||
outcomes.Add(new TradeupOutcome(
|
||||
output.SkinId,
|
||||
output.Name,
|
||||
outputFloat,
|
||||
band,
|
||||
probability,
|
||||
resolved.LowestAsk is { } ask ? NetSell(ask) : null,
|
||||
resolved.BandLiquidity,
|
||||
resolved.Basis == OutputPriceBasis.Floor ? "market-floor" : "market"));
|
||||
}
|
||||
|
||||
var (expectedNet, worstCaseNet, guaranteed) = Economics(outcomes, cost);
|
||||
|
||||
return new TradeupCandidate(
|
||||
group.CollectionId,
|
||||
group.CollectionName,
|
||||
group.InputRarity,
|
||||
group.OutputRarity,
|
||||
statTrak,
|
||||
averageFraction,
|
||||
cost,
|
||||
expectedNet,
|
||||
worstCaseNet,
|
||||
guaranteed,
|
||||
picks.ToList(),
|
||||
outcomes,
|
||||
new[] { new TradeupContribution(group.CollectionId, group.CollectionName, group.OutputRarity, _options.ContractSize) });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For each candidate with a thinly-listed output (liquidity below the configured
|
||||
/// threshold), fetches that output's current lowest ask from the live CSFloat API and
|
||||
/// recomputes the contract's economics. Distinct (skin, ST, band) lookups are cached and
|
||||
/// the total is capped, so this stays within the API's rate-limit budget. Inert (returns
|
||||
/// the input unchanged) when the feature is off or no CSFloat key is configured.
|
||||
/// </summary>
|
||||
private async Task<List<TradeupCandidate>> EnrichThinOutputsAsync(
|
||||
List<TradeupCandidate> candidates,
|
||||
IReadOnlyDictionary<int, (int Def, int Paint)> indexes,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!_options.UseCsFloatForThinOutputs || candidates.Count == 0)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var client = TryResolveCsFloatClient();
|
||||
if (client is null)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var cache = new Dictionary<(int Def, int Paint, bool StatTrak, WearBand Band), BandPrice?>();
|
||||
var lookups = 0;
|
||||
var enriched = 0;
|
||||
var stop = false;
|
||||
|
||||
var result = new List<TradeupCandidate>(candidates.Count);
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var thin = candidate.Outcomes.Any(o =>
|
||||
o.Liquidity < _options.CsFloatThinOutputThreshold && indexes.ContainsKey(o.SkinId));
|
||||
if (!thin)
|
||||
{
|
||||
result.Add(candidate);
|
||||
continue;
|
||||
}
|
||||
|
||||
var newOutcomes = new List<TradeupOutcome>(candidate.Outcomes.Count);
|
||||
var changed = false;
|
||||
|
||||
foreach (var outcome in candidate.Outcomes)
|
||||
{
|
||||
if (outcome.Liquidity >= _options.CsFloatThinOutputThreshold
|
||||
|| !indexes.TryGetValue(outcome.SkinId, out var idx))
|
||||
{
|
||||
newOutcomes.Add(outcome);
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = (idx.Def, idx.Paint, candidate.StatTrak, outcome.Band);
|
||||
if (!cache.TryGetValue(key, out var live))
|
||||
{
|
||||
if (stop || lookups >= _options.CsFloatMaxLookups)
|
||||
{
|
||||
newOutcomes.Add(outcome);
|
||||
continue;
|
||||
}
|
||||
|
||||
lookups++;
|
||||
try
|
||||
{
|
||||
live = await FetchCsFloatBandPriceAsync(
|
||||
client, idx.Def, idx.Paint, candidate.StatTrak, outcome.Band, ct);
|
||||
cache[key] = live;
|
||||
}
|
||||
catch (CsFloatApiException ex)
|
||||
{
|
||||
// Rate-limited or rejected — stop hitting the API and keep stored prices.
|
||||
_logger.LogWarning("CSFloat re-pricing halted after {Lookups} lookups: {Message}",
|
||||
lookups, ex.Message);
|
||||
stop = true;
|
||||
newOutcomes.Add(outcome);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (live is { } bp)
|
||||
{
|
||||
newOutcomes.Add(outcome with
|
||||
{
|
||||
NetSellPrice = NetSell(bp.LowestAsk),
|
||||
Liquidity = bp.Liquidity,
|
||||
PriceSource = "csfloat-live",
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
newOutcomes.Add(outcome);
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed)
|
||||
{
|
||||
result.Add(candidate);
|
||||
continue;
|
||||
}
|
||||
|
||||
var (expectedNet, worstCaseNet, guaranteed) = Economics(newOutcomes, candidate.InputCost);
|
||||
result.Add(candidate with
|
||||
{
|
||||
Outcomes = newOutcomes,
|
||||
ExpectedNet = expectedNet,
|
||||
WorstCaseNet = worstCaseNet,
|
||||
Guaranteed = guaranteed,
|
||||
});
|
||||
enriched++;
|
||||
}
|
||||
|
||||
if (enriched > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Re-priced {Enriched} contracts with thin outputs via CSFloat ({Lookups} live lookups).",
|
||||
enriched, lookups);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task<BandPrice?> FetchCsFloatBandPriceAsync(
|
||||
CsFloatListingsClient client, int defIndex, int paintIndex, bool statTrak, WearBand band, CancellationToken ct)
|
||||
{
|
||||
var (min, max) = band.Bounds();
|
||||
|
||||
// Sorted lowest_price ascending, scoped to the band — so the first listing matching the
|
||||
// ST flag (and not a souvenir) is the lowest comparable ask.
|
||||
var page = await client.FetchPageAsync(
|
||||
defIndex, paintIndex, sortBy: "lowest_price", limit: 50, cursor: null,
|
||||
type: "buy_now", minFloat: min, maxFloat: max, ct: ct);
|
||||
|
||||
decimal? lowest = null;
|
||||
var count = 0;
|
||||
foreach (var listing in page.Listings)
|
||||
{
|
||||
if (listing.IsSouvenir || listing.IsStatTrak != statTrak)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
count++;
|
||||
lowest ??= listing.Price;
|
||||
}
|
||||
|
||||
return lowest is { } price ? new BandPrice(price, count) : null;
|
||||
}
|
||||
|
||||
private CsFloatListingsClient? TryResolveCsFloatClient()
|
||||
{
|
||||
try
|
||||
{
|
||||
return _serviceProvider.GetRequiredService<CsFloatListingsClient>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// No API key configured (the client's ctor throws): the feature is simply inert.
|
||||
_logger.LogWarning("CSFloat re-pricing unavailable, using stored prices only: {Message}", ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Realisable sale value from a lowest ask: undercut to sell, then pay the marketplace fee.
|
||||
private decimal NetSell(decimal lowestAsk)
|
||||
=> lowestAsk * (1m - _options.UndercutRate) * (1m - _options.SellFeeRate);
|
||||
|
||||
private static (decimal Expected, decimal Worst, bool Guaranteed) Economics(
|
||||
IReadOnlyList<TradeupOutcome> outcomes, decimal cost)
|
||||
{
|
||||
decimal expected = 0m;
|
||||
decimal worst = decimal.MaxValue;
|
||||
var allPriced = true;
|
||||
|
||||
foreach (var outcome in outcomes)
|
||||
{
|
||||
var realised = outcome.NetSellPrice ?? 0m;
|
||||
expected += outcome.Probability * realised;
|
||||
worst = Math.Min(worst, realised);
|
||||
if (outcome.NetSellPrice is null)
|
||||
{
|
||||
allPriced = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (outcomes.Count == 0)
|
||||
{
|
||||
worst = 0m;
|
||||
}
|
||||
|
||||
// Guaranteed = every output is priced AND even the cheapest one clears input cost.
|
||||
return (expected, worst, allPriced && worst > cost);
|
||||
}
|
||||
|
||||
private async Task<TradeupListingData> LoadListingsAsync(CancellationToken ct)
|
||||
{
|
||||
var rows = await _db.MarketListings
|
||||
.Where(l => l.Status == "Active"
|
||||
&& l.Currency == _options.Currency
|
||||
&& l.SkinId != null
|
||||
&& l.Price > 0m)
|
||||
.Select(l => new TradeupListingRow(
|
||||
l.SkinId!.Value,
|
||||
l.MarketHashName,
|
||||
l.Marketplace,
|
||||
l.InspectLink,
|
||||
l.ExternalId,
|
||||
l.IsStatTrak,
|
||||
l.IsSouvenir,
|
||||
l.FloatValue,
|
||||
l.Price))
|
||||
.ToListAsync(ct);
|
||||
|
||||
_logger.LogInformation("Loaded {Count} active {Currency} listings for tradeup search.",
|
||||
rows.Count, _options.Currency);
|
||||
|
||||
return TradeupListingData.Build(rows);
|
||||
}
|
||||
|
||||
private decimal RankingMetric(TradeupCandidate candidate) => _options.Ranking switch
|
||||
{
|
||||
TradeupRanking.ExpectedProfit => candidate.ExpectedProfit,
|
||||
_ => candidate.WorstCaseProfit,
|
||||
};
|
||||
|
||||
private static IReadOnlyList<bool> StatTrakUniverses(StatTrakMode mode) => mode switch
|
||||
{
|
||||
StatTrakMode.NonStatTrakOnly => new[] { false },
|
||||
StatTrakMode.StatTrakOnly => new[] { true },
|
||||
_ => new[] { false, true },
|
||||
};
|
||||
}
|
||||
37
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraph.cs
Normal file
37
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraph.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace BlueLaminate.Core.Tradeups;
|
||||
|
||||
/// <summary>
|
||||
/// A skin that can come OUT of a tradeup, carrying the float bounds needed to map an
|
||||
/// input average float onto this skin's own wear range. <see cref="StatTrakAvailable"/>
|
||||
/// is recorded so the listing-side query (Phase C) can filter ST vs non-ST outputs;
|
||||
/// the graph itself is ST-agnostic.
|
||||
/// </summary>
|
||||
public sealed record TradeupOutputSkin(
|
||||
int SkinId,
|
||||
string Name,
|
||||
decimal FloatMin,
|
||||
decimal FloatMax,
|
||||
bool StatTrakAvailable);
|
||||
|
||||
/// <summary>
|
||||
/// One tradeup "recipe slot": all eligible input skins of a single rarity within one
|
||||
/// collection, and the set of output skins they produce (the next rarity tier present
|
||||
/// in that collection). For a single-collection contract, ten inputs are drawn from
|
||||
/// <see cref="InputSkinIds"/> and each of <see cref="OutputSkins"/> is an equally likely
|
||||
/// outcome, so k_C = <c>OutputSkins.Count</c>.
|
||||
/// </summary>
|
||||
public sealed record TradeupInputGroup(
|
||||
int CollectionId,
|
||||
string CollectionName,
|
||||
WeaponRarity InputRarity,
|
||||
WeaponRarity OutputRarity,
|
||||
IReadOnlyList<int> InputSkinIds,
|
||||
IReadOnlyList<TradeupOutputSkin> OutputSkins);
|
||||
|
||||
/// <summary>
|
||||
/// The full tradeup reference graph derived from the static catalogue: every
|
||||
/// (collection, input rarity) → (output rarity, output skins) edge that yields a
|
||||
/// 10-input weapon tradeup. Built once per process from the monthly-synced catalogue
|
||||
/// (see <see cref="TradeupGraphBuilder"/>); contains no pricing or listing data.
|
||||
/// </summary>
|
||||
public sealed record TradeupGraph(IReadOnlyList<TradeupInputGroup> Groups);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
203
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupListingData.cs
Normal file
203
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupListingData.cs
Normal file
@@ -0,0 +1,203 @@
|
||||
namespace BlueLaminate.Core.Tradeups;
|
||||
|
||||
/// <summary>One active listing reduced to the fields the finder needs.</summary>
|
||||
public readonly record struct TradeupListingRow(
|
||||
int SkinId,
|
||||
string MarketHashName,
|
||||
string Marketplace,
|
||||
string? InspectLink,
|
||||
string ExternalId,
|
||||
bool IsStatTrak,
|
||||
bool IsSouvenir,
|
||||
decimal? FloatValue,
|
||||
decimal Price);
|
||||
|
||||
/// <summary>
|
||||
/// A purchasable input copy: a real listing the engine can pick into a contract. Carries
|
||||
/// the market-hash name, marketplace, and the source listing's inspect link + external id
|
||||
/// so the output is an actionable buy list that points at the exact listing.
|
||||
/// </summary>
|
||||
public readonly record struct InputListing(
|
||||
int SkinId,
|
||||
string MarketHashName,
|
||||
string Marketplace,
|
||||
string? InspectLink,
|
||||
string ExternalId,
|
||||
decimal FloatValue,
|
||||
decimal Price);
|
||||
|
||||
/// <summary>Lowest active ask and listing count for one (skin, ST, wear band).</summary>
|
||||
public readonly record struct BandPrice(decimal LowestAsk, int Liquidity);
|
||||
|
||||
/// <summary>Where a resolved output price came from.</summary>
|
||||
public enum OutputPriceBasis
|
||||
{
|
||||
/// <summary>Nothing comparable is listed anywhere — unpriceable.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>The wear band the output lands in, which is liquid enough to trust.</summary>
|
||||
Band,
|
||||
|
||||
/// <summary>
|
||||
/// The band was too thin to trust, so this is the skin's cheapest comparable listing
|
||||
/// across any wear — a conservative proxy. A live CSFloat lookup should refine it.
|
||||
/// </summary>
|
||||
Floor,
|
||||
}
|
||||
|
||||
/// <summary>An output price plus how thin its own band was and where the number came from.</summary>
|
||||
public readonly record struct ResolvedOutputPrice(decimal? LowestAsk, int BandLiquidity, OutputPriceBasis Basis);
|
||||
|
||||
/// <summary>
|
||||
/// The listing-side inputs to the finder (Phase B/C data), built once from a single scan
|
||||
/// of active listings:
|
||||
/// <list type="bullet">
|
||||
/// <item>input pools — every floated input copy, split into the disjoint non-ST and ST
|
||||
/// universes (non-ST = normal ∪ souvenir);</item>
|
||||
/// <item>an <see cref="OutputPriceBook"/> — the lowest non-souvenir ask per (skin, ST,
|
||||
/// wear band), used to value a produced output at its computed float.</item>
|
||||
/// </list>
|
||||
/// Floatless listings are dropped: an input copy with no float can't be normalised, and
|
||||
/// an output listing with no float can't be placed in a wear band.
|
||||
/// </summary>
|
||||
public sealed class TradeupListingData
|
||||
{
|
||||
private readonly IReadOnlyDictionary<int, List<InputListing>> _nonStatTrakInputs;
|
||||
private readonly IReadOnlyDictionary<int, List<InputListing>> _statTrakInputs;
|
||||
private readonly OutputPriceBook _outputPrices;
|
||||
|
||||
private TradeupListingData(
|
||||
IReadOnlyDictionary<int, List<InputListing>> nonStatTrakInputs,
|
||||
IReadOnlyDictionary<int, List<InputListing>> statTrakInputs,
|
||||
OutputPriceBook outputPrices)
|
||||
{
|
||||
_nonStatTrakInputs = nonStatTrakInputs;
|
||||
_statTrakInputs = statTrakInputs;
|
||||
_outputPrices = outputPrices;
|
||||
}
|
||||
|
||||
public OutputPriceBook OutputPrices => _outputPrices;
|
||||
|
||||
/// <summary>All purchasable input copies of <paramref name="skinId"/> in the given universe.</summary>
|
||||
public IReadOnlyList<InputListing> InputsFor(int skinId, bool statTrak)
|
||||
{
|
||||
var pool = statTrak ? _statTrakInputs : _nonStatTrakInputs;
|
||||
return pool.TryGetValue(skinId, out var listings) ? listings : Array.Empty<InputListing>();
|
||||
}
|
||||
|
||||
public static TradeupListingData Build(IEnumerable<TradeupListingRow> rows)
|
||||
{
|
||||
var nonStInputs = new Dictionary<int, List<InputListing>>();
|
||||
var stInputs = new Dictionary<int, List<InputListing>>();
|
||||
var book = new OutputPriceBook();
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (row.FloatValue is not { } floatValue || row.Price <= 0m)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Input side: a copy can be used as input regardless of souvenir flag; only the
|
||||
// ST flag splits the two disjoint universes.
|
||||
var inputs = row.IsStatTrak ? stInputs : nonStInputs;
|
||||
if (!inputs.TryGetValue(row.SkinId, out var list))
|
||||
{
|
||||
list = new List<InputListing>();
|
||||
inputs[row.SkinId] = list;
|
||||
}
|
||||
|
||||
list.Add(new InputListing(
|
||||
row.SkinId, row.MarketHashName, row.Marketplace,
|
||||
row.InspectLink, row.ExternalId, floatValue, row.Price));
|
||||
|
||||
// Output side: a tradeup never produces a souvenir, so souvenir listings don't
|
||||
// price an output.
|
||||
if (!row.IsSouvenir)
|
||||
{
|
||||
book.Add(row.SkinId, row.IsStatTrak, WearBands.FromFloat(floatValue), row.Price);
|
||||
}
|
||||
}
|
||||
|
||||
return new TradeupListingData(nonStInputs, stInputs, book);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase B artifact: the lowest active ask (and liquidity count) for each
|
||||
/// (skin, StatTrak, wear band). A produced output is valued by looking up the band its
|
||||
/// computed float falls into — a conservative, listing-grounded estimate that never
|
||||
/// invents a premium for a float no one is currently selling near.
|
||||
/// </summary>
|
||||
public sealed class OutputPriceBook
|
||||
{
|
||||
private readonly Dictionary<(int SkinId, bool StatTrak), Dictionary<WearBand, MutableBand>> _bands = new();
|
||||
// Skin's cheapest comparable listing across ALL wears — the conservative floor used when a
|
||||
// single band is too thin to trust.
|
||||
private readonly Dictionary<(int SkinId, bool StatTrak), MutableBand> _floor = new();
|
||||
|
||||
internal void Add(int skinId, bool statTrak, WearBand band, decimal price)
|
||||
{
|
||||
var key = (skinId, statTrak);
|
||||
if (!_bands.TryGetValue(key, out var byBand))
|
||||
{
|
||||
byBand = new Dictionary<WearBand, MutableBand>();
|
||||
_bands[key] = byBand;
|
||||
}
|
||||
|
||||
byBand[band] = byBand.TryGetValue(band, out var entry)
|
||||
? new MutableBand(Math.Min(entry.LowestAsk, price), entry.Liquidity + 1)
|
||||
: new MutableBand(price, 1);
|
||||
|
||||
_floor[key] = _floor.TryGetValue(key, out var f)
|
||||
? new MutableBand(Math.Min(f.LowestAsk, price), f.Liquidity + 1)
|
||||
: new MutableBand(price, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The lowest ask for the given skin/ST in the wear band that <paramref name="outputFloat"/>
|
||||
/// lands in, or null when nothing comparable is listed.
|
||||
/// </summary>
|
||||
public BandPrice? PriceAt(int skinId, bool statTrak, decimal outputFloat)
|
||||
{
|
||||
if (_bands.TryGetValue((skinId, statTrak), out var byBand)
|
||||
&& byBand.TryGetValue(WearBands.FromFloat(outputFloat), out var entry))
|
||||
{
|
||||
return new BandPrice(entry.LowestAsk, entry.Liquidity);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an output's value: the band price when the band is liquid enough
|
||||
/// (≥ <paramref name="thinThreshold"/> listings); otherwise the skin's overall floor, since
|
||||
/// a one- or two-listing band is dominated by outliers (e.g. a lone over-priced FN sitting
|
||||
/// just past a wear boundary). The reported <see cref="ResolvedOutputPrice.BandLiquidity"/>
|
||||
/// is always the band's own count, so a thin result still triggers live CSFloat re-pricing.
|
||||
/// </summary>
|
||||
public ResolvedOutputPrice Resolve(int skinId, bool statTrak, decimal outputFloat, int thinThreshold)
|
||||
{
|
||||
var key = (skinId, statTrak);
|
||||
var bandLiquidity = 0;
|
||||
if (_bands.TryGetValue(key, out var byBand)
|
||||
&& byBand.TryGetValue(WearBands.FromFloat(outputFloat), out var entry))
|
||||
{
|
||||
bandLiquidity = entry.Liquidity;
|
||||
if (entry.Liquidity >= thinThreshold)
|
||||
{
|
||||
return new ResolvedOutputPrice(entry.LowestAsk, bandLiquidity, OutputPriceBasis.Band);
|
||||
}
|
||||
}
|
||||
|
||||
// Thin (or empty) band — fall back to the skin's cheapest comparable listing.
|
||||
if (_floor.TryGetValue(key, out var floor))
|
||||
{
|
||||
return new ResolvedOutputPrice(floor.LowestAsk, bandLiquidity, OutputPriceBasis.Floor);
|
||||
}
|
||||
|
||||
return new ResolvedOutputPrice(null, bandLiquidity, OutputPriceBasis.None);
|
||||
}
|
||||
|
||||
private readonly record struct MutableBand(decimal LowestAsk, int Liquidity);
|
||||
}
|
||||
38
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupMath.cs
Normal file
38
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupMath.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace BlueLaminate.Core.Tradeups;
|
||||
|
||||
/// <summary>
|
||||
/// The exact float arithmetic of a CS2 tradeup. Kept pure and dependency-free so it
|
||||
/// can be unit-tested in isolation and reused verbatim by any frontend.
|
||||
/// <para>
|
||||
/// The contract: each input float is normalised to its own skin's range FIRST, those
|
||||
/// fractions are averaged, and the average is mapped onto the OUTPUT skin's range. The
|
||||
/// output float depends only on the average input fraction — a single scalar — which
|
||||
/// is what makes the search tractable (see the engine design notes).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class TradeupMath
|
||||
{
|
||||
/// <summary>
|
||||
/// Normalises an input float to the fraction of its own skin's wear range:
|
||||
/// <c>(value − min) / (max − min)</c>, clamped to [0,1]. A zero-width range
|
||||
/// (min == max) has no meaningful fraction and yields 0.
|
||||
/// </summary>
|
||||
public static decimal NormalizedFraction(decimal floatValue, decimal skinFloatMin, decimal skinFloatMax)
|
||||
{
|
||||
var span = skinFloatMax - skinFloatMin;
|
||||
if (span <= 0m)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
var fraction = (floatValue - skinFloatMin) / span;
|
||||
return Math.Clamp(fraction, 0m, 1m);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an average input fraction onto an output skin's wear range to get the exact
|
||||
/// float the tradeup would produce: <c>avgFraction × (max − min) + min</c>.
|
||||
/// </summary>
|
||||
public static decimal OutputFloat(decimal averageFraction, decimal outputFloatMin, decimal outputFloatMax)
|
||||
=> averageFraction * (outputFloatMax - outputFloatMin) + outputFloatMin;
|
||||
}
|
||||
278
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupSelector.cs
Normal file
278
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupSelector.cs
Normal file
@@ -0,0 +1,278 @@
|
||||
namespace BlueLaminate.Core.Tradeups;
|
||||
|
||||
/// <summary>One candidate input copy, with its normalised fraction precomputed.</summary>
|
||||
public readonly record struct SelectableInput(decimal Fraction, InputListing Listing);
|
||||
|
||||
/// <summary>
|
||||
/// A persistent (shared-tail) singly-linked list of chosen listings. Travels with each
|
||||
/// DP state so the winning selection is reconstructed for free — no fragile back-pointer
|
||||
/// walk over a mutated cost table.
|
||||
/// </summary>
|
||||
public sealed record PickNode(InputListing Listing, PickNode? Previous)
|
||||
{
|
||||
public IReadOnlyList<InputListing> ToList()
|
||||
{
|
||||
var items = new List<InputListing>();
|
||||
for (var node = this; node is not null; node = node.Previous)
|
||||
{
|
||||
items.Add(node.Listing);
|
||||
}
|
||||
|
||||
items.Reverse();
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <summary>Exact total cost of the chosen copies (the DP minimises this in double).</summary>
|
||||
public decimal TotalCost()
|
||||
{
|
||||
var total = 0m;
|
||||
for (var node = this; node is not null; node = node.Previous)
|
||||
{
|
||||
total += node.Listing.Price;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The result of the selection DP: for every reachable summed-fraction bucket, the
|
||||
/// cheapest way to pick exactly <see cref="ContractSize"/> distinct input copies whose
|
||||
/// average fraction rounds (conservatively, upward) to that bucket. The finder reads
|
||||
/// this once and evaluates output revenue across every bucket, which is equivalent to
|
||||
/// solving the cheapest-inputs knapsack at every wear-boundary breakpoint at once.
|
||||
/// </summary>
|
||||
public sealed class TradeupSelection
|
||||
{
|
||||
private readonly PickNode?[] _pickBySum;
|
||||
private readonly decimal _bucketWidth;
|
||||
|
||||
internal TradeupSelection(PickNode?[] pickBySum, int contractSize, decimal bucketWidth)
|
||||
{
|
||||
_pickBySum = pickBySum;
|
||||
ContractSize = contractSize;
|
||||
_bucketWidth = bucketWidth;
|
||||
}
|
||||
|
||||
public int ContractSize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Every feasible full selection: the (conservative) average input fraction, the total
|
||||
/// input cost, and the exact copies to buy.
|
||||
/// </summary>
|
||||
public IEnumerable<(decimal AverageFraction, decimal Cost, PickNode Picks)> Selections()
|
||||
{
|
||||
for (var sum = 0; sum < _pickBySum.Length; sum++)
|
||||
{
|
||||
if (_pickBySum[sum] is { } picks)
|
||||
{
|
||||
var averageFraction = sum * _bucketWidth / ContractSize;
|
||||
yield return (averageFraction, picks.TotalCost(), picks);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Solves "pick exactly N distinct listings minimising total price, for each attainable
|
||||
/// summed-fraction level". A bounded knapsack over the discretised fraction: O(items × N
|
||||
/// × buckets). Fractions are bucketed by rounding UP, so the reported average float is an
|
||||
/// upper bound — the conservative direction (it can only make an output look worse, never
|
||||
/// better). Cost is minimised in <c>double</c> for speed; the exact decimal cost is
|
||||
/// recovered from the reconstructed picks.
|
||||
/// </summary>
|
||||
public static class TradeupSelector
|
||||
{
|
||||
public static TradeupSelection Solve(
|
||||
IReadOnlyList<SelectableInput> pool,
|
||||
int contractSize,
|
||||
decimal bucketWidth)
|
||||
{
|
||||
if (contractSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(contractSize));
|
||||
}
|
||||
|
||||
if (bucketWidth <= 0m)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(bucketWidth));
|
||||
}
|
||||
|
||||
// Buckets 0..maxBucketPerItem (fraction is clamped to [0,1]); summed across the
|
||||
// contract gives the DP's second dimension.
|
||||
var maxBucketPerItem = (int)Math.Ceiling(1m / bucketWidth);
|
||||
var maxSum = maxBucketPerItem * contractSize;
|
||||
|
||||
var items = Trim(pool, bucketWidth, maxBucketPerItem, contractSize);
|
||||
|
||||
// dp[c][s] = cheapest cost (double) to choose exactly c items whose bucket sum is s;
|
||||
// dpPick carries the actual copies so the winner is reconstructed exactly.
|
||||
var dpCost = new double[contractSize + 1, maxSum + 1];
|
||||
var dpPick = new PickNode?[contractSize + 1, maxSum + 1];
|
||||
for (var c = 0; c <= contractSize; c++)
|
||||
{
|
||||
for (var s = 0; s <= maxSum; s++)
|
||||
{
|
||||
dpCost[c, s] = double.PositiveInfinity;
|
||||
}
|
||||
}
|
||||
|
||||
dpCost[0, 0] = 0d;
|
||||
|
||||
foreach (var (bucket, listing, price) in items)
|
||||
{
|
||||
// Descending count + sum so each item is used at most once (0/1 knapsack).
|
||||
for (var c = contractSize - 1; c >= 0; c--)
|
||||
{
|
||||
for (var s = maxSum - bucket; s >= 0; s--)
|
||||
{
|
||||
var baseCost = dpCost[c, s];
|
||||
if (double.IsPositiveInfinity(baseCost))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var newCost = baseCost + price;
|
||||
var ns = s + bucket;
|
||||
if (newCost < dpCost[c + 1, ns])
|
||||
{
|
||||
dpCost[c + 1, ns] = newCost;
|
||||
dpPick[c + 1, ns] = new PickNode(listing, dpPick[c, s]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var pickBySum = new PickNode?[maxSum + 1];
|
||||
for (var s = 0; s <= maxSum; s++)
|
||||
{
|
||||
pickBySum[s] = dpPick[contractSize, s];
|
||||
}
|
||||
|
||||
return new TradeupSelection(pickBySum, contractSize, bucketWidth);
|
||||
}
|
||||
|
||||
// Within a single fraction bucket only the cheapest `contractSize` copies can ever be
|
||||
// part of an optimal selection, so drop the rest up front. This bounds the item count
|
||||
// regardless of how deep a popular skin's order book is.
|
||||
private static List<(int Bucket, InputListing Listing, double Price)> Trim(
|
||||
IReadOnlyList<SelectableInput> pool,
|
||||
decimal bucketWidth,
|
||||
int maxBucketPerItem,
|
||||
int contractSize)
|
||||
{
|
||||
var byBucket = new Dictionary<int, List<InputListing>>();
|
||||
foreach (var item in pool)
|
||||
{
|
||||
var bucket = BucketOf(item.Fraction, bucketWidth, maxBucketPerItem);
|
||||
if (!byBucket.TryGetValue(bucket, out var list))
|
||||
{
|
||||
list = new List<InputListing>();
|
||||
byBucket[bucket] = list;
|
||||
}
|
||||
|
||||
list.Add(item.Listing);
|
||||
}
|
||||
|
||||
var trimmed = new List<(int, InputListing, double)>();
|
||||
foreach (var (bucket, listings) in byBucket)
|
||||
{
|
||||
listings.Sort(static (a, b) => a.Price.CompareTo(b.Price));
|
||||
var take = Math.Min(contractSize, listings.Count);
|
||||
for (var i = 0; i < take; i++)
|
||||
{
|
||||
trimmed.Add((bucket, listings[i], (double)listings[i].Price));
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Round the fraction UP to a bucket index (conservative: never understates output float).
|
||||
private static int BucketOf(decimal fraction, decimal bucketWidth, int maxBucketPerItem)
|
||||
{
|
||||
var clamped = Math.Clamp(fraction, 0m, 1m);
|
||||
var bucket = (int)Math.Ceiling(clamped / bucketWidth);
|
||||
return Math.Clamp(bucket, 0, maxBucketPerItem);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One candidate input copy for the multi-collection search: its (already bucketed) float
|
||||
/// contribution and a per-item reward (its collection's average output value share minus
|
||||
/// its price, at a fixed output-float target).
|
||||
/// </summary>
|
||||
public readonly record struct RewardItem(int Bucket, double Reward, InputListing Listing);
|
||||
|
||||
/// <summary>
|
||||
/// Picks exactly <paramref name="contractSize"/> copies that MAXIMISE total reward subject
|
||||
/// to the bucketed float sum not exceeding <paramref name="capBucket"/> (i.e. mean float
|
||||
/// ≤ the target). This is the multi-collection core: with each item's reward set to its
|
||||
/// collection's value share minus price, the optimum naturally mixes whichever collections
|
||||
/// pay off — no collection-subset enumeration. Returns the chosen copies, or null if the
|
||||
/// contract can't be filled within the cap.
|
||||
/// </summary>
|
||||
public static PickNode? SolveMaxReward(
|
||||
IReadOnlyList<RewardItem> items, int contractSize, int capBucket)
|
||||
{
|
||||
if (contractSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(contractSize));
|
||||
}
|
||||
|
||||
var maxSum = Math.Max(0, capBucket);
|
||||
|
||||
// dp[c][s] = max total reward for exactly c items with bucket sum s (≤ cap).
|
||||
var dpReward = new double[contractSize + 1, maxSum + 1];
|
||||
var dpPick = new PickNode?[contractSize + 1, maxSum + 1];
|
||||
for (var c = 0; c <= contractSize; c++)
|
||||
{
|
||||
for (var s = 0; s <= maxSum; s++)
|
||||
{
|
||||
dpReward[c, s] = double.NegativeInfinity;
|
||||
}
|
||||
}
|
||||
|
||||
dpReward[0, 0] = 0d;
|
||||
|
||||
foreach (var (bucket, reward, listing) in items)
|
||||
{
|
||||
if (bucket > maxSum)
|
||||
{
|
||||
continue; // a single copy already over the cap can't be in any valid contract
|
||||
}
|
||||
|
||||
for (var c = contractSize - 1; c >= 0; c--)
|
||||
{
|
||||
for (var s = maxSum - bucket; s >= 0; s--)
|
||||
{
|
||||
var baseReward = dpReward[c, s];
|
||||
if (double.IsNegativeInfinity(baseReward))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var newReward = baseReward + reward;
|
||||
var ns = s + bucket;
|
||||
if (newReward > dpReward[c + 1, ns])
|
||||
{
|
||||
dpReward[c + 1, ns] = newReward;
|
||||
dpPick[c + 1, ns] = new PickNode(listing, dpPick[c, s]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PickNode? best = null;
|
||||
var bestReward = double.NegativeInfinity;
|
||||
for (var s = 0; s <= maxSum; s++)
|
||||
{
|
||||
if (dpReward[contractSize, s] > bestReward && dpPick[contractSize, s] is { } pick)
|
||||
{
|
||||
bestReward = dpReward[contractSize, s];
|
||||
best = pick;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
}
|
||||
75
BlueLaminate/BlueLaminate.Core/Tradeups/WeaponRarity.cs
Normal file
75
BlueLaminate/BlueLaminate.Core/Tradeups/WeaponRarity.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
namespace BlueLaminate.Core.Tradeups;
|
||||
|
||||
/// <summary>
|
||||
/// The ordered weapon-skin rarity tiers that participate in a 10-input tradeup.
|
||||
/// The ordinal value IS the tier order: a tradeup consumes 10 inputs of tier T and
|
||||
/// produces an output at the next tier present in the same collection.
|
||||
/// <para>
|
||||
/// Only the six weapon tiers are modelled. The catalogue also carries
|
||||
/// <c>Contraband</c> (the Howl) and <c>Extraordinary</c> (gloves), and knives are
|
||||
/// stored as <c>Covert</c>; none of those are weapon tradeup tiers, so
|
||||
/// <see cref="TryParse"/> reports them as "not a weapon tier" rather than mapping
|
||||
/// them. See the eligibility rules in <see cref="TradeupGraphBuilder"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public enum WeaponRarity
|
||||
{
|
||||
Consumer = 1,
|
||||
Industrial = 2,
|
||||
MilSpec = 3,
|
||||
Restricted = 4,
|
||||
Classified = 5,
|
||||
Covert = 6,
|
||||
}
|
||||
|
||||
public static class WeaponRarityExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a <c>skins.rarity</c> string literal to its weapon tier.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <c>true</c> with <paramref name="rarity"/> set when the literal is one of the
|
||||
/// six weapon tiers; <c>false</c> for <c>Contraband</c>/<c>Extraordinary</c>
|
||||
/// (valid catalogue rarities that are not weapon tradeup tiers).
|
||||
/// </returns>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// The literal is none of the known catalogue rarities. Thrown deliberately so a
|
||||
/// catalogue rename surfaces loudly instead of silently dropping a whole tier.
|
||||
/// </exception>
|
||||
public static bool TryParse(string rarity, out WeaponRarity result)
|
||||
{
|
||||
switch (rarity)
|
||||
{
|
||||
case "Consumer Grade":
|
||||
result = WeaponRarity.Consumer;
|
||||
return true;
|
||||
case "Industrial Grade":
|
||||
result = WeaponRarity.Industrial;
|
||||
return true;
|
||||
case "Mil-Spec Grade":
|
||||
result = WeaponRarity.MilSpec;
|
||||
return true;
|
||||
case "Restricted":
|
||||
result = WeaponRarity.Restricted;
|
||||
return true;
|
||||
case "Classified":
|
||||
result = WeaponRarity.Classified;
|
||||
return true;
|
||||
case "Covert":
|
||||
result = WeaponRarity.Covert;
|
||||
return true;
|
||||
|
||||
// Known, valid catalogue rarities that are not weapon tradeup tiers.
|
||||
case "Contraband": // The Howl
|
||||
case "Extraordinary": // Gloves
|
||||
result = default;
|
||||
return false;
|
||||
|
||||
default:
|
||||
throw new ArgumentException(
|
||||
$"Unknown skin rarity literal '{rarity}'. The catalogue may have renamed a "
|
||||
+ "rarity; update WeaponRarityExtensions.TryParse so a tier isn't silently dropped.",
|
||||
nameof(rarity));
|
||||
}
|
||||
}
|
||||
}
|
||||
58
BlueLaminate/BlueLaminate.Core/Tradeups/WearBand.cs
Normal file
58
BlueLaminate/BlueLaminate.Core/Tradeups/WearBand.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
namespace BlueLaminate.Core.Tradeups;
|
||||
|
||||
/// <summary>
|
||||
/// The five CS2 wear bands, defined by absolute float thresholds (independent of any
|
||||
/// individual skin's float range). A produced item's band — and therefore which
|
||||
/// listings it competes with — is read straight off its absolute float.
|
||||
/// </summary>
|
||||
public enum WearBand
|
||||
{
|
||||
FactoryNew,
|
||||
MinimalWear,
|
||||
FieldTested,
|
||||
WellWorn,
|
||||
BattleScarred,
|
||||
}
|
||||
|
||||
public static class WearBands
|
||||
{
|
||||
// Upper-exclusive boundaries: FN [0,0.07) MW [0.07,0.15) FT [0.15,0.38)
|
||||
// WW [0.38,0.45) BS [0.45,1.0].
|
||||
public const decimal MinimalWearFloor = 0.07m;
|
||||
public const decimal FieldTestedFloor = 0.15m;
|
||||
public const decimal WellWornFloor = 0.38m;
|
||||
public const decimal BattleScarredFloor = 0.45m;
|
||||
|
||||
/// <summary>The wear band an absolute float value falls into.</summary>
|
||||
public static WearBand FromFloat(decimal floatValue) => floatValue switch
|
||||
{
|
||||
< MinimalWearFloor => WearBand.FactoryNew,
|
||||
< FieldTestedFloor => WearBand.MinimalWear,
|
||||
< WellWornFloor => WearBand.FieldTested,
|
||||
< BattleScarredFloor => WearBand.WellWorn,
|
||||
_ => WearBand.BattleScarred,
|
||||
};
|
||||
|
||||
/// <summary>The absolute float range [min, max) that defines this band — used to scope a
|
||||
/// CSFloat query to the band the produced output lands in.</summary>
|
||||
public static (decimal Min, decimal Max) Bounds(this WearBand band) => band switch
|
||||
{
|
||||
WearBand.FactoryNew => (0.00m, MinimalWearFloor),
|
||||
WearBand.MinimalWear => (MinimalWearFloor, FieldTestedFloor),
|
||||
WearBand.FieldTested => (FieldTestedFloor, WellWornFloor),
|
||||
WearBand.WellWorn => (WellWornFloor, BattleScarredFloor),
|
||||
WearBand.BattleScarred => (BattleScarredFloor, 1.00m),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(band), band, null),
|
||||
};
|
||||
|
||||
/// <summary>The full wear name as it appears in listing data ("Factory New", …).</summary>
|
||||
public static string ToName(this WearBand band) => band switch
|
||||
{
|
||||
WearBand.FactoryNew => "Factory New",
|
||||
WearBand.MinimalWear => "Minimal Wear",
|
||||
WearBand.FieldTested => "Field-Tested",
|
||||
WearBand.WellWorn => "Well-Worn",
|
||||
WearBand.BattleScarred => "Battle-Scarred",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(band), band, null),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user