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