279 lines
10 KiB
C#
279 lines
10 KiB
C#
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;
|
||
}
|
||
}
|