final
This commit is contained in:
278
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupSelector.cs
Normal file
278
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupSelector.cs
Normal file
@@ -0,0 +1,278 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user