using System.Collections.Concurrent;
using BlueLaminate.Core.Options;
namespace BlueLaminate.Core.Tradeups;
///
/// 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).
///
/// It exploits two facts: an output's probability is linear in how many inputs came from its
/// collection (n_C / size·k_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.
///
///
public static class MultiCollectionSearch
{
private sealed record CollectionInfo(
int CollectionId,
string CollectionName,
WeaponRarity OutputRarity,
IReadOnlyList 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 Evaluate(
TradeupGraph graph,
TradeupListingData listingData,
IReadOnlyDictionary floatBounds,
TradeupOptions options,
CancellationToken ct)
{
var step = options.MultiCollectionFloatGrid;
var size = options.ContractSize;
var maxBucketPerItem = (int)Math.Ceiling(1m / step);
var results = new List();
// 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 tierGroups,
bool statTrak,
TradeupListingData listingData,
IReadOnlyDictionary floatBounds,
TradeupOptions options,
decimal step,
int size,
int maxBucketPerItem,
List 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();
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();
for (var f = step; f <= 1m + 1e-9m; f += step)
{
grid.Add(Math.Min(f, 1m));
}
var chunkResults = new ConcurrentBag();
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 BuildTrimmedPool(
List tierGroups,
bool statTrak,
TradeupListingData listingData,
IReadOnlyDictionary floatBounds,
decimal step,
int maxBucketPerItem,
int size)
{
// (collection, bucket) -> cheapest copies.
var cells = new Dictionary<(int Collection, int Bucket), List>();
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();
cells[key] = cell;
}
cell.Add(new PoolItem(group.CollectionId, bucket, fraction, listing));
}
}
}
var trimmed = new List();
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 trimmed,
IReadOnlyDictionary collections,
IReadOnlyDictionary skinCollection,
IReadOnlyDictionary 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(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(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 collections,
IReadOnlyDictionary skinCollection,
IReadOnlyDictionary 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();
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();
var composition = new List();
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 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 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 StatTrakUniverses(StatTrakMode mode) => mode switch
{
StatTrakMode.NonStatTrakOnly => new[] { false },
StatTrakMode.StatTrakOnly => new[] { true },
_ => new[] { false, true },
};
}