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 }, }; }