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

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