Files
Operation-Blue-Laminate-v2/BlueLaminate/BlueLaminate.Core/Tradeups/MultiCollectionSearch.cs
2026-06-02 13:31:27 -05:00

410 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 },
};
}