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 },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user