410 lines
15 KiB
C#
410 lines
15 KiB
C#
using System.Collections.Concurrent;
|
||
using BlueLaminate.Core.Options;
|
||
|
||
namespace BlueLaminate.Core.Tradeups;
|
||
|
||
/// <summary>
|
||
/// The multi-collection tradeup search. Where the single-collection pass keeps all ten
|
||
/// inputs in one collection, this mixes inputs from any collections sharing a rarity tier —
|
||
/// the pattern behind most genuinely profitable contracts (cheap inputs from one collection,
|
||
/// a valuable output rolled from another).
|
||
/// <para>
|
||
/// It exploits two facts: an output's probability is linear in how many inputs came from its
|
||
/// collection (<c>n_C / size·k_C</c>), and the produced float depends only on the single
|
||
/// global average input fraction. So for a FIXED output-float target F, each input copy has an
|
||
/// independent reward — its collection's average output value share minus its price — and one
|
||
/// max-reward knapsack over the whole tier's pool finds the optimal mix without enumerating
|
||
/// collection subsets. The search sweeps F on a grid; each grid point is an independent chunk
|
||
/// run in parallel. Because the reward is a linear (expected-value) function, this optimises
|
||
/// EXPECTED profit; each winning selection is then evaluated exactly.
|
||
/// </para>
|
||
/// </summary>
|
||
public static class MultiCollectionSearch
|
||
{
|
||
private sealed record CollectionInfo(
|
||
int CollectionId,
|
||
string CollectionName,
|
||
WeaponRarity OutputRarity,
|
||
IReadOnlyList<TradeupOutputSkin> Outputs);
|
||
|
||
// An input copy already reduced to its collection, float bucket and price.
|
||
private readonly record struct PoolItem(int CollectionId, int Bucket, decimal Fraction, InputListing Listing);
|
||
|
||
public static List<TradeupCandidate> Evaluate(
|
||
TradeupGraph graph,
|
||
TradeupListingData listingData,
|
||
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
|
||
TradeupOptions options,
|
||
CancellationToken ct)
|
||
{
|
||
var step = options.MultiCollectionFloatGrid;
|
||
var size = options.ContractSize;
|
||
var maxBucketPerItem = (int)Math.Ceiling(1m / step);
|
||
|
||
var results = new List<TradeupCandidate>();
|
||
|
||
// All recipes sharing an input rarity can be mixed (each collection still rolls into
|
||
// its own next tier). Group by (input rarity, ST universe).
|
||
var byTier = graph.Groups.GroupBy(g => g.InputRarity);
|
||
|
||
foreach (var tier in byTier)
|
||
{
|
||
var tierGroups = tier.ToList();
|
||
foreach (var statTrak in StatTrakUniverses(options.StatTrak))
|
||
{
|
||
ct.ThrowIfCancellationRequested();
|
||
EvaluateTier(tier.Key, tierGroups, statTrak, listingData, floatBounds, options, step, size,
|
||
maxBucketPerItem, results, ct);
|
||
}
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
private static void EvaluateTier(
|
||
WeaponRarity inputRarity,
|
||
List<TradeupInputGroup> tierGroups,
|
||
bool statTrak,
|
||
TradeupListingData listingData,
|
||
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
|
||
TradeupOptions options,
|
||
decimal step,
|
||
int size,
|
||
int maxBucketPerItem,
|
||
List<TradeupCandidate> results,
|
||
CancellationToken ct)
|
||
{
|
||
var collections = tierGroups.ToDictionary(
|
||
g => g.CollectionId,
|
||
g => new CollectionInfo(g.CollectionId, g.CollectionName, g.OutputRarity, g.OutputSkins));
|
||
var skinCollection = new Dictionary<int, int>();
|
||
foreach (var g in tierGroups)
|
||
{
|
||
foreach (var skinId in g.InputSkinIds)
|
||
{
|
||
skinCollection[skinId] = g.CollectionId;
|
||
}
|
||
}
|
||
|
||
// Build the tier's input pool once, then trim to the cheapest `size` copies per
|
||
// (collection, float bucket) — within a cell every copy has the same float and value,
|
||
// so only the cheapest can ever be optimal. Bucketing/trim don't depend on the target,
|
||
// so this is done once and reused across all grid chunks.
|
||
var trimmed = BuildTrimmedPool(tierGroups, statTrak, listingData, floatBounds, step, maxBucketPerItem, size);
|
||
if (trimmed.Count < size)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var priceBook = listingData.OutputPrices;
|
||
|
||
// One chunk per float-target grid point, run in parallel.
|
||
var grid = new List<decimal>();
|
||
for (var f = step; f <= 1m + 1e-9m; f += step)
|
||
{
|
||
grid.Add(Math.Min(f, 1m));
|
||
}
|
||
|
||
var chunkResults = new ConcurrentBag<TradeupCandidate>();
|
||
|
||
Parallel.ForEach(
|
||
grid,
|
||
new ParallelOptions { CancellationToken = ct },
|
||
target =>
|
||
{
|
||
var candidate = EvaluateChunk(
|
||
inputRarity, target, trimmed, collections, skinCollection, floatBounds, priceBook,
|
||
options, step, size, statTrak);
|
||
if (candidate is not null)
|
||
{
|
||
chunkResults.Add(candidate);
|
||
}
|
||
});
|
||
|
||
// Only genuine mixes belong here — single-collection selections are the dedicated
|
||
// pass's job, and emitting them too just duplicates rows. De-duplicate by collection
|
||
// mix (different float targets often converge on the same set), keep the best expected
|
||
// profit, then take the top few.
|
||
var deduped = chunkResults
|
||
.Where(c => c.CollectionCount >= 2)
|
||
.GroupBy(MixSignature)
|
||
.Select(grp => grp.MaxBy(c => c.ExpectedProfit)!)
|
||
.OrderByDescending(c => c.ExpectedProfit)
|
||
.Take(options.MultiCollectionPerTier);
|
||
|
||
lock (results)
|
||
{
|
||
results.AddRange(deduped);
|
||
}
|
||
}
|
||
|
||
private static List<PoolItem> BuildTrimmedPool(
|
||
List<TradeupInputGroup> tierGroups,
|
||
bool statTrak,
|
||
TradeupListingData listingData,
|
||
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
|
||
decimal step,
|
||
int maxBucketPerItem,
|
||
int size)
|
||
{
|
||
// (collection, bucket) -> cheapest copies.
|
||
var cells = new Dictionary<(int Collection, int Bucket), List<PoolItem>>();
|
||
|
||
foreach (var group in tierGroups)
|
||
{
|
||
foreach (var skinId in group.InputSkinIds)
|
||
{
|
||
if (!floatBounds.TryGetValue(skinId, out var bounds))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
foreach (var listing in listingData.InputsFor(skinId, statTrak))
|
||
{
|
||
var fraction = TradeupMath.NormalizedFraction(listing.FloatValue, bounds.Min, bounds.Max);
|
||
var bucket = Math.Clamp((int)Math.Ceiling(fraction / step), 0, maxBucketPerItem);
|
||
var key = (group.CollectionId, bucket);
|
||
if (!cells.TryGetValue(key, out var cell))
|
||
{
|
||
cell = new List<PoolItem>();
|
||
cells[key] = cell;
|
||
}
|
||
|
||
cell.Add(new PoolItem(group.CollectionId, bucket, fraction, listing));
|
||
}
|
||
}
|
||
}
|
||
|
||
var trimmed = new List<PoolItem>();
|
||
foreach (var cell in cells.Values)
|
||
{
|
||
cell.Sort(static (a, b) => a.Listing.Price.CompareTo(b.Listing.Price));
|
||
for (var i = 0; i < Math.Min(size, cell.Count); i++)
|
||
{
|
||
trimmed.Add(cell[i]);
|
||
}
|
||
}
|
||
|
||
return trimmed;
|
||
}
|
||
|
||
private static TradeupCandidate? EvaluateChunk(
|
||
WeaponRarity inputRarity,
|
||
decimal target,
|
||
List<PoolItem> trimmed,
|
||
IReadOnlyDictionary<int, CollectionInfo> collections,
|
||
IReadOnlyDictionary<int, int> skinCollection,
|
||
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
|
||
OutputPriceBook priceBook,
|
||
TradeupOptions options,
|
||
decimal step,
|
||
int size,
|
||
bool statTrak)
|
||
{
|
||
// Each collection's average output value if the produced float averages `target`.
|
||
var valueByCollection = new Dictionary<int, decimal>(collections.Count);
|
||
foreach (var (id, info) in collections)
|
||
{
|
||
valueByCollection[id] = AverageOutputValue(info, target, priceBook, options, statTrak);
|
||
}
|
||
|
||
var capBucket = (int)Math.Floor(target * size / step);
|
||
|
||
var items = new List<TradeupSelector.RewardItem>(trimmed.Count);
|
||
foreach (var item in trimmed)
|
||
{
|
||
if (item.Bucket > capBucket)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// Reward = this copy's share of expected output value, minus what it costs.
|
||
var reward = (double)(valueByCollection[item.CollectionId] / size - item.Listing.Price);
|
||
items.Add(new TradeupSelector.RewardItem(item.Bucket, reward, item.Listing));
|
||
}
|
||
|
||
var picks = TradeupSelector.SolveMaxReward(items, size, capBucket);
|
||
return picks is null
|
||
? null
|
||
: BuildCandidate(inputRarity, picks, collections, skinCollection, floatBounds, priceBook, options, size, statTrak);
|
||
}
|
||
|
||
// The conservative average output value of a collection at a given input-float average:
|
||
// each next-tier skin is equally likely; an output with no comparable listing contributes 0.
|
||
private static decimal AverageOutputValue(
|
||
CollectionInfo info, decimal averageFraction, OutputPriceBook priceBook,
|
||
TradeupOptions options, bool statTrak)
|
||
{
|
||
if (info.Outputs.Count == 0)
|
||
{
|
||
return 0m;
|
||
}
|
||
|
||
decimal total = 0m;
|
||
foreach (var output in info.Outputs)
|
||
{
|
||
var outputFloat = TradeupMath.OutputFloat(averageFraction, output.FloatMin, output.FloatMax);
|
||
var resolved = priceBook.Resolve(output.SkinId, statTrak, outputFloat, options.CsFloatThinOutputThreshold);
|
||
if (resolved.LowestAsk is { } ask)
|
||
{
|
||
total += NetSell(ask, options);
|
||
}
|
||
}
|
||
|
||
return total / info.Outputs.Count;
|
||
}
|
||
|
||
private static TradeupCandidate BuildCandidate(
|
||
WeaponRarity inputRarity,
|
||
PickNode picks,
|
||
IReadOnlyDictionary<int, CollectionInfo> collections,
|
||
IReadOnlyDictionary<int, int> skinCollection,
|
||
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
|
||
OutputPriceBook priceBook,
|
||
TradeupOptions options,
|
||
int size,
|
||
bool statTrak)
|
||
{
|
||
var inputs = picks.ToList();
|
||
|
||
// Realised average fraction from the actual copies (exact, not bucketed).
|
||
decimal fractionSum = 0m;
|
||
var counts = new Dictionary<int, int>();
|
||
decimal cost = 0m;
|
||
foreach (var input in inputs)
|
||
{
|
||
cost += input.Price;
|
||
if (floatBounds.TryGetValue(input.SkinId, out var bounds))
|
||
{
|
||
fractionSum += TradeupMath.NormalizedFraction(input.FloatValue, bounds.Min, bounds.Max);
|
||
}
|
||
|
||
var collectionId = skinCollection[input.SkinId];
|
||
counts[collectionId] = counts.GetValueOrDefault(collectionId) + 1;
|
||
}
|
||
|
||
var averageFraction = fractionSum / size;
|
||
|
||
var outcomes = new List<TradeupOutcome>();
|
||
var composition = new List<TradeupContribution>();
|
||
|
||
foreach (var (collectionId, n) in counts.OrderByDescending(kv => kv.Value))
|
||
{
|
||
var info = collections[collectionId];
|
||
composition.Add(new TradeupContribution(collectionId, info.CollectionName, info.OutputRarity, n));
|
||
|
||
var k = info.Outputs.Count;
|
||
if (k == 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var probability = (decimal)n / (size * k);
|
||
foreach (var output in info.Outputs)
|
||
{
|
||
var outputFloat = TradeupMath.OutputFloat(averageFraction, output.FloatMin, output.FloatMax);
|
||
var band = WearBands.FromFloat(outputFloat);
|
||
var resolved = priceBook.Resolve(output.SkinId, statTrak, outputFloat, options.CsFloatThinOutputThreshold);
|
||
outcomes.Add(new TradeupOutcome(
|
||
output.SkinId,
|
||
output.Name,
|
||
outputFloat,
|
||
band,
|
||
probability,
|
||
resolved.LowestAsk is { } ask ? NetSell(ask, options) : null,
|
||
resolved.BandLiquidity,
|
||
resolved.Basis == OutputPriceBasis.Floor ? "market-floor" : "market"));
|
||
}
|
||
}
|
||
|
||
var (expectedNet, worstCaseNet, guaranteed) = Economics(outcomes, cost);
|
||
|
||
var primary = composition[0];
|
||
return new TradeupCandidate(
|
||
primary.CollectionId,
|
||
SummariseMix(composition),
|
||
inputRarity,
|
||
primary.OutputRarity,
|
||
statTrak,
|
||
averageFraction,
|
||
cost,
|
||
expectedNet,
|
||
worstCaseNet,
|
||
guaranteed,
|
||
inputs,
|
||
outcomes,
|
||
composition);
|
||
}
|
||
|
||
private static string SummariseMix(IReadOnlyList<TradeupContribution> composition)
|
||
{
|
||
if (composition.Count == 1)
|
||
{
|
||
return composition[0].CollectionName;
|
||
}
|
||
|
||
var parts = composition.Take(3).Select(c => $"{Shorten(c.CollectionName)} ×{c.InputCount}");
|
||
var summary = string.Join(" + ", parts);
|
||
return composition.Count > 3 ? $"{summary} +{composition.Count - 3}" : summary;
|
||
}
|
||
|
||
private static string Shorten(string name)
|
||
{
|
||
// "The 2021 Mirage Collection" -> "Mirage" style trimming for compact summaries.
|
||
var trimmed = name;
|
||
if (trimmed.StartsWith("The ", StringComparison.Ordinal))
|
||
{
|
||
trimmed = trimmed[4..];
|
||
}
|
||
|
||
const string suffix = " Collection";
|
||
if (trimmed.EndsWith(suffix, StringComparison.Ordinal))
|
||
{
|
||
trimmed = trimmed[..^suffix.Length];
|
||
}
|
||
|
||
return trimmed;
|
||
}
|
||
|
||
private static string MixSignature(TradeupCandidate candidate)
|
||
=> string.Join(',', candidate.Composition
|
||
.OrderBy(c => c.CollectionId)
|
||
.Select(c => $"{c.CollectionId}:{c.InputCount}"));
|
||
|
||
private static decimal NetSell(decimal lowestAsk, TradeupOptions options)
|
||
=> lowestAsk * (1m - options.UndercutRate) * (1m - options.SellFeeRate);
|
||
|
||
private static (decimal Expected, decimal Worst, bool Guaranteed) Economics(
|
||
IReadOnlyList<TradeupOutcome> outcomes, decimal cost)
|
||
{
|
||
decimal expected = 0m;
|
||
decimal worst = decimal.MaxValue;
|
||
var allPriced = true;
|
||
|
||
foreach (var outcome in outcomes)
|
||
{
|
||
var realised = outcome.NetSellPrice ?? 0m;
|
||
expected += outcome.Probability * realised;
|
||
worst = Math.Min(worst, realised);
|
||
if (outcome.NetSellPrice is null)
|
||
{
|
||
allPriced = false;
|
||
}
|
||
}
|
||
|
||
if (outcomes.Count == 0)
|
||
{
|
||
worst = 0m;
|
||
}
|
||
|
||
return (expected, worst, allPriced && worst > cost);
|
||
}
|
||
|
||
private static IReadOnlyList<bool> StatTrakUniverses(StatTrakMode mode) => mode switch
|
||
{
|
||
StatTrakMode.NonStatTrakOnly => new[] { false },
|
||
StatTrakMode.StatTrakOnly => new[] { true },
|
||
_ => new[] { false, true },
|
||
};
|
||
}
|