namespace BlueLaminate.Core.Tradeups; /// One candidate input copy, with its normalised fraction precomputed. public readonly record struct SelectableInput(decimal Fraction, InputListing Listing); /// /// 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. /// public sealed record PickNode(InputListing Listing, PickNode? Previous) { public IReadOnlyList ToList() { var items = new List(); for (var node = this; node is not null; node = node.Previous) { items.Add(node.Listing); } items.Reverse(); return items; } /// Exact total cost of the chosen copies (the DP minimises this in double). public decimal TotalCost() { var total = 0m; for (var node = this; node is not null; node = node.Previous) { total += node.Listing.Price; } return total; } } /// /// The result of the selection DP: for every reachable summed-fraction bucket, the /// cheapest way to pick exactly 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. /// 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; } /// /// Every feasible full selection: the (conservative) average input fraction, the total /// input cost, and the exact copies to buy. /// 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); } } } } /// /// 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 double for speed; the exact decimal cost is /// recovered from the reconstructed picks. /// public static class TradeupSelector { public static TradeupSelection Solve( IReadOnlyList 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 pool, decimal bucketWidth, int maxBucketPerItem, int contractSize) { var byBucket = new Dictionary>(); foreach (var item in pool) { var bucket = BucketOf(item.Fraction, bucketWidth, maxBucketPerItem); if (!byBucket.TryGetValue(bucket, out var list)) { list = new List(); 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); } /// /// 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). /// public readonly record struct RewardItem(int Bucket, double Reward, InputListing Listing); /// /// Picks exactly copies that MAXIMISE total reward subject /// to the bucketed float sum not exceeding (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. /// public static PickNode? SolveMaxReward( IReadOnlyList 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; } }