final
This commit is contained in:
@@ -11,6 +11,10 @@
|
||||
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="BlueLaminate.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\BlueLaminate.Core\BlueLaminate.Core.csproj" />
|
||||
<ProjectReference Include="..\BlueLaminate.Scraper\BlueLaminate.Scraper.csproj" />
|
||||
@@ -20,6 +24,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="Spectre.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
267
BlueLaminate/BlueLaminate.Cli/Commands/FindTradeupsCommand.cs
Normal file
267
BlueLaminate/BlueLaminate.Cli/Commands/FindTradeupsCommand.cs
Normal file
@@ -0,0 +1,267 @@
|
||||
using BlueLaminate.Cli.Tui;
|
||||
using BlueLaminate.Core.Options;
|
||||
using BlueLaminate.Core.Tradeups;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.CommandLine;
|
||||
|
||||
namespace BlueLaminate.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// <c>find-tradeups</c>: surfaces the most profitable 10-input tradeup contracts over the
|
||||
/// live listings. Pure presentation over <see cref="TradeupFinder.FindAsync"/> — all the
|
||||
/// economics live in the Core engine so the future web UI shares them verbatim. The CLI
|
||||
/// flags only override <see cref="TradeupOptions"/> for the run.
|
||||
/// <para>
|
||||
/// In an interactive terminal it opens the <see cref="TradeupBrowser"/> TUI; pipe the
|
||||
/// output or pass <c>--plain</c> for the scriptable table dump.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class FindTradeupsCommand
|
||||
{
|
||||
public static Command Build(IHost host)
|
||||
{
|
||||
var topOption = new Option<int>("--top")
|
||||
{
|
||||
Description = "How many contracts to show.",
|
||||
DefaultValueFactory = _ => 20,
|
||||
};
|
||||
var minProfitOption = new Option<decimal?>("--min-profit")
|
||||
{
|
||||
Description = "Only show contracts whose ranking profit clears this amount (USD).",
|
||||
};
|
||||
var statTrakOption = new Option<StatTrakMode?>("--stattrak")
|
||||
{
|
||||
Description = "Which universes to search: Both, NonStatTrakOnly, or StatTrakOnly.",
|
||||
};
|
||||
var rankingOption = new Option<TradeupRanking?>("--rank")
|
||||
{
|
||||
Description = "Rank by WorstCaseProfit (guaranteed) or ExpectedProfit.",
|
||||
};
|
||||
var allowRiskyOption = new Option<bool>("--allow-risky")
|
||||
{
|
||||
Description = "Include contracts that aren't guaranteed-profit (off by default).",
|
||||
};
|
||||
var detailOption = new Option<bool>("--detail")
|
||||
{
|
||||
Description = "Plain mode only: show the per-output distribution and the copies to buy.",
|
||||
};
|
||||
var plainOption = new Option<bool>("--plain")
|
||||
{
|
||||
Description = "Force the non-interactive table dump instead of the TUI.",
|
||||
};
|
||||
|
||||
var command = new Command(
|
||||
"find-tradeups",
|
||||
"Find profitable 10-input CS2 tradeup contracts from live listings, ranked best-first.")
|
||||
{
|
||||
topOption,
|
||||
minProfitOption,
|
||||
statTrakOption,
|
||||
rankingOption,
|
||||
allowRiskyOption,
|
||||
detailOption,
|
||||
plainOption,
|
||||
};
|
||||
|
||||
command.SetAction((parseResult, ct) => RunAsync(
|
||||
host,
|
||||
parseResult.GetValue(topOption),
|
||||
parseResult.GetValue(minProfitOption),
|
||||
parseResult.GetValue(statTrakOption),
|
||||
parseResult.GetValue(rankingOption),
|
||||
parseResult.GetValue(allowRiskyOption),
|
||||
parseResult.GetValue(detailOption),
|
||||
parseResult.GetValue(plainOption),
|
||||
ct));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static async Task<int> RunAsync(
|
||||
IHost host,
|
||||
int top,
|
||||
decimal? minProfit,
|
||||
StatTrakMode? statTrak,
|
||||
TradeupRanking? ranking,
|
||||
bool allowRisky,
|
||||
bool detail,
|
||||
bool plain,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var scope = host.Services.CreateScope();
|
||||
|
||||
// Apply per-run overrides on top of the configured TradeupOptions. The finder reads
|
||||
// IOptions, so mutate the resolved instance for this scope only.
|
||||
var options = scope.ServiceProvider.GetRequiredService<IOptions<TradeupOptions>>().Value;
|
||||
if (minProfit is { } mp)
|
||||
{
|
||||
options.MinProfit = mp;
|
||||
}
|
||||
|
||||
if (statTrak is { } st)
|
||||
{
|
||||
options.StatTrak = st;
|
||||
}
|
||||
|
||||
if (ranking is { } r)
|
||||
{
|
||||
options.Ranking = r;
|
||||
}
|
||||
|
||||
if (allowRisky)
|
||||
{
|
||||
options.GuaranteedOnly = false;
|
||||
}
|
||||
|
||||
var interactive = !plain && TradeupBrowser.IsSupported;
|
||||
|
||||
// When launched bare (no search-policy flags), open the TUI on its settings screen so
|
||||
// the options are tweakable in-app. Passing any policy flag skips straight to results
|
||||
// (the settings screen is still reachable from there via "Adjust & re-run").
|
||||
var noPolicyFlags = minProfit is null && statTrak is null && ranking is null && !allowRisky;
|
||||
var showSettings = interactive && noPolicyFlags;
|
||||
|
||||
try
|
||||
{
|
||||
var finder = scope.ServiceProvider.GetRequiredService<TradeupFinder>();
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (showSettings && TradeupBrowser.PromptSettings(options, ref top) == SettingsAction.Quit)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"Searching tradeups ({options.StatTrak}, rank by {options.Ranking}, "
|
||||
+ $"{(options.GuaranteedOnly ? "guaranteed-only" : "incl. risky")}, "
|
||||
+ $"min profit {options.MinProfit:C})…");
|
||||
Console.WriteLine();
|
||||
|
||||
var candidates = await finder.FindAsync(maxResults: top, ct: ct);
|
||||
|
||||
if (!interactive)
|
||||
{
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
Console.WriteLine("No qualifying contracts found.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Print(candidates, options.Ranking, detail);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
Console.WriteLine("No qualifying contracts found — adjusting settings.");
|
||||
Console.WriteLine();
|
||||
showSettings = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TradeupBrowser.Run(candidates, options) == BrowseAction.Quit)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// BrowseAction.AdjustSettings → loop back to the settings screen and re-search.
|
||||
showSettings = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Tradeup search failed: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Print(
|
||||
IReadOnlyList<TradeupCandidate> candidates, TradeupRanking ranking, bool detail)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"{"#",-3} {"Collection",-34} {"Recipe",-22} {"ST",-3} "
|
||||
+ $"{"Cost",10} {"E[net]",10} {"Worst",10} {"E[profit]",11} {"Worst P/L",11} {"G",2}");
|
||||
Console.WriteLine(new string('-', 140));
|
||||
|
||||
var rank = 1;
|
||||
foreach (var c in candidates)
|
||||
{
|
||||
var recipe = c.CollectionCount > 1
|
||||
? $"{c.InputRarity}→mix×{c.CollectionCount}"
|
||||
: $"{c.InputRarity}→{c.OutputRarity}";
|
||||
var guaranteed = c.Guaranteed ? "✓" : " ";
|
||||
Console.WriteLine(
|
||||
$"{rank,-3} {Truncate(c.CollectionName, 34),-34} {recipe,-22} "
|
||||
+ $"{(c.StatTrak ? "ST" : "—"),-3} "
|
||||
+ $"{c.InputCost,10:C} {c.ExpectedNet,10:C} {c.WorstCaseNet,10:C} "
|
||||
+ $"{c.ExpectedProfit,11:C} {c.WorstCaseProfit,11:C} {guaranteed,2}");
|
||||
|
||||
if (detail)
|
||||
{
|
||||
PrintDetail(c);
|
||||
}
|
||||
|
||||
rank++;
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(
|
||||
$"Ranked by {ranking}. 'G' marks guaranteed (every output clears input cost). "
|
||||
+ "Sell prices are net of undercut + fee; outputs with no comparable listing are unpriced.");
|
||||
}
|
||||
|
||||
private static void PrintDetail(TradeupCandidate c)
|
||||
{
|
||||
if (c.CollectionCount > 1)
|
||||
{
|
||||
var mix = string.Join(", ", c.Composition.Select(p =>
|
||||
$"{p.InputCount}× {p.CollectionName} → {p.OutputRarity}"));
|
||||
Console.WriteLine($" mix: {mix}");
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$" avg input fraction {c.AverageFraction:F4} — "
|
||||
+ $"possible outputs ({c.Outcomes.Count}):");
|
||||
|
||||
foreach (var o in c.Outcomes.OrderByDescending(o => o.NetSellPrice ?? -1m))
|
||||
{
|
||||
var price = o.NetSellPrice is { } net ? net.ToString("C") : "(unpriced)";
|
||||
var source = o.PriceSource switch
|
||||
{
|
||||
"csfloat-live" => " (csfloat live)",
|
||||
"market-floor" => " (floor est)",
|
||||
_ => string.Empty,
|
||||
};
|
||||
Console.WriteLine(
|
||||
$" {o.Probability,6:P1} {Truncate(o.Name, 44),-44} "
|
||||
+ $"float {o.OutputFloat:F4} {o.Band,-14} {price,10} liq {o.Liquidity}{source}");
|
||||
}
|
||||
|
||||
// The actionable buy list: each of the ten copies on its own line — exactly what to
|
||||
// search for, on which market, at what float and price. Sorted cheapest-first.
|
||||
Console.WriteLine($" buy ({c.Inputs.Count} inputs, total {c.InputCost:C}):");
|
||||
var n = 1;
|
||||
foreach (var input in c.Inputs.OrderBy(i => i.Price))
|
||||
{
|
||||
var locator = string.IsNullOrWhiteSpace(input.InspectLink)
|
||||
? $"id {input.ExternalId}"
|
||||
: input.InspectLink;
|
||||
Console.WriteLine(
|
||||
$" {n,2}. {Truncate(input.MarketHashName, 52),-52} "
|
||||
+ $"float {FullFloat(input.FloatValue),-20} {input.Price,9:C} @ {input.Marketplace,-9} {locator}");
|
||||
n++;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int max)
|
||||
=> value.Length <= max ? value : value[..(max - 1)] + "…";
|
||||
|
||||
// Full stored listing float (trailing zeros dropped) so a copy is matchable exactly.
|
||||
private static string FullFloat(decimal value)
|
||||
=> value.ToString("0.##################", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -72,6 +72,7 @@ var root = new RootCommand("BlueLaminate CLI — Counter-Strike skin tracker too
|
||||
FetchListingsCommand.Build(host),
|
||||
SweepListingsCommand.Build(host),
|
||||
SweepCatalogCommand.Build(host),
|
||||
FindTradeupsCommand.Build(host),
|
||||
};
|
||||
|
||||
// Ctrl+C → cancel the action's token so long-running commands (e.g. sweep-catalog,
|
||||
|
||||
352
BlueLaminate/BlueLaminate.Cli/Tui/TradeupBrowser.cs
Normal file
352
BlueLaminate/BlueLaminate.Cli/Tui/TradeupBrowser.cs
Normal file
@@ -0,0 +1,352 @@
|
||||
using BlueLaminate.Core.Options;
|
||||
using BlueLaminate.Core.Tradeups;
|
||||
using Spectre.Console;
|
||||
using System.Globalization;
|
||||
|
||||
namespace BlueLaminate.Cli.Tui;
|
||||
|
||||
/// <summary>What the user chose to do next after browsing results.</summary>
|
||||
internal enum BrowseAction
|
||||
{
|
||||
/// <summary>Leave the finder.</summary>
|
||||
Quit,
|
||||
|
||||
/// <summary>Return to the settings screen and run a fresh search.</summary>
|
||||
AdjustSettings,
|
||||
}
|
||||
|
||||
/// <summary>What the user chose on the settings screen.</summary>
|
||||
internal enum SettingsAction
|
||||
{
|
||||
/// <summary>Run a search with the current settings.</summary>
|
||||
RunSearch,
|
||||
|
||||
/// <summary>Leave the finder.</summary>
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interactive terminal browser for tradeup candidates: a settings screen, then an
|
||||
/// arrow-key navigable list of contracts that drills into a detail view with the output
|
||||
/// distribution and a line-by-line buy list (each copy linked to its source listing).
|
||||
/// Pure presentation over the <see cref="TradeupCandidate"/>s the Core engine produces; it
|
||||
/// only mutates a <see cref="TradeupOptions"/> the command then feeds back to the finder.
|
||||
/// <para>
|
||||
/// The render/prompt methods take an <see cref="IAnsiConsole"/> so they can be exercised
|
||||
/// against a recording console in tests; only the live loop needs the real terminal.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class TradeupBrowser
|
||||
{
|
||||
private const int QuitSentinel = -1;
|
||||
private const int AdjustSentinel = -2;
|
||||
|
||||
/// <summary>Whether the current terminal can host the interactive prompts.</summary>
|
||||
public static bool IsSupported => AnsiConsole.Profile.Capabilities.Interactive;
|
||||
|
||||
/// <summary>
|
||||
/// Settings screen: shows the current search options and lets the user tweak any of them
|
||||
/// before running. Mutates <paramref name="options"/> and <paramref name="top"/> in place;
|
||||
/// returns whether to run a search or quit.
|
||||
/// </summary>
|
||||
public static SettingsAction PromptSettings(TradeupOptions options, ref int top)
|
||||
=> PromptSettings(AnsiConsole.Console, options, ref top);
|
||||
|
||||
public static SettingsAction PromptSettings(IAnsiConsole console, TradeupOptions options, ref int top)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
console.Clear();
|
||||
console.Write(new Rule("[bold yellow]Tradeup Finder[/] — settings")
|
||||
{
|
||||
Justification = Justify.Left,
|
||||
});
|
||||
console.MarkupLine("[grey]Pick a setting to change it, then [green]Run search[/]. (↑/↓ + Enter)[/]");
|
||||
console.WriteLine();
|
||||
|
||||
const string run = "[green]▶ Run search[/]";
|
||||
const string quit = "[red]✕ Quit[/]";
|
||||
var stItem = $"StatTrak universe · [aqua]{options.StatTrak}[/]";
|
||||
var rankItem = $"Rank by · [aqua]{options.Ranking}[/]";
|
||||
var guarItem = "Profit filter · "
|
||||
+ (options.GuaranteedOnly ? "[green]guaranteed only[/]" : "[yellow]include risky[/]");
|
||||
var minItem = $"Min profit · [aqua]{options.MinProfit:C}[/]";
|
||||
var topItem = $"Show top · [aqua]{top}[/]";
|
||||
|
||||
var choice = console.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title("Settings")
|
||||
.PageSize(12)
|
||||
.AddChoices(run, stItem, rankItem, guarItem, minItem, topItem, quit));
|
||||
|
||||
if (choice == run)
|
||||
{
|
||||
return SettingsAction.RunSearch;
|
||||
}
|
||||
|
||||
if (choice == quit)
|
||||
{
|
||||
console.Clear();
|
||||
return SettingsAction.Quit;
|
||||
}
|
||||
|
||||
if (choice == stItem)
|
||||
{
|
||||
options.StatTrak = console.Prompt(
|
||||
new SelectionPrompt<StatTrakMode>()
|
||||
.Title("StatTrak universe to search")
|
||||
.AddChoices(StatTrakMode.Both, StatTrakMode.NonStatTrakOnly, StatTrakMode.StatTrakOnly));
|
||||
}
|
||||
else if (choice == rankItem)
|
||||
{
|
||||
options.Ranking = console.Prompt(
|
||||
new SelectionPrompt<TradeupRanking>()
|
||||
.Title("Rank surviving contracts by")
|
||||
.AddChoices(TradeupRanking.WorstCaseProfit, TradeupRanking.ExpectedProfit));
|
||||
}
|
||||
else if (choice == guarItem)
|
||||
{
|
||||
options.GuaranteedOnly = console.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title("Which contracts should survive?")
|
||||
.AddChoices("Guaranteed only (worst output still profits)", "Include risky (any positive EV)"))
|
||||
.StartsWith("Guaranteed");
|
||||
}
|
||||
else if (choice == minItem)
|
||||
{
|
||||
options.MinProfit = console.Prompt(
|
||||
new TextPrompt<decimal>("Minimum ranking profit (USD):")
|
||||
.DefaultValue(options.MinProfit)
|
||||
.ShowDefaultValue());
|
||||
}
|
||||
else if (choice == topItem)
|
||||
{
|
||||
top = console.Prompt(
|
||||
new TextPrompt<int>("Show how many contracts:")
|
||||
.DefaultValue(top)
|
||||
.ShowDefaultValue()
|
||||
.Validate(v => v > 0 ? ValidationResult.Success() : ValidationResult.Error("must be > 0")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static BrowseAction Run(IReadOnlyList<TradeupCandidate> candidates, TradeupOptions options)
|
||||
=> Run(AnsiConsole.Console, candidates, options);
|
||||
|
||||
public static BrowseAction Run(
|
||||
IAnsiConsole console, IReadOnlyList<TradeupCandidate> candidates, TradeupOptions options)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
console.Clear();
|
||||
RenderHeader(console, candidates.Count, options);
|
||||
|
||||
var indices = Enumerable.Range(0, candidates.Count)
|
||||
.Append(AdjustSentinel)
|
||||
.Append(QuitSentinel)
|
||||
.ToList();
|
||||
|
||||
var selected = console.Prompt(
|
||||
new SelectionPrompt<int>()
|
||||
.Title("Select a contract to inspect its [green]buy list[/]:")
|
||||
.PageSize(18)
|
||||
.WrapAround()
|
||||
.MoreChoicesText("[grey](↑/↓ to scroll, type to filter)[/]")
|
||||
.AddChoices(indices)
|
||||
.UseConverter(ListChoiceLabel(candidates)));
|
||||
|
||||
switch (selected)
|
||||
{
|
||||
case QuitSentinel:
|
||||
console.Clear();
|
||||
return BrowseAction.Quit;
|
||||
case AdjustSentinel:
|
||||
return BrowseAction.AdjustSettings;
|
||||
}
|
||||
|
||||
RenderDetail(console, candidates[selected], selected);
|
||||
|
||||
const string back = "← Back to list";
|
||||
const string adjust = "⚙ Adjust settings & re-run";
|
||||
const string quit = "Quit";
|
||||
var next = console.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title(string.Empty)
|
||||
.AddChoices(back, adjust, quit));
|
||||
|
||||
if (next == quit)
|
||||
{
|
||||
console.Clear();
|
||||
return BrowseAction.Quit;
|
||||
}
|
||||
|
||||
if (next == adjust)
|
||||
{
|
||||
return BrowseAction.AdjustSettings;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Func<int, string> ListChoiceLabel(IReadOnlyList<TradeupCandidate> candidates) => i => i switch
|
||||
{
|
||||
QuitSentinel => "[red]Quit[/]",
|
||||
AdjustSentinel => "[yellow]⚙ Adjust settings & re-run[/]",
|
||||
_ => SummaryLine(candidates[i], i),
|
||||
};
|
||||
|
||||
private static void RenderHeader(IAnsiConsole console, int count, TradeupOptions options)
|
||||
{
|
||||
console.Write(new Rule($"[bold yellow]Tradeup Finder[/] — {count} contracts")
|
||||
{
|
||||
Justification = Justify.Left,
|
||||
});
|
||||
console.MarkupLine(
|
||||
$"[grey]{options.StatTrak} · rank by {options.Ranking} · "
|
||||
+ $"{(options.GuaranteedOnly ? "guaranteed-only" : "incl. risky")} · "
|
||||
+ $"sell net of {options.UndercutRate:P0} undercut + {options.SellFeeRate:P0} fee[/]");
|
||||
console.WriteLine();
|
||||
}
|
||||
|
||||
// One aligned, lightly-coloured line per contract for the selection list. Padding is
|
||||
// applied to the raw text before markup so columns stay aligned (skin/collection names
|
||||
// contain no markup brackets in practice; escaped defensively anyway).
|
||||
internal static string SummaryLine(TradeupCandidate c, int index)
|
||||
{
|
||||
var rank = $"{index + 1,3}.";
|
||||
var collection = Truncate(c.CollectionName, 30).PadRight(30);
|
||||
var recipeText = c.CollectionCount > 1
|
||||
? $"{c.InputRarity}→mix×{c.CollectionCount}"
|
||||
: $"{c.InputRarity}→{c.OutputRarity}";
|
||||
var recipe = recipeText.PadRight(21);
|
||||
var st = c.StatTrak ? "ST " : " ";
|
||||
var worst = Money(c.WorstCaseProfit).PadLeft(10);
|
||||
var expected = Money(c.ExpectedProfit).PadLeft(10);
|
||||
var flag = c.Guaranteed ? "[green]✓[/]" : " ";
|
||||
|
||||
var worstColor = c.WorstCaseProfit > 0 ? "green" : "red";
|
||||
return $"{rank} {Markup.Escape(collection)} [aqua]{Markup.Escape(recipe)}[/] {st} "
|
||||
+ $"cost [silver]{Money(c.InputCost),10}[/] worst [{worstColor}]{worst}[/] "
|
||||
+ $"exp [green]{expected}[/] {flag}";
|
||||
}
|
||||
|
||||
internal static void RenderDetail(IAnsiConsole console, TradeupCandidate c, int index)
|
||||
{
|
||||
console.Clear();
|
||||
|
||||
var recipe = c.CollectionCount > 1 ? $"{c.InputRarity}→mix" : $"{c.InputRarity}→{c.OutputRarity}";
|
||||
var title = $"#{index + 1} [bold]{Markup.Escape(c.CollectionName)}[/] "
|
||||
+ $"[aqua]{recipe}[/]{(c.StatTrak ? " [orange1]StatTrak™[/]" : string.Empty)}";
|
||||
console.Write(new Rule(title) { Justification = Justify.Left });
|
||||
|
||||
if (c.CollectionCount > 1)
|
||||
{
|
||||
var mix = string.Join(" ", c.Composition.Select(p =>
|
||||
$"[silver]{p.InputCount}×[/] {Markup.Escape(p.CollectionName)} [grey]→ {p.OutputRarity}[/]"));
|
||||
console.MarkupLine($"[grey]mix:[/] {mix}");
|
||||
}
|
||||
|
||||
var worstColor = c.WorstCaseProfit > 0 ? "green" : "red";
|
||||
var economics = new Grid();
|
||||
economics.AddColumns(6);
|
||||
economics.AddRow(
|
||||
"[grey]Input cost[/]", "[grey]E(net)[/]", "[grey]Worst net[/]",
|
||||
"[grey]E(profit)[/]", "[grey]Worst P/L[/]", "[grey]Avg float frac[/]");
|
||||
economics.AddRow(
|
||||
$"[silver]{Money(c.InputCost)}[/]",
|
||||
$"{Money(c.ExpectedNet)}",
|
||||
$"{Money(c.WorstCaseNet)}",
|
||||
$"[green]{Money(c.ExpectedProfit)}[/]",
|
||||
$"[{worstColor}]{Money(c.WorstCaseProfit)}[/]",
|
||||
$"{c.AverageFraction:F4}");
|
||||
console.Write(new Panel(economics)
|
||||
{
|
||||
Header = new PanelHeader(c.Guaranteed ? " [green]GUARANTEED[/] " : " [yellow]not guaranteed[/] "),
|
||||
Border = BoxBorder.Rounded,
|
||||
});
|
||||
|
||||
RenderOutcomes(console, c);
|
||||
RenderBuyList(console, c);
|
||||
}
|
||||
|
||||
private static void RenderOutcomes(IAnsiConsole console, TradeupCandidate c)
|
||||
{
|
||||
var table = new Table().Border(TableBorder.Minimal).Title("[bold]Possible outputs[/]");
|
||||
table.AddColumn(new TableColumn("Chance").RightAligned());
|
||||
table.AddColumn("Output");
|
||||
table.AddColumn(new TableColumn("Float").RightAligned());
|
||||
table.AddColumn("Wear");
|
||||
table.AddColumn(new TableColumn("Net sell").RightAligned());
|
||||
table.AddColumn(new TableColumn("Liq").RightAligned());
|
||||
|
||||
foreach (var o in c.Outcomes.OrderByDescending(o => o.NetSellPrice ?? -1m))
|
||||
{
|
||||
var price = o.NetSellPrice is { } net ? $"[green]{Money(net)}[/]" : "[grey](unpriced)[/]";
|
||||
var liquidity = o.PriceSource switch
|
||||
{
|
||||
"csfloat-live" => $"{o.Liquidity} [blue]· csfloat live[/]",
|
||||
"market-floor" => $"{o.Liquidity} [yellow]· floor est[/]",
|
||||
_ => o.Liquidity.ToString(),
|
||||
};
|
||||
table.AddRow(
|
||||
$"{o.Probability:P1}",
|
||||
Markup.Escape(o.Name),
|
||||
$"{o.OutputFloat:F4}",
|
||||
Markup.Escape(o.Band.ToName()),
|
||||
price,
|
||||
liquidity);
|
||||
}
|
||||
|
||||
console.Write(table);
|
||||
}
|
||||
|
||||
private static void RenderBuyList(IAnsiConsole console, TradeupCandidate c)
|
||||
{
|
||||
var table = new Table().Border(TableBorder.Minimal)
|
||||
.Title($"[bold]Buy list[/] — {c.Inputs.Count} inputs, total [silver]{Money(c.InputCost)}[/]");
|
||||
table.AddColumn(new TableColumn("#").RightAligned());
|
||||
table.AddColumn("Item");
|
||||
table.AddColumn(new TableColumn("Float").RightAligned().NoWrap());
|
||||
table.AddColumn(new TableColumn("Price").RightAligned());
|
||||
table.AddColumn("Market");
|
||||
table.AddColumn("Listing");
|
||||
|
||||
var n = 1;
|
||||
foreach (var input in c.Inputs.OrderBy(i => i.Price))
|
||||
{
|
||||
table.AddRow(
|
||||
n.ToString(),
|
||||
Markup.Escape(input.MarketHashName),
|
||||
FullFloat(input.FloatValue),
|
||||
$"[silver]{Money(input.Price)}[/]",
|
||||
Markup.Escape(input.Marketplace),
|
||||
ListingLink(input));
|
||||
n++;
|
||||
}
|
||||
|
||||
console.Write(table);
|
||||
}
|
||||
|
||||
// A clickable inspect link in terminals that support OSC-8 hyperlinks; otherwise the
|
||||
// external listing id, so the copy is still traceable.
|
||||
private static string ListingLink(InputListing input)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(input.InspectLink)
|
||||
&& !input.InspectLink.Contains('[')
|
||||
&& !input.InspectLink.Contains(']'))
|
||||
{
|
||||
return $"[link={input.InspectLink}]inspect[/]";
|
||||
}
|
||||
|
||||
return $"[grey]{Markup.Escape(input.ExternalId)}[/]";
|
||||
}
|
||||
|
||||
private static string Money(decimal value) => value.ToString("C");
|
||||
|
||||
// The real listing float at full stored precision (trailing zeros dropped), so a copy can
|
||||
// be matched exactly on the market. Invariant culture keeps the decimal point a dot.
|
||||
private static string FullFloat(decimal value)
|
||||
=> value.ToString("0.##################", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Truncate(string value, int max)
|
||||
=> value.Length <= max ? value : value[..(max - 1)] + "…";
|
||||
}
|
||||
@@ -51,6 +51,8 @@ public static class ServiceCollectionExtensions
|
||||
.Bind(configuration.GetSection(SkinCatalogOptions.SectionName));
|
||||
services.AddOptions<SweepOptions>()
|
||||
.Bind(configuration.GetSection(SweepOptions.SectionName));
|
||||
services.AddOptions<TradeupOptions>()
|
||||
.Bind(configuration.GetSection(TradeupOptions.SectionName));
|
||||
|
||||
// Typed-handler pooling via IHttpClientFactory; clients are scoped so a
|
||||
// command's handler and the service it drives share one instance (and thus
|
||||
@@ -73,6 +75,8 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<CsMoney.CsMoneyIngestService>();
|
||||
services.AddScoped<CsMoney.MarketPresenceService>();
|
||||
services.AddScoped<SkinLand.SkinLandIngestService>();
|
||||
services.AddScoped<Tradeups.TradeupGraphBuilder>();
|
||||
services.AddScoped<Tradeups.TradeupFinder>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
122
BlueLaminate/BlueLaminate.Core/Options/TradeupOptions.cs
Normal file
122
BlueLaminate/BlueLaminate.Core/Options/TradeupOptions.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
namespace BlueLaminate.Core.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Which StatTrak universes the finder searches. The two input pools are disjoint and
|
||||
/// never mix in a contract: non-ST inputs (normal ∪ souvenir) produce a normal output,
|
||||
/// ST inputs produce an ST output.
|
||||
/// </summary>
|
||||
public enum StatTrakMode
|
||||
{
|
||||
/// <summary>Search both the non-ST and ST universes (default).</summary>
|
||||
Both,
|
||||
|
||||
/// <summary>Only the non-ST universe (normal + souvenir inputs → normal output).</summary>
|
||||
NonStatTrakOnly,
|
||||
|
||||
/// <summary>Only the StatTrak universe (ST inputs → ST output).</summary>
|
||||
StatTrakOnly,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How to rank surviving candidates.
|
||||
/// </summary>
|
||||
public enum TradeupRanking
|
||||
{
|
||||
/// <summary>By worst-case (minimum across outputs) net profit — low variance.</summary>
|
||||
WorstCaseProfit,
|
||||
|
||||
/// <summary>By expected net profit across the output distribution.</summary>
|
||||
ExpectedProfit,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tuning for the tradeup finder, bound from the <c>Tradeups</c> configuration section.
|
||||
/// Defaults are sensible for CS2 marketplaces (15% sell fee) and a conservative v1
|
||||
/// (guaranteed-profit only). Everything here is economics/policy — none of it lives in
|
||||
/// the CLI.
|
||||
/// </summary>
|
||||
public sealed class TradeupOptions
|
||||
{
|
||||
public const string SectionName = "Tradeups";
|
||||
|
||||
/// <summary>Number of inputs per contract. v1 supports 10-input weapon tradeups only.</summary>
|
||||
public int ContractSize { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Fraction of the sale price taken as marketplace commission when selling an output
|
||||
/// (0.15 = 15%). Applied to the realised sell price.
|
||||
/// </summary>
|
||||
public decimal SellFeeRate { get; set; } = 0.15m;
|
||||
|
||||
/// <summary>
|
||||
/// Fraction shaved off the lowest active ask to model undercutting it for a quick
|
||||
/// sale (0.01 = list 1% under the cheapest competitor). Applied before the fee.
|
||||
/// </summary>
|
||||
public decimal UndercutRate { get; set; } = 0.01m;
|
||||
|
||||
/// <summary>
|
||||
/// Bucket width used to discretise normalised input fractions for the
|
||||
/// cardinality-constrained selection DP. Smaller = finer output-float resolution at
|
||||
/// higher cost. 0.005 resolves the wear boundaries to within 0.005 of output float.
|
||||
/// </summary>
|
||||
public decimal FractionBucket { get; set; } = 0.005m;
|
||||
|
||||
/// <summary>
|
||||
/// When true (v1 default) only contracts whose worst-case output still clears input
|
||||
/// cost survive — a guaranteed profit. When false, any positive-EV contract survives.
|
||||
/// </summary>
|
||||
public bool GuaranteedOnly { get; set; } = true;
|
||||
|
||||
/// <summary>Minimum net profit (in the listing currency) for a candidate to be reported.</summary>
|
||||
public decimal MinProfit { get; set; } = 0m;
|
||||
|
||||
/// <summary>How surviving candidates are ordered.</summary>
|
||||
public TradeupRanking Ranking { get; set; } = TradeupRanking.WorstCaseProfit;
|
||||
|
||||
/// <summary>Which StatTrak universes to search.</summary>
|
||||
public StatTrakMode StatTrak { get; set; } = StatTrakMode.Both;
|
||||
|
||||
/// <summary>
|
||||
/// Currency listings must be in to be comparable. The finder ignores listings in
|
||||
/// other currencies rather than converting (v1 keeps a single money space).
|
||||
/// </summary>
|
||||
public string Currency { get; set; } = "USD";
|
||||
|
||||
/// <summary>
|
||||
/// When a proposed output has fewer than this many active listings in our data, its
|
||||
/// stored lowest-ask is fragile, so the finder re-prices it from the live CSFloat API.
|
||||
/// </summary>
|
||||
public int CsFloatThinOutputThreshold { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Enables the live CSFloat re-pricing of thin outputs. Silently inert when no CSFloat
|
||||
/// API key is configured.
|
||||
/// </summary>
|
||||
public bool UseCsFloatForThinOutputs { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Hard cap on live CSFloat lookups per search, so the re-pricing pass can't blow the
|
||||
/// API rate-limit budget. Distinct (skin, ST, wear band) lookups are cached within a run.
|
||||
/// </summary>
|
||||
public int CsFloatMaxLookups { get; set; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Enables the multi-collection search: alongside the single-collection pass, it mixes
|
||||
/// inputs from any collections at a rarity tier to maximise expected profit. Off keeps the
|
||||
/// finder single-collection only.
|
||||
/// </summary>
|
||||
public bool MultiCollection { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Step of the output-float target grid the multi-collection search sweeps. Each grid
|
||||
/// point is an independent, parallelised chunk (one knapsack over the tier's pool), so a
|
||||
/// finer grid is more thorough but does more work. 0.02 ≈ 50 chunks per tier × ST.
|
||||
/// </summary>
|
||||
public decimal MultiCollectionFloatGrid { get; set; } = 0.02m;
|
||||
|
||||
/// <summary>
|
||||
/// How many distinct multi-collection contracts to keep per (rarity tier, StatTrak), best
|
||||
/// expected-profit first, after de-duplicating by collection mix. Caps result volume.
|
||||
/// </summary>
|
||||
public int MultiCollectionPerTier { get; set; } = 8;
|
||||
}
|
||||
@@ -27,6 +27,13 @@ public static class SkinLandSlug
|
||||
/// Lowercase, collapse every run of non-alphanumeric characters to a single hyphen,
|
||||
/// and trim leading/trailing hyphens. So "AK-47 | Redline (Field-Tested)" and the
|
||||
/// catalogue's "AK-47 Redline Field-Tested" both reduce to "ak-47-redline-field-tested".
|
||||
/// <para>
|
||||
/// The apostrophe is the one exception: skin.land keeps it literally in the slug rather
|
||||
/// than collapsing it to a hyphen (verified live — "AWP | Man-o'-war" →
|
||||
/// <c>awp-man-o'-war</c>, "AUG | Lil' Pig" → <c>aug-lil'-pig</c>; the collapsed
|
||||
/// <c>man-o-war</c>/<c>lil-pig</c> forms 404). Both the ASCII (') and typographic (’)
|
||||
/// apostrophe normalize to a literal ASCII apostrophe in the slug.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static string Slugify(string value)
|
||||
{
|
||||
@@ -34,7 +41,14 @@ public static class SkinLandSlug
|
||||
var pendingHyphen = false;
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch))
|
||||
if (ch is '\'' or '’')
|
||||
{
|
||||
// skin.land preserves the apostrophe as part of the word — emit it literally,
|
||||
// and don't let it trigger a hyphen on either side.
|
||||
sb.Append('\'');
|
||||
pendingHyphen = false;
|
||||
}
|
||||
else if (char.IsLetterOrDigit(ch))
|
||||
{
|
||||
if (pendingHyphen && sb.Length > 0)
|
||||
{
|
||||
|
||||
409
BlueLaminate/BlueLaminate.Core/Tradeups/MultiCollectionSearch.cs
Normal file
409
BlueLaminate/BlueLaminate.Core/Tradeups/MultiCollectionSearch.cs
Normal file
@@ -0,0 +1,409 @@
|
||||
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 },
|
||||
};
|
||||
}
|
||||
67
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupCandidate.cs
Normal file
67
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupCandidate.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
namespace BlueLaminate.Core.Tradeups;
|
||||
|
||||
/// <summary>One possible result of a contract and what it would net if it lands.</summary>
|
||||
/// <param name="Probability">Chance this specific output is produced (single-collection: 1/k).</param>
|
||||
/// <param name="NetSellPrice">
|
||||
/// Realisable sale value after undercut + sell fee, or null when nothing comparable is
|
||||
/// listed (treated as unsellable for the worst-case test).
|
||||
/// </param>
|
||||
/// <param name="Liquidity">Active listings backing the price, in the same wear band.</param>
|
||||
/// <param name="PriceSource">Where the price came from: "market" (our stored listings) or
|
||||
/// "csfloat-live" (re-priced from the CSFloat API because the stored liquidity was thin).</param>
|
||||
public sealed record TradeupOutcome(
|
||||
int SkinId,
|
||||
string Name,
|
||||
decimal OutputFloat,
|
||||
WearBand Band,
|
||||
decimal Probability,
|
||||
decimal? NetSellPrice,
|
||||
int Liquidity,
|
||||
string PriceSource = "market");
|
||||
|
||||
/// <summary>
|
||||
/// One collection's share of a (possibly multi-collection) contract: how many of the ten
|
||||
/// inputs came from it, and which output tier those inputs roll into. Single-collection
|
||||
/// contracts have exactly one of these.
|
||||
/// </summary>
|
||||
public sealed record TradeupContribution(
|
||||
int CollectionId,
|
||||
string CollectionName,
|
||||
WeaponRarity OutputRarity,
|
||||
int InputCount);
|
||||
|
||||
/// <summary>
|
||||
/// A concrete, actionable tradeup: which ten copies to buy, what they cost, the output
|
||||
/// distribution, and the resulting economics. The finder returns these ranked; a frontend
|
||||
/// only formats them.
|
||||
/// <para>
|
||||
/// A contract may mix several collections (all inputs share the input rarity, but each
|
||||
/// collection rolls into its own next tier). <see cref="Composition"/> records the per-
|
||||
/// collection split; <see cref="CollectionCount"/> is its length. <see cref="OutputRarity"/>
|
||||
/// is the tier of the largest contributor (a display convenience for the common case).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record TradeupCandidate(
|
||||
int CollectionId,
|
||||
string CollectionName,
|
||||
WeaponRarity InputRarity,
|
||||
WeaponRarity OutputRarity,
|
||||
bool StatTrak,
|
||||
decimal AverageFraction,
|
||||
decimal InputCost,
|
||||
decimal ExpectedNet,
|
||||
decimal WorstCaseNet,
|
||||
bool Guaranteed,
|
||||
IReadOnlyList<InputListing> Inputs,
|
||||
IReadOnlyList<TradeupOutcome> Outcomes,
|
||||
IReadOnlyList<TradeupContribution> Composition)
|
||||
{
|
||||
/// <summary>Number of distinct collections the inputs are drawn from (1 = single-collection).</summary>
|
||||
public int CollectionCount => Composition.Count;
|
||||
|
||||
/// <summary>Expected profit across the output distribution, net of cost.</summary>
|
||||
public decimal ExpectedProfit => ExpectedNet - InputCost;
|
||||
|
||||
/// <summary>Profit if the worst (lowest-value) output lands — negative unless guaranteed.</summary>
|
||||
public decimal WorstCaseProfit => WorstCaseNet - InputCost;
|
||||
}
|
||||
476
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupFinder.cs
Normal file
476
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupFinder.cs
Normal file
@@ -0,0 +1,476 @@
|
||||
using BlueLaminate.Core.Options;
|
||||
using BlueLaminate.EFCore.Data;
|
||||
using BlueLaminate.EFCore.Entities;
|
||||
using BlueLaminate.Scraper.CsFloat;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace BlueLaminate.Core.Tradeups;
|
||||
|
||||
/// <summary>
|
||||
/// Finds profitable 10-input CS2 tradeup contracts over the live listings. It joins three
|
||||
/// things: the catalogue-derived <see cref="TradeupGraph"/> (which collections produce
|
||||
/// what), the active <see cref="MarketListing"/>s (what inputs cost and what outputs sell
|
||||
/// for), and the exact <see cref="TradeupMath"/>. For each (collection-recipe, StatTrak)
|
||||
/// universe it runs the cardinality-constrained selection DP and values every resulting
|
||||
/// output distribution, keeping the best contract per recipe and ranking them.
|
||||
/// <para>
|
||||
/// When a proposed contract's output is thinly listed in our data, its stored lowest-ask is
|
||||
/// fragile, so a follow-up pass re-prices that output from the live CSFloat API and
|
||||
/// recomputes the economics (see <see cref="EnrichThinOutputsAsync"/>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All economics live here, never in a frontend: the CLI and the future web UI both call
|
||||
/// <see cref="FindAsync"/> and only format the returned candidates.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class TradeupFinder
|
||||
{
|
||||
private readonly SkinTrackerDbContext _db;
|
||||
private readonly TradeupGraphBuilder _graphBuilder;
|
||||
private readonly TradeupOptions _options;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<TradeupFinder> _logger;
|
||||
|
||||
public TradeupFinder(
|
||||
SkinTrackerDbContext db,
|
||||
TradeupGraphBuilder graphBuilder,
|
||||
IOptions<TradeupOptions> options,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<TradeupFinder> logger)
|
||||
{
|
||||
_db = db;
|
||||
_graphBuilder = graphBuilder;
|
||||
_options = options.Value;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the search and returns candidates ranked best-first. <paramref name="maxResults"/>
|
||||
/// caps the returned list; pass 0 or negative for "all".
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<TradeupCandidate>> FindAsync(
|
||||
int maxResults = 50,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var graph = await _graphBuilder.BuildAsync(ct);
|
||||
|
||||
// Float bounds for input skins, needed to normalise each input copy's float to its
|
||||
// own range before averaging. (Output bounds already live on the graph.)
|
||||
var floatBounds = await _db.Skins
|
||||
.Where(s => s.FloatMin != null && s.FloatMax != null)
|
||||
.Select(s => new { s.Id, Min = s.FloatMin!.Value, Max = s.FloatMax!.Value })
|
||||
.AsNoTracking()
|
||||
.ToDictionaryAsync(s => s.Id, s => (s.Min, s.Max), ct);
|
||||
|
||||
// def_index/paint_index for output skins, so a thin output can be looked up live on
|
||||
// CSFloat (which identifies items by these two indexes).
|
||||
var indexes = await _db.Skins
|
||||
.Where(s => s.DefIndex != null && s.PaintIndex != null)
|
||||
.Select(s => new { s.Id, Def = s.DefIndex!.Value, Paint = s.PaintIndex!.Value })
|
||||
.AsNoTracking()
|
||||
.ToDictionaryAsync(s => s.Id, s => (s.Def, s.Paint), ct);
|
||||
|
||||
var listingData = await LoadListingsAsync(ct);
|
||||
|
||||
var universes = StatTrakUniverses(_options.StatTrak);
|
||||
var candidates = new List<TradeupCandidate>();
|
||||
|
||||
foreach (var group in graph.Groups)
|
||||
{
|
||||
foreach (var statTrak in universes)
|
||||
{
|
||||
var candidate = EvaluateRecipe(group, statTrak, listingData, floatBounds);
|
||||
if (candidate is not null)
|
||||
{
|
||||
candidates.Add(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-collection contracts (expected-profit optimised) on top of the single-collection
|
||||
// pass. Skipped under StatTrak filters? No — it respects the same universes internally.
|
||||
if (_options.MultiCollection)
|
||||
{
|
||||
var multi = MultiCollectionSearch.Evaluate(graph, listingData, floatBounds, _options, ct);
|
||||
_logger.LogInformation("Multi-collection search produced {Count} candidate contracts.", multi.Count);
|
||||
candidates.AddRange(multi);
|
||||
}
|
||||
|
||||
var ranked = candidates.OrderByDescending(RankingMetric).ToList();
|
||||
|
||||
// Re-price thin outputs from the live CSFloat API. Only the top window is enriched
|
||||
// (it's where results are shown, and it bounds the live lookups); the rest keep their
|
||||
// stored pricing. Then re-filter and re-rank with the refreshed economics.
|
||||
var window = maxResults > 0 ? Math.Max(maxResults * 3, 60) : ranked.Count;
|
||||
var head = await EnrichThinOutputsAsync(ranked.Take(window).ToList(), indexes, ct);
|
||||
|
||||
ranked = head.Concat(ranked.Skip(window))
|
||||
.Where(c => !_options.GuaranteedOnly || c.Guaranteed)
|
||||
.Where(c => RankingMetric(c) >= _options.MinProfit)
|
||||
.OrderByDescending(RankingMetric)
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Tradeup search complete: {Surviving} qualifying contracts (guaranteedOnly={Guaranteed}, "
|
||||
+ "minProfit={MinProfit}, statTrak={StatTrak}).",
|
||||
ranked.Count, _options.GuaranteedOnly, _options.MinProfit, _options.StatTrak);
|
||||
|
||||
return maxResults > 0 ? ranked.Take(maxResults).ToList() : ranked;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates one (recipe, StatTrak) universe: builds the input pool, solves the
|
||||
/// selection DP, and returns the best qualifying contract — or null if the recipe can't
|
||||
/// be filled or nothing clears the filters.
|
||||
/// </summary>
|
||||
private TradeupCandidate? EvaluateRecipe(
|
||||
TradeupInputGroup group,
|
||||
bool statTrak,
|
||||
TradeupListingData listingData,
|
||||
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds)
|
||||
{
|
||||
var pool = BuildPool(group, statTrak, listingData, floatBounds);
|
||||
if (pool.Count < _options.ContractSize)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var selection = TradeupSelector.Solve(pool, _options.ContractSize, _options.FractionBucket);
|
||||
|
||||
TradeupCandidate? best = null;
|
||||
decimal bestMetric = decimal.MinValue;
|
||||
|
||||
foreach (var (averageFraction, cost, picks) in selection.Selections())
|
||||
{
|
||||
var candidate = BuildCandidate(
|
||||
group, statTrak, averageFraction, cost, picks, listingData.OutputPrices);
|
||||
|
||||
if (_options.GuaranteedOnly && !candidate.Guaranteed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var metric = RankingMetric(candidate);
|
||||
if (metric < _options.MinProfit)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (metric > bestMetric)
|
||||
{
|
||||
bestMetric = metric;
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private List<SelectableInput> BuildPool(
|
||||
TradeupInputGroup group,
|
||||
bool statTrak,
|
||||
TradeupListingData listingData,
|
||||
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds)
|
||||
{
|
||||
var pool = new List<SelectableInput>();
|
||||
|
||||
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);
|
||||
pool.Add(new SelectableInput(fraction, listing));
|
||||
}
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
private TradeupCandidate BuildCandidate(
|
||||
TradeupInputGroup group,
|
||||
bool statTrak,
|
||||
decimal averageFraction,
|
||||
decimal cost,
|
||||
PickNode picks,
|
||||
OutputPriceBook priceBook)
|
||||
{
|
||||
var probability = 1m / group.OutputSkins.Count; // single-collection v1: equally likely.
|
||||
|
||||
var outcomes = new List<TradeupOutcome>(group.OutputSkins.Count);
|
||||
foreach (var output in group.OutputSkins)
|
||||
{
|
||||
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) : null,
|
||||
resolved.BandLiquidity,
|
||||
resolved.Basis == OutputPriceBasis.Floor ? "market-floor" : "market"));
|
||||
}
|
||||
|
||||
var (expectedNet, worstCaseNet, guaranteed) = Economics(outcomes, cost);
|
||||
|
||||
return new TradeupCandidate(
|
||||
group.CollectionId,
|
||||
group.CollectionName,
|
||||
group.InputRarity,
|
||||
group.OutputRarity,
|
||||
statTrak,
|
||||
averageFraction,
|
||||
cost,
|
||||
expectedNet,
|
||||
worstCaseNet,
|
||||
guaranteed,
|
||||
picks.ToList(),
|
||||
outcomes,
|
||||
new[] { new TradeupContribution(group.CollectionId, group.CollectionName, group.OutputRarity, _options.ContractSize) });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For each candidate with a thinly-listed output (liquidity below the configured
|
||||
/// threshold), fetches that output's current lowest ask from the live CSFloat API and
|
||||
/// recomputes the contract's economics. Distinct (skin, ST, band) lookups are cached and
|
||||
/// the total is capped, so this stays within the API's rate-limit budget. Inert (returns
|
||||
/// the input unchanged) when the feature is off or no CSFloat key is configured.
|
||||
/// </summary>
|
||||
private async Task<List<TradeupCandidate>> EnrichThinOutputsAsync(
|
||||
List<TradeupCandidate> candidates,
|
||||
IReadOnlyDictionary<int, (int Def, int Paint)> indexes,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!_options.UseCsFloatForThinOutputs || candidates.Count == 0)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var client = TryResolveCsFloatClient();
|
||||
if (client is null)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var cache = new Dictionary<(int Def, int Paint, bool StatTrak, WearBand Band), BandPrice?>();
|
||||
var lookups = 0;
|
||||
var enriched = 0;
|
||||
var stop = false;
|
||||
|
||||
var result = new List<TradeupCandidate>(candidates.Count);
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var thin = candidate.Outcomes.Any(o =>
|
||||
o.Liquidity < _options.CsFloatThinOutputThreshold && indexes.ContainsKey(o.SkinId));
|
||||
if (!thin)
|
||||
{
|
||||
result.Add(candidate);
|
||||
continue;
|
||||
}
|
||||
|
||||
var newOutcomes = new List<TradeupOutcome>(candidate.Outcomes.Count);
|
||||
var changed = false;
|
||||
|
||||
foreach (var outcome in candidate.Outcomes)
|
||||
{
|
||||
if (outcome.Liquidity >= _options.CsFloatThinOutputThreshold
|
||||
|| !indexes.TryGetValue(outcome.SkinId, out var idx))
|
||||
{
|
||||
newOutcomes.Add(outcome);
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = (idx.Def, idx.Paint, candidate.StatTrak, outcome.Band);
|
||||
if (!cache.TryGetValue(key, out var live))
|
||||
{
|
||||
if (stop || lookups >= _options.CsFloatMaxLookups)
|
||||
{
|
||||
newOutcomes.Add(outcome);
|
||||
continue;
|
||||
}
|
||||
|
||||
lookups++;
|
||||
try
|
||||
{
|
||||
live = await FetchCsFloatBandPriceAsync(
|
||||
client, idx.Def, idx.Paint, candidate.StatTrak, outcome.Band, ct);
|
||||
cache[key] = live;
|
||||
}
|
||||
catch (CsFloatApiException ex)
|
||||
{
|
||||
// Rate-limited or rejected — stop hitting the API and keep stored prices.
|
||||
_logger.LogWarning("CSFloat re-pricing halted after {Lookups} lookups: {Message}",
|
||||
lookups, ex.Message);
|
||||
stop = true;
|
||||
newOutcomes.Add(outcome);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (live is { } bp)
|
||||
{
|
||||
newOutcomes.Add(outcome with
|
||||
{
|
||||
NetSellPrice = NetSell(bp.LowestAsk),
|
||||
Liquidity = bp.Liquidity,
|
||||
PriceSource = "csfloat-live",
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
newOutcomes.Add(outcome);
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed)
|
||||
{
|
||||
result.Add(candidate);
|
||||
continue;
|
||||
}
|
||||
|
||||
var (expectedNet, worstCaseNet, guaranteed) = Economics(newOutcomes, candidate.InputCost);
|
||||
result.Add(candidate with
|
||||
{
|
||||
Outcomes = newOutcomes,
|
||||
ExpectedNet = expectedNet,
|
||||
WorstCaseNet = worstCaseNet,
|
||||
Guaranteed = guaranteed,
|
||||
});
|
||||
enriched++;
|
||||
}
|
||||
|
||||
if (enriched > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Re-priced {Enriched} contracts with thin outputs via CSFloat ({Lookups} live lookups).",
|
||||
enriched, lookups);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task<BandPrice?> FetchCsFloatBandPriceAsync(
|
||||
CsFloatListingsClient client, int defIndex, int paintIndex, bool statTrak, WearBand band, CancellationToken ct)
|
||||
{
|
||||
var (min, max) = band.Bounds();
|
||||
|
||||
// Sorted lowest_price ascending, scoped to the band — so the first listing matching the
|
||||
// ST flag (and not a souvenir) is the lowest comparable ask.
|
||||
var page = await client.FetchPageAsync(
|
||||
defIndex, paintIndex, sortBy: "lowest_price", limit: 50, cursor: null,
|
||||
type: "buy_now", minFloat: min, maxFloat: max, ct: ct);
|
||||
|
||||
decimal? lowest = null;
|
||||
var count = 0;
|
||||
foreach (var listing in page.Listings)
|
||||
{
|
||||
if (listing.IsSouvenir || listing.IsStatTrak != statTrak)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
count++;
|
||||
lowest ??= listing.Price;
|
||||
}
|
||||
|
||||
return lowest is { } price ? new BandPrice(price, count) : null;
|
||||
}
|
||||
|
||||
private CsFloatListingsClient? TryResolveCsFloatClient()
|
||||
{
|
||||
try
|
||||
{
|
||||
return _serviceProvider.GetRequiredService<CsFloatListingsClient>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// No API key configured (the client's ctor throws): the feature is simply inert.
|
||||
_logger.LogWarning("CSFloat re-pricing unavailable, using stored prices only: {Message}", ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Realisable sale value from a lowest ask: undercut to sell, then pay the marketplace fee.
|
||||
private decimal NetSell(decimal lowestAsk)
|
||||
=> 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;
|
||||
}
|
||||
|
||||
// Guaranteed = every output is priced AND even the cheapest one clears input cost.
|
||||
return (expected, worst, allPriced && worst > cost);
|
||||
}
|
||||
|
||||
private async Task<TradeupListingData> LoadListingsAsync(CancellationToken ct)
|
||||
{
|
||||
var rows = await _db.MarketListings
|
||||
.Where(l => l.Status == "Active"
|
||||
&& l.Currency == _options.Currency
|
||||
&& l.SkinId != null
|
||||
&& l.Price > 0m)
|
||||
.Select(l => new TradeupListingRow(
|
||||
l.SkinId!.Value,
|
||||
l.MarketHashName,
|
||||
l.Marketplace,
|
||||
l.InspectLink,
|
||||
l.ExternalId,
|
||||
l.IsStatTrak,
|
||||
l.IsSouvenir,
|
||||
l.FloatValue,
|
||||
l.Price))
|
||||
.ToListAsync(ct);
|
||||
|
||||
_logger.LogInformation("Loaded {Count} active {Currency} listings for tradeup search.",
|
||||
rows.Count, _options.Currency);
|
||||
|
||||
return TradeupListingData.Build(rows);
|
||||
}
|
||||
|
||||
private decimal RankingMetric(TradeupCandidate candidate) => _options.Ranking switch
|
||||
{
|
||||
TradeupRanking.ExpectedProfit => candidate.ExpectedProfit,
|
||||
_ => candidate.WorstCaseProfit,
|
||||
};
|
||||
|
||||
private static IReadOnlyList<bool> StatTrakUniverses(StatTrakMode mode) => mode switch
|
||||
{
|
||||
StatTrakMode.NonStatTrakOnly => new[] { false },
|
||||
StatTrakMode.StatTrakOnly => new[] { true },
|
||||
_ => new[] { false, true },
|
||||
};
|
||||
}
|
||||
37
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraph.cs
Normal file
37
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraph.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace BlueLaminate.Core.Tradeups;
|
||||
|
||||
/// <summary>
|
||||
/// A skin that can come OUT of a tradeup, carrying the float bounds needed to map an
|
||||
/// input average float onto this skin's own wear range. <see cref="StatTrakAvailable"/>
|
||||
/// is recorded so the listing-side query (Phase C) can filter ST vs non-ST outputs;
|
||||
/// the graph itself is ST-agnostic.
|
||||
/// </summary>
|
||||
public sealed record TradeupOutputSkin(
|
||||
int SkinId,
|
||||
string Name,
|
||||
decimal FloatMin,
|
||||
decimal FloatMax,
|
||||
bool StatTrakAvailable);
|
||||
|
||||
/// <summary>
|
||||
/// One tradeup "recipe slot": all eligible input skins of a single rarity within one
|
||||
/// collection, and the set of output skins they produce (the next rarity tier present
|
||||
/// in that collection). For a single-collection contract, ten inputs are drawn from
|
||||
/// <see cref="InputSkinIds"/> and each of <see cref="OutputSkins"/> is an equally likely
|
||||
/// outcome, so k_C = <c>OutputSkins.Count</c>.
|
||||
/// </summary>
|
||||
public sealed record TradeupInputGroup(
|
||||
int CollectionId,
|
||||
string CollectionName,
|
||||
WeaponRarity InputRarity,
|
||||
WeaponRarity OutputRarity,
|
||||
IReadOnlyList<int> InputSkinIds,
|
||||
IReadOnlyList<TradeupOutputSkin> OutputSkins);
|
||||
|
||||
/// <summary>
|
||||
/// The full tradeup reference graph derived from the static catalogue: every
|
||||
/// (collection, input rarity) → (output rarity, output skins) edge that yields a
|
||||
/// 10-input weapon tradeup. Built once per process from the monthly-synced catalogue
|
||||
/// (see <see cref="TradeupGraphBuilder"/>); contains no pricing or listing data.
|
||||
/// </summary>
|
||||
public sealed record TradeupGraph(IReadOnlyList<TradeupInputGroup> Groups);
|
||||
197
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraphBuilder.cs
Normal file
197
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraphBuilder.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using BlueLaminate.EFCore.Data;
|
||||
using BlueLaminate.EFCore.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace BlueLaminate.Core.Tradeups;
|
||||
|
||||
/// <summary>
|
||||
/// Derives the <see cref="TradeupGraph"/> from the synced catalogue
|
||||
/// (<see cref="Skin"/> + <see cref="Collection"/> + <see cref="Weapon"/>) with no
|
||||
/// pricing, no listings, and no new tables. A single query loads the catalogue; the
|
||||
/// graph is assembled in memory. Because the catalogue changes only when
|
||||
/// <c>SkinSyncService</c> runs (monthly), callers can build this once and cache it for
|
||||
/// the process lifetime.
|
||||
/// </summary>
|
||||
public sealed class TradeupGraphBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Pseudo-collections that are not real tradeup collections. "Limited Edition Item"
|
||||
/// holds armory/non-tradeable skins (e.g. AK-47 Aphrodite) that must never be treated
|
||||
/// as tradeup inputs or outputs.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> SkipCollectionNames = new(StringComparer.Ordinal)
|
||||
{
|
||||
"Limited Edition Item",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Weapon categories that carry weapon-tier rarities but are never weapon tradeup
|
||||
/// outputs: knives are stored as <c>Covert</c> and gloves as <c>Extraordinary</c>.
|
||||
/// Excluded defensively even though the rarity/float filters already drop most.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ExcludedWeaponTypes = new(StringComparer.Ordinal)
|
||||
{
|
||||
"Knives",
|
||||
"Gloves",
|
||||
};
|
||||
|
||||
private const string CollectionType = "Collection";
|
||||
|
||||
private readonly SkinTrackerDbContext _db;
|
||||
private readonly ILogger<TradeupGraphBuilder> _logger;
|
||||
|
||||
public TradeupGraphBuilder(SkinTrackerDbContext db, ILogger<TradeupGraphBuilder> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<TradeupGraph> BuildAsync(CancellationToken ct = default)
|
||||
{
|
||||
var skins = await _db.Skins
|
||||
.Include(s => s.Collections)
|
||||
.Include(s => s.Weapon)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(ct);
|
||||
|
||||
// collectionId -> (collection, rarity -> eligible skins). Only Type='Collection'
|
||||
// sources outside the skip-list participate; one skin can be filed under several
|
||||
// collections, so it is added to each.
|
||||
var byCollection = new Dictionary<int, CollectionBucket>();
|
||||
|
||||
foreach (var skin in skins)
|
||||
{
|
||||
if (!IsEligibleSkin(skin, out var rarity))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var collection in skin.Collections)
|
||||
{
|
||||
if (collection.Type != CollectionType || SkipCollectionNames.Contains(collection.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!byCollection.TryGetValue(collection.Id, out var bucket))
|
||||
{
|
||||
bucket = new CollectionBucket(collection);
|
||||
byCollection[collection.Id] = bucket;
|
||||
}
|
||||
|
||||
bucket.Add(rarity, skin);
|
||||
}
|
||||
}
|
||||
|
||||
var groups = new List<TradeupInputGroup>();
|
||||
|
||||
foreach (var bucket in byCollection.Values)
|
||||
{
|
||||
// Tiers that actually have eligible skins in this collection, ascending.
|
||||
var presentTiers = bucket.SkinsByRarity.Keys.OrderBy(r => (int)r).ToList();
|
||||
|
||||
foreach (var inputRarity in presentTiers)
|
||||
{
|
||||
// Covert is the v1 ceiling: it can be an output but never a 10-input source.
|
||||
if (inputRarity >= WeaponRarity.Covert)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var outputRarity = NextPresentTier(presentTiers, inputRarity);
|
||||
if (outputRarity is null)
|
||||
{
|
||||
// Collection tops out at this tier (or caps below Covert) — no tradeup.
|
||||
continue;
|
||||
}
|
||||
|
||||
var inputSkinIds = bucket.SkinsByRarity[inputRarity]
|
||||
.Select(s => s.Id)
|
||||
.ToList();
|
||||
|
||||
var outputSkins = bucket.SkinsByRarity[outputRarity.Value]
|
||||
.Select(s => new TradeupOutputSkin(
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.FloatMin!.Value,
|
||||
s.FloatMax!.Value,
|
||||
s.StatTrakAvailable))
|
||||
.ToList();
|
||||
|
||||
groups.Add(new TradeupInputGroup(
|
||||
bucket.Collection.Id,
|
||||
bucket.Collection.Name,
|
||||
inputRarity,
|
||||
outputRarity.Value,
|
||||
inputSkinIds,
|
||||
outputSkins));
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Built tradeup graph: {Groups} input groups across {Collections} collections "
|
||||
+ "from {Skins} catalogue skins.",
|
||||
groups.Count, byCollection.Count, skins.Count);
|
||||
|
||||
return new TradeupGraph(groups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A skin is eligible to appear in the graph (as input or output) iff it parses to a
|
||||
/// weapon tier, is not a knife/glove, and has both float bounds. The skip-list and
|
||||
/// Type='Collection' filter are applied per-collection by the caller.
|
||||
/// </summary>
|
||||
private static bool IsEligibleSkin(Skin skin, out WeaponRarity rarity)
|
||||
{
|
||||
rarity = default;
|
||||
|
||||
if (skin.FloatMin is null || skin.FloatMax is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ExcludedWeaponTypes.Contains(skin.Weapon.Type))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Throws on an unknown literal (catalogue rename); returns false for the
|
||||
// non-weapon rarities (Contraband/Extraordinary).
|
||||
return WeaponRarityExtensions.TryParse(skin.Rarity, out rarity);
|
||||
}
|
||||
|
||||
/// <summary>The smallest present tier strictly greater than <paramref name="tier"/>, or null.</summary>
|
||||
private static WeaponRarity? NextPresentTier(List<WeaponRarity> presentTiers, WeaponRarity tier)
|
||||
{
|
||||
foreach (var candidate in presentTiers)
|
||||
{
|
||||
if (candidate > tier)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed class CollectionBucket
|
||||
{
|
||||
public CollectionBucket(Collection collection) => Collection = collection;
|
||||
|
||||
public Collection Collection { get; }
|
||||
|
||||
public Dictionary<WeaponRarity, List<Skin>> SkinsByRarity { get; } = new();
|
||||
|
||||
public void Add(WeaponRarity rarity, Skin skin)
|
||||
{
|
||||
if (!SkinsByRarity.TryGetValue(rarity, out var list))
|
||||
{
|
||||
list = new List<Skin>();
|
||||
SkinsByRarity[rarity] = list;
|
||||
}
|
||||
|
||||
list.Add(skin);
|
||||
}
|
||||
}
|
||||
}
|
||||
203
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupListingData.cs
Normal file
203
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupListingData.cs
Normal file
@@ -0,0 +1,203 @@
|
||||
namespace BlueLaminate.Core.Tradeups;
|
||||
|
||||
/// <summary>One active listing reduced to the fields the finder needs.</summary>
|
||||
public readonly record struct TradeupListingRow(
|
||||
int SkinId,
|
||||
string MarketHashName,
|
||||
string Marketplace,
|
||||
string? InspectLink,
|
||||
string ExternalId,
|
||||
bool IsStatTrak,
|
||||
bool IsSouvenir,
|
||||
decimal? FloatValue,
|
||||
decimal Price);
|
||||
|
||||
/// <summary>
|
||||
/// A purchasable input copy: a real listing the engine can pick into a contract. Carries
|
||||
/// the market-hash name, marketplace, and the source listing's inspect link + external id
|
||||
/// so the output is an actionable buy list that points at the exact listing.
|
||||
/// </summary>
|
||||
public readonly record struct InputListing(
|
||||
int SkinId,
|
||||
string MarketHashName,
|
||||
string Marketplace,
|
||||
string? InspectLink,
|
||||
string ExternalId,
|
||||
decimal FloatValue,
|
||||
decimal Price);
|
||||
|
||||
/// <summary>Lowest active ask and listing count for one (skin, ST, wear band).</summary>
|
||||
public readonly record struct BandPrice(decimal LowestAsk, int Liquidity);
|
||||
|
||||
/// <summary>Where a resolved output price came from.</summary>
|
||||
public enum OutputPriceBasis
|
||||
{
|
||||
/// <summary>Nothing comparable is listed anywhere — unpriceable.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>The wear band the output lands in, which is liquid enough to trust.</summary>
|
||||
Band,
|
||||
|
||||
/// <summary>
|
||||
/// The band was too thin to trust, so this is the skin's cheapest comparable listing
|
||||
/// across any wear — a conservative proxy. A live CSFloat lookup should refine it.
|
||||
/// </summary>
|
||||
Floor,
|
||||
}
|
||||
|
||||
/// <summary>An output price plus how thin its own band was and where the number came from.</summary>
|
||||
public readonly record struct ResolvedOutputPrice(decimal? LowestAsk, int BandLiquidity, OutputPriceBasis Basis);
|
||||
|
||||
/// <summary>
|
||||
/// The listing-side inputs to the finder (Phase B/C data), built once from a single scan
|
||||
/// of active listings:
|
||||
/// <list type="bullet">
|
||||
/// <item>input pools — every floated input copy, split into the disjoint non-ST and ST
|
||||
/// universes (non-ST = normal ∪ souvenir);</item>
|
||||
/// <item>an <see cref="OutputPriceBook"/> — the lowest non-souvenir ask per (skin, ST,
|
||||
/// wear band), used to value a produced output at its computed float.</item>
|
||||
/// </list>
|
||||
/// Floatless listings are dropped: an input copy with no float can't be normalised, and
|
||||
/// an output listing with no float can't be placed in a wear band.
|
||||
/// </summary>
|
||||
public sealed class TradeupListingData
|
||||
{
|
||||
private readonly IReadOnlyDictionary<int, List<InputListing>> _nonStatTrakInputs;
|
||||
private readonly IReadOnlyDictionary<int, List<InputListing>> _statTrakInputs;
|
||||
private readonly OutputPriceBook _outputPrices;
|
||||
|
||||
private TradeupListingData(
|
||||
IReadOnlyDictionary<int, List<InputListing>> nonStatTrakInputs,
|
||||
IReadOnlyDictionary<int, List<InputListing>> statTrakInputs,
|
||||
OutputPriceBook outputPrices)
|
||||
{
|
||||
_nonStatTrakInputs = nonStatTrakInputs;
|
||||
_statTrakInputs = statTrakInputs;
|
||||
_outputPrices = outputPrices;
|
||||
}
|
||||
|
||||
public OutputPriceBook OutputPrices => _outputPrices;
|
||||
|
||||
/// <summary>All purchasable input copies of <paramref name="skinId"/> in the given universe.</summary>
|
||||
public IReadOnlyList<InputListing> InputsFor(int skinId, bool statTrak)
|
||||
{
|
||||
var pool = statTrak ? _statTrakInputs : _nonStatTrakInputs;
|
||||
return pool.TryGetValue(skinId, out var listings) ? listings : Array.Empty<InputListing>();
|
||||
}
|
||||
|
||||
public static TradeupListingData Build(IEnumerable<TradeupListingRow> rows)
|
||||
{
|
||||
var nonStInputs = new Dictionary<int, List<InputListing>>();
|
||||
var stInputs = new Dictionary<int, List<InputListing>>();
|
||||
var book = new OutputPriceBook();
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (row.FloatValue is not { } floatValue || row.Price <= 0m)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Input side: a copy can be used as input regardless of souvenir flag; only the
|
||||
// ST flag splits the two disjoint universes.
|
||||
var inputs = row.IsStatTrak ? stInputs : nonStInputs;
|
||||
if (!inputs.TryGetValue(row.SkinId, out var list))
|
||||
{
|
||||
list = new List<InputListing>();
|
||||
inputs[row.SkinId] = list;
|
||||
}
|
||||
|
||||
list.Add(new InputListing(
|
||||
row.SkinId, row.MarketHashName, row.Marketplace,
|
||||
row.InspectLink, row.ExternalId, floatValue, row.Price));
|
||||
|
||||
// Output side: a tradeup never produces a souvenir, so souvenir listings don't
|
||||
// price an output.
|
||||
if (!row.IsSouvenir)
|
||||
{
|
||||
book.Add(row.SkinId, row.IsStatTrak, WearBands.FromFloat(floatValue), row.Price);
|
||||
}
|
||||
}
|
||||
|
||||
return new TradeupListingData(nonStInputs, stInputs, book);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase B artifact: the lowest active ask (and liquidity count) for each
|
||||
/// (skin, StatTrak, wear band). A produced output is valued by looking up the band its
|
||||
/// computed float falls into — a conservative, listing-grounded estimate that never
|
||||
/// invents a premium for a float no one is currently selling near.
|
||||
/// </summary>
|
||||
public sealed class OutputPriceBook
|
||||
{
|
||||
private readonly Dictionary<(int SkinId, bool StatTrak), Dictionary<WearBand, MutableBand>> _bands = new();
|
||||
// Skin's cheapest comparable listing across ALL wears — the conservative floor used when a
|
||||
// single band is too thin to trust.
|
||||
private readonly Dictionary<(int SkinId, bool StatTrak), MutableBand> _floor = new();
|
||||
|
||||
internal void Add(int skinId, bool statTrak, WearBand band, decimal price)
|
||||
{
|
||||
var key = (skinId, statTrak);
|
||||
if (!_bands.TryGetValue(key, out var byBand))
|
||||
{
|
||||
byBand = new Dictionary<WearBand, MutableBand>();
|
||||
_bands[key] = byBand;
|
||||
}
|
||||
|
||||
byBand[band] = byBand.TryGetValue(band, out var entry)
|
||||
? new MutableBand(Math.Min(entry.LowestAsk, price), entry.Liquidity + 1)
|
||||
: new MutableBand(price, 1);
|
||||
|
||||
_floor[key] = _floor.TryGetValue(key, out var f)
|
||||
? new MutableBand(Math.Min(f.LowestAsk, price), f.Liquidity + 1)
|
||||
: new MutableBand(price, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The lowest ask for the given skin/ST in the wear band that <paramref name="outputFloat"/>
|
||||
/// lands in, or null when nothing comparable is listed.
|
||||
/// </summary>
|
||||
public BandPrice? PriceAt(int skinId, bool statTrak, decimal outputFloat)
|
||||
{
|
||||
if (_bands.TryGetValue((skinId, statTrak), out var byBand)
|
||||
&& byBand.TryGetValue(WearBands.FromFloat(outputFloat), out var entry))
|
||||
{
|
||||
return new BandPrice(entry.LowestAsk, entry.Liquidity);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an output's value: the band price when the band is liquid enough
|
||||
/// (≥ <paramref name="thinThreshold"/> listings); otherwise the skin's overall floor, since
|
||||
/// a one- or two-listing band is dominated by outliers (e.g. a lone over-priced FN sitting
|
||||
/// just past a wear boundary). The reported <see cref="ResolvedOutputPrice.BandLiquidity"/>
|
||||
/// is always the band's own count, so a thin result still triggers live CSFloat re-pricing.
|
||||
/// </summary>
|
||||
public ResolvedOutputPrice Resolve(int skinId, bool statTrak, decimal outputFloat, int thinThreshold)
|
||||
{
|
||||
var key = (skinId, statTrak);
|
||||
var bandLiquidity = 0;
|
||||
if (_bands.TryGetValue(key, out var byBand)
|
||||
&& byBand.TryGetValue(WearBands.FromFloat(outputFloat), out var entry))
|
||||
{
|
||||
bandLiquidity = entry.Liquidity;
|
||||
if (entry.Liquidity >= thinThreshold)
|
||||
{
|
||||
return new ResolvedOutputPrice(entry.LowestAsk, bandLiquidity, OutputPriceBasis.Band);
|
||||
}
|
||||
}
|
||||
|
||||
// Thin (or empty) band — fall back to the skin's cheapest comparable listing.
|
||||
if (_floor.TryGetValue(key, out var floor))
|
||||
{
|
||||
return new ResolvedOutputPrice(floor.LowestAsk, bandLiquidity, OutputPriceBasis.Floor);
|
||||
}
|
||||
|
||||
return new ResolvedOutputPrice(null, bandLiquidity, OutputPriceBasis.None);
|
||||
}
|
||||
|
||||
private readonly record struct MutableBand(decimal LowestAsk, int Liquidity);
|
||||
}
|
||||
38
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupMath.cs
Normal file
38
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupMath.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace BlueLaminate.Core.Tradeups;
|
||||
|
||||
/// <summary>
|
||||
/// The exact float arithmetic of a CS2 tradeup. Kept pure and dependency-free so it
|
||||
/// can be unit-tested in isolation and reused verbatim by any frontend.
|
||||
/// <para>
|
||||
/// The contract: each input float is normalised to its own skin's range FIRST, those
|
||||
/// fractions are averaged, and the average is mapped onto the OUTPUT skin's range. The
|
||||
/// output float depends only on the average input fraction — a single scalar — which
|
||||
/// is what makes the search tractable (see the engine design notes).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class TradeupMath
|
||||
{
|
||||
/// <summary>
|
||||
/// Normalises an input float to the fraction of its own skin's wear range:
|
||||
/// <c>(value − min) / (max − min)</c>, clamped to [0,1]. A zero-width range
|
||||
/// (min == max) has no meaningful fraction and yields 0.
|
||||
/// </summary>
|
||||
public static decimal NormalizedFraction(decimal floatValue, decimal skinFloatMin, decimal skinFloatMax)
|
||||
{
|
||||
var span = skinFloatMax - skinFloatMin;
|
||||
if (span <= 0m)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
var fraction = (floatValue - skinFloatMin) / span;
|
||||
return Math.Clamp(fraction, 0m, 1m);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an average input fraction onto an output skin's wear range to get the exact
|
||||
/// float the tradeup would produce: <c>avgFraction × (max − min) + min</c>.
|
||||
/// </summary>
|
||||
public static decimal OutputFloat(decimal averageFraction, decimal outputFloatMin, decimal outputFloatMax)
|
||||
=> averageFraction * (outputFloatMax - outputFloatMin) + outputFloatMin;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
75
BlueLaminate/BlueLaminate.Core/Tradeups/WeaponRarity.cs
Normal file
75
BlueLaminate/BlueLaminate.Core/Tradeups/WeaponRarity.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
namespace BlueLaminate.Core.Tradeups;
|
||||
|
||||
/// <summary>
|
||||
/// The ordered weapon-skin rarity tiers that participate in a 10-input tradeup.
|
||||
/// The ordinal value IS the tier order: a tradeup consumes 10 inputs of tier T and
|
||||
/// produces an output at the next tier present in the same collection.
|
||||
/// <para>
|
||||
/// Only the six weapon tiers are modelled. The catalogue also carries
|
||||
/// <c>Contraband</c> (the Howl) and <c>Extraordinary</c> (gloves), and knives are
|
||||
/// stored as <c>Covert</c>; none of those are weapon tradeup tiers, so
|
||||
/// <see cref="TryParse"/> reports them as "not a weapon tier" rather than mapping
|
||||
/// them. See the eligibility rules in <see cref="TradeupGraphBuilder"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public enum WeaponRarity
|
||||
{
|
||||
Consumer = 1,
|
||||
Industrial = 2,
|
||||
MilSpec = 3,
|
||||
Restricted = 4,
|
||||
Classified = 5,
|
||||
Covert = 6,
|
||||
}
|
||||
|
||||
public static class WeaponRarityExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a <c>skins.rarity</c> string literal to its weapon tier.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <c>true</c> with <paramref name="rarity"/> set when the literal is one of the
|
||||
/// six weapon tiers; <c>false</c> for <c>Contraband</c>/<c>Extraordinary</c>
|
||||
/// (valid catalogue rarities that are not weapon tradeup tiers).
|
||||
/// </returns>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// The literal is none of the known catalogue rarities. Thrown deliberately so a
|
||||
/// catalogue rename surfaces loudly instead of silently dropping a whole tier.
|
||||
/// </exception>
|
||||
public static bool TryParse(string rarity, out WeaponRarity result)
|
||||
{
|
||||
switch (rarity)
|
||||
{
|
||||
case "Consumer Grade":
|
||||
result = WeaponRarity.Consumer;
|
||||
return true;
|
||||
case "Industrial Grade":
|
||||
result = WeaponRarity.Industrial;
|
||||
return true;
|
||||
case "Mil-Spec Grade":
|
||||
result = WeaponRarity.MilSpec;
|
||||
return true;
|
||||
case "Restricted":
|
||||
result = WeaponRarity.Restricted;
|
||||
return true;
|
||||
case "Classified":
|
||||
result = WeaponRarity.Classified;
|
||||
return true;
|
||||
case "Covert":
|
||||
result = WeaponRarity.Covert;
|
||||
return true;
|
||||
|
||||
// Known, valid catalogue rarities that are not weapon tradeup tiers.
|
||||
case "Contraband": // The Howl
|
||||
case "Extraordinary": // Gloves
|
||||
result = default;
|
||||
return false;
|
||||
|
||||
default:
|
||||
throw new ArgumentException(
|
||||
$"Unknown skin rarity literal '{rarity}'. The catalogue may have renamed a "
|
||||
+ "rarity; update WeaponRarityExtensions.TryParse so a tier isn't silently dropped.",
|
||||
nameof(rarity));
|
||||
}
|
||||
}
|
||||
}
|
||||
58
BlueLaminate/BlueLaminate.Core/Tradeups/WearBand.cs
Normal file
58
BlueLaminate/BlueLaminate.Core/Tradeups/WearBand.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
namespace BlueLaminate.Core.Tradeups;
|
||||
|
||||
/// <summary>
|
||||
/// The five CS2 wear bands, defined by absolute float thresholds (independent of any
|
||||
/// individual skin's float range). A produced item's band — and therefore which
|
||||
/// listings it competes with — is read straight off its absolute float.
|
||||
/// </summary>
|
||||
public enum WearBand
|
||||
{
|
||||
FactoryNew,
|
||||
MinimalWear,
|
||||
FieldTested,
|
||||
WellWorn,
|
||||
BattleScarred,
|
||||
}
|
||||
|
||||
public static class WearBands
|
||||
{
|
||||
// Upper-exclusive boundaries: FN [0,0.07) MW [0.07,0.15) FT [0.15,0.38)
|
||||
// WW [0.38,0.45) BS [0.45,1.0].
|
||||
public const decimal MinimalWearFloor = 0.07m;
|
||||
public const decimal FieldTestedFloor = 0.15m;
|
||||
public const decimal WellWornFloor = 0.38m;
|
||||
public const decimal BattleScarredFloor = 0.45m;
|
||||
|
||||
/// <summary>The wear band an absolute float value falls into.</summary>
|
||||
public static WearBand FromFloat(decimal floatValue) => floatValue switch
|
||||
{
|
||||
< MinimalWearFloor => WearBand.FactoryNew,
|
||||
< FieldTestedFloor => WearBand.MinimalWear,
|
||||
< WellWornFloor => WearBand.FieldTested,
|
||||
< BattleScarredFloor => WearBand.WellWorn,
|
||||
_ => WearBand.BattleScarred,
|
||||
};
|
||||
|
||||
/// <summary>The absolute float range [min, max) that defines this band — used to scope a
|
||||
/// CSFloat query to the band the produced output lands in.</summary>
|
||||
public static (decimal Min, decimal Max) Bounds(this WearBand band) => band switch
|
||||
{
|
||||
WearBand.FactoryNew => (0.00m, MinimalWearFloor),
|
||||
WearBand.MinimalWear => (MinimalWearFloor, FieldTestedFloor),
|
||||
WearBand.FieldTested => (FieldTestedFloor, WellWornFloor),
|
||||
WearBand.WellWorn => (WellWornFloor, BattleScarredFloor),
|
||||
WearBand.BattleScarred => (BattleScarredFloor, 1.00m),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(band), band, null),
|
||||
};
|
||||
|
||||
/// <summary>The full wear name as it appears in listing data ("Factory New", …).</summary>
|
||||
public static string ToName(this WearBand band) => band switch
|
||||
{
|
||||
WearBand.FactoryNew => "Factory New",
|
||||
WearBand.MinimalWear => "Minimal Wear",
|
||||
WearBand.FieldTested => "Field-Tested",
|
||||
WearBand.WellWorn => "Well-Worn",
|
||||
WearBand.BattleScarred => "Battle-Scarred",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(band), band, null),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BlueLaminate.EFCore.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// cs.money's <c>pricing.default</c> is the post-discount sticker price, which understates
|
||||
/// what a copy actually costs to buy — so tradeup input costs (and cross-market price
|
||||
/// comparisons) read low and conjure phantom profit. Switch the csmoney arm of the
|
||||
/// cross-market view to <c>price_before_discount</c>, falling back to <c>price</c> when no
|
||||
/// discount was recorded. View-only change; the underlying table keeps both columns.
|
||||
/// </summary>
|
||||
public partial class UseCsMoneyPriceBeforeDiscountInMarketView : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
CREATE OR REPLACE VIEW skintracker.market_listings AS
|
||||
SELECT
|
||||
'csfloat'::text AS marketplace,
|
||||
l.cs_float_listing_id AS external_id,
|
||||
l.skin_id AS skin_id,
|
||||
l.condition_id AS condition_id,
|
||||
l.skin_instance_id AS skin_instance_id,
|
||||
l.market_hash_name AS market_hash_name,
|
||||
l.wear_name AS wear,
|
||||
l.float_value AS float_value,
|
||||
l.paint_seed AS paint_seed,
|
||||
l.is_stat_trak AS is_stat_trak,
|
||||
l.is_souvenir AS is_souvenir,
|
||||
l.sticker_count AS sticker_count,
|
||||
l.price AS price,
|
||||
l.currency AS currency,
|
||||
l.inspect_link AS inspect_link,
|
||||
l.asset_id AS asset_id,
|
||||
l.status AS status,
|
||||
l.first_seen_at AS first_seen_at,
|
||||
l.last_seen_at AS last_seen_at,
|
||||
l.removed_at AS removed_at
|
||||
FROM skintracker.listings l
|
||||
UNION ALL
|
||||
SELECT
|
||||
'csmoney'::text,
|
||||
c.sell_order_id::text,
|
||||
c.skin_id,
|
||||
c.condition_id,
|
||||
c.skin_instance_id,
|
||||
c.market_hash_name,
|
||||
CASE lower(c.quality)
|
||||
WHEN 'fn' THEN 'Factory New'
|
||||
WHEN 'mw' THEN 'Minimal Wear'
|
||||
WHEN 'ft' THEN 'Field-Tested'
|
||||
WHEN 'ww' THEN 'Well-Worn'
|
||||
WHEN 'bs' THEN 'Battle-Scarred'
|
||||
ELSE c.quality
|
||||
END,
|
||||
c.float_value,
|
||||
c.paint_seed,
|
||||
c.is_stat_trak,
|
||||
c.is_souvenir,
|
||||
c.sticker_count,
|
||||
-- Undiscounted price is the real cost to buy; fall back to price when no
|
||||
-- discount was recorded.
|
||||
COALESCE(c.price_before_discount, c.price),
|
||||
c.currency,
|
||||
c.inspect_link,
|
||||
c.asset_id,
|
||||
c.status,
|
||||
c.first_seen_at,
|
||||
c.last_seen_at,
|
||||
c.removed_at
|
||||
FROM skintracker.cs_money_listings c
|
||||
UNION ALL
|
||||
SELECT
|
||||
'skinland'::text,
|
||||
s.listing_id::text,
|
||||
s.skin_id,
|
||||
s.condition_id,
|
||||
NULL::integer,
|
||||
s.market_hash_name,
|
||||
sc.condition,
|
||||
s.float_value,
|
||||
NULL::integer,
|
||||
s.is_stat_trak,
|
||||
s.is_souvenir,
|
||||
s.sticker_count,
|
||||
s.price,
|
||||
s.currency,
|
||||
s.inspect_link,
|
||||
NULL::text,
|
||||
s.status,
|
||||
s.first_seen_at,
|
||||
s.last_seen_at,
|
||||
s.removed_at
|
||||
FROM skintracker.skin_land_listings s
|
||||
LEFT JOIN skintracker.skin_conditions sc ON sc.id = s.condition_id;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Restore the csmoney arm to the post-discount pricing.default price.
|
||||
migrationBuilder.Sql("""
|
||||
CREATE OR REPLACE VIEW skintracker.market_listings AS
|
||||
SELECT
|
||||
'csfloat'::text AS marketplace,
|
||||
l.cs_float_listing_id AS external_id,
|
||||
l.skin_id AS skin_id,
|
||||
l.condition_id AS condition_id,
|
||||
l.skin_instance_id AS skin_instance_id,
|
||||
l.market_hash_name AS market_hash_name,
|
||||
l.wear_name AS wear,
|
||||
l.float_value AS float_value,
|
||||
l.paint_seed AS paint_seed,
|
||||
l.is_stat_trak AS is_stat_trak,
|
||||
l.is_souvenir AS is_souvenir,
|
||||
l.sticker_count AS sticker_count,
|
||||
l.price AS price,
|
||||
l.currency AS currency,
|
||||
l.inspect_link AS inspect_link,
|
||||
l.asset_id AS asset_id,
|
||||
l.status AS status,
|
||||
l.first_seen_at AS first_seen_at,
|
||||
l.last_seen_at AS last_seen_at,
|
||||
l.removed_at AS removed_at
|
||||
FROM skintracker.listings l
|
||||
UNION ALL
|
||||
SELECT
|
||||
'csmoney'::text,
|
||||
c.sell_order_id::text,
|
||||
c.skin_id,
|
||||
c.condition_id,
|
||||
c.skin_instance_id,
|
||||
c.market_hash_name,
|
||||
CASE lower(c.quality)
|
||||
WHEN 'fn' THEN 'Factory New'
|
||||
WHEN 'mw' THEN 'Minimal Wear'
|
||||
WHEN 'ft' THEN 'Field-Tested'
|
||||
WHEN 'ww' THEN 'Well-Worn'
|
||||
WHEN 'bs' THEN 'Battle-Scarred'
|
||||
ELSE c.quality
|
||||
END,
|
||||
c.float_value,
|
||||
c.paint_seed,
|
||||
c.is_stat_trak,
|
||||
c.is_souvenir,
|
||||
c.sticker_count,
|
||||
c.price,
|
||||
c.currency,
|
||||
c.inspect_link,
|
||||
c.asset_id,
|
||||
c.status,
|
||||
c.first_seen_at,
|
||||
c.last_seen_at,
|
||||
c.removed_at
|
||||
FROM skintracker.cs_money_listings c
|
||||
UNION ALL
|
||||
SELECT
|
||||
'skinland'::text,
|
||||
s.listing_id::text,
|
||||
s.skin_id,
|
||||
s.condition_id,
|
||||
NULL::integer,
|
||||
s.market_hash_name,
|
||||
sc.condition,
|
||||
s.float_value,
|
||||
NULL::integer,
|
||||
s.is_stat_trak,
|
||||
s.is_souvenir,
|
||||
s.sticker_count,
|
||||
s.price,
|
||||
s.currency,
|
||||
s.inspect_link,
|
||||
NULL::text,
|
||||
s.status,
|
||||
s.first_seen_at,
|
||||
s.last_seen_at,
|
||||
s.removed_at
|
||||
FROM skintracker.skin_land_listings s
|
||||
LEFT JOIN skintracker.skin_conditions sc ON sc.id = s.condition_id;
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
1347
BlueLaminate/BlueLaminate.EFCore/Migrations/20260602034328_UseCsMoneyComputedPriceInMarketView.Designer.cs
generated
Normal file
1347
BlueLaminate/BlueLaminate.EFCore/Migrations/20260602034328_UseCsMoneyComputedPriceInMarketView.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BlueLaminate.EFCore.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Spot-checking tradeups against cs.money's live site showed <c>pricing.computed</c> (the
|
||||
/// reference/market price) — not the post-discount <c>price</c> nor <c>price_before_discount</c>
|
||||
/// — is the true value of a copy. Switch the csmoney arm of the cross-market view to
|
||||
/// <c>computed_price</c>, falling back to price_before_discount then price when it's null.
|
||||
/// View-only change; the underlying table keeps all three price columns.
|
||||
/// </summary>
|
||||
public partial class UseCsMoneyComputedPriceInMarketView : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
CREATE OR REPLACE VIEW skintracker.market_listings AS
|
||||
SELECT
|
||||
'csfloat'::text AS marketplace,
|
||||
l.cs_float_listing_id AS external_id,
|
||||
l.skin_id AS skin_id,
|
||||
l.condition_id AS condition_id,
|
||||
l.skin_instance_id AS skin_instance_id,
|
||||
l.market_hash_name AS market_hash_name,
|
||||
l.wear_name AS wear,
|
||||
l.float_value AS float_value,
|
||||
l.paint_seed AS paint_seed,
|
||||
l.is_stat_trak AS is_stat_trak,
|
||||
l.is_souvenir AS is_souvenir,
|
||||
l.sticker_count AS sticker_count,
|
||||
l.price AS price,
|
||||
l.currency AS currency,
|
||||
l.inspect_link AS inspect_link,
|
||||
l.asset_id AS asset_id,
|
||||
l.status AS status,
|
||||
l.first_seen_at AS first_seen_at,
|
||||
l.last_seen_at AS last_seen_at,
|
||||
l.removed_at AS removed_at
|
||||
FROM skintracker.listings l
|
||||
UNION ALL
|
||||
SELECT
|
||||
'csmoney'::text,
|
||||
c.sell_order_id::text,
|
||||
c.skin_id,
|
||||
c.condition_id,
|
||||
c.skin_instance_id,
|
||||
c.market_hash_name,
|
||||
CASE lower(c.quality)
|
||||
WHEN 'fn' THEN 'Factory New'
|
||||
WHEN 'mw' THEN 'Minimal Wear'
|
||||
WHEN 'ft' THEN 'Field-Tested'
|
||||
WHEN 'ww' THEN 'Well-Worn'
|
||||
WHEN 'bs' THEN 'Battle-Scarred'
|
||||
ELSE c.quality
|
||||
END,
|
||||
c.float_value,
|
||||
c.paint_seed,
|
||||
c.is_stat_trak,
|
||||
c.is_souvenir,
|
||||
c.sticker_count,
|
||||
-- computed_price is the true market value; fall back when it's missing.
|
||||
COALESCE(c.computed_price, c.price_before_discount, c.price),
|
||||
c.currency,
|
||||
c.inspect_link,
|
||||
c.asset_id,
|
||||
c.status,
|
||||
c.first_seen_at,
|
||||
c.last_seen_at,
|
||||
c.removed_at
|
||||
FROM skintracker.cs_money_listings c
|
||||
UNION ALL
|
||||
SELECT
|
||||
'skinland'::text,
|
||||
s.listing_id::text,
|
||||
s.skin_id,
|
||||
s.condition_id,
|
||||
NULL::integer,
|
||||
s.market_hash_name,
|
||||
sc.condition,
|
||||
s.float_value,
|
||||
NULL::integer,
|
||||
s.is_stat_trak,
|
||||
s.is_souvenir,
|
||||
s.sticker_count,
|
||||
s.price,
|
||||
s.currency,
|
||||
s.inspect_link,
|
||||
NULL::text,
|
||||
s.status,
|
||||
s.first_seen_at,
|
||||
s.last_seen_at,
|
||||
s.removed_at
|
||||
FROM skintracker.skin_land_listings s
|
||||
LEFT JOIN skintracker.skin_conditions sc ON sc.id = s.condition_id;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Restore the csmoney arm to price_before_discount (with price fallback).
|
||||
migrationBuilder.Sql("""
|
||||
CREATE OR REPLACE VIEW skintracker.market_listings AS
|
||||
SELECT
|
||||
'csfloat'::text AS marketplace,
|
||||
l.cs_float_listing_id AS external_id,
|
||||
l.skin_id AS skin_id,
|
||||
l.condition_id AS condition_id,
|
||||
l.skin_instance_id AS skin_instance_id,
|
||||
l.market_hash_name AS market_hash_name,
|
||||
l.wear_name AS wear,
|
||||
l.float_value AS float_value,
|
||||
l.paint_seed AS paint_seed,
|
||||
l.is_stat_trak AS is_stat_trak,
|
||||
l.is_souvenir AS is_souvenir,
|
||||
l.sticker_count AS sticker_count,
|
||||
l.price AS price,
|
||||
l.currency AS currency,
|
||||
l.inspect_link AS inspect_link,
|
||||
l.asset_id AS asset_id,
|
||||
l.status AS status,
|
||||
l.first_seen_at AS first_seen_at,
|
||||
l.last_seen_at AS last_seen_at,
|
||||
l.removed_at AS removed_at
|
||||
FROM skintracker.listings l
|
||||
UNION ALL
|
||||
SELECT
|
||||
'csmoney'::text,
|
||||
c.sell_order_id::text,
|
||||
c.skin_id,
|
||||
c.condition_id,
|
||||
c.skin_instance_id,
|
||||
c.market_hash_name,
|
||||
CASE lower(c.quality)
|
||||
WHEN 'fn' THEN 'Factory New'
|
||||
WHEN 'mw' THEN 'Minimal Wear'
|
||||
WHEN 'ft' THEN 'Field-Tested'
|
||||
WHEN 'ww' THEN 'Well-Worn'
|
||||
WHEN 'bs' THEN 'Battle-Scarred'
|
||||
ELSE c.quality
|
||||
END,
|
||||
c.float_value,
|
||||
c.paint_seed,
|
||||
c.is_stat_trak,
|
||||
c.is_souvenir,
|
||||
c.sticker_count,
|
||||
COALESCE(c.price_before_discount, c.price),
|
||||
c.currency,
|
||||
c.inspect_link,
|
||||
c.asset_id,
|
||||
c.status,
|
||||
c.first_seen_at,
|
||||
c.last_seen_at,
|
||||
c.removed_at
|
||||
FROM skintracker.cs_money_listings c
|
||||
UNION ALL
|
||||
SELECT
|
||||
'skinland'::text,
|
||||
s.listing_id::text,
|
||||
s.skin_id,
|
||||
s.condition_id,
|
||||
NULL::integer,
|
||||
s.market_hash_name,
|
||||
sc.condition,
|
||||
s.float_value,
|
||||
NULL::integer,
|
||||
s.is_stat_trak,
|
||||
s.is_souvenir,
|
||||
s.sticker_count,
|
||||
s.price,
|
||||
s.currency,
|
||||
s.inspect_link,
|
||||
NULL::text,
|
||||
s.status,
|
||||
s.first_seen_at,
|
||||
s.last_seen_at,
|
||||
s.removed_at
|
||||
FROM skintracker.skin_land_listings s
|
||||
LEFT JOIN skintracker.skin_conditions sc ON sc.id = s.condition_id;
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
25
BlueLaminate/BlueLaminate.Tests/BlueLaminate.Tests.csproj
Normal file
25
BlueLaminate/BlueLaminate.Tests/BlueLaminate.Tests.csproj
Normal file
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\BlueLaminate.Core\BlueLaminate.Core.csproj" />
|
||||
<ProjectReference Include="..\BlueLaminate.EFCore\BlueLaminate.EFCore.csproj" />
|
||||
<ProjectReference Include="..\BlueLaminate.Cli\BlueLaminate.Cli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
||||
<PackageReference Include="Spectre.Console.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,205 @@
|
||||
using BlueLaminate.Core.Tradeups;
|
||||
using BlueLaminate.EFCore.Data;
|
||||
using BlueLaminate.EFCore.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace BlueLaminate.Tests.Tradeups;
|
||||
|
||||
/// <summary>
|
||||
/// Graph-derivation rules verified against a synthetic in-memory catalogue — never the
|
||||
/// live database (see the no-live-DB-perturbation rule). Each test seeds the minimal
|
||||
/// catalogue shape it needs.
|
||||
/// </summary>
|
||||
public class TradeupGraphBuilderTests
|
||||
{
|
||||
private static SkinTrackerDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<SkinTrackerDbContext>()
|
||||
.UseInMemoryDatabase($"tradeups-{Guid.NewGuid()}")
|
||||
.Options;
|
||||
return new SkinTrackerDbContext(options);
|
||||
}
|
||||
|
||||
private static async Task<TradeupGraph> BuildAsync(SkinTrackerDbContext db)
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
var builder = new TradeupGraphBuilder(db, NullLogger<TradeupGraphBuilder>.Instance);
|
||||
return await builder.BuildAsync();
|
||||
}
|
||||
|
||||
private sealed class Catalogue
|
||||
{
|
||||
private int _nextId = 1;
|
||||
public Weapon Rifle { get; } = new() { Id = 1000, Name = "AK-47", Type = "Rifle", Team = "Both" };
|
||||
public Weapon Knife { get; } = new() { Id = 1001, Name = "Karambit", Type = "Knives", Team = "Both" };
|
||||
public Weapon Glove { get; } = new() { Id = 1002, Name = "Sport Gloves", Type = "Gloves", Team = "Both" };
|
||||
|
||||
public List<Skin> Skins { get; } = new();
|
||||
|
||||
public Skin Add(
|
||||
Collection collection,
|
||||
string rarity,
|
||||
Weapon? weapon = null,
|
||||
decimal? floatMin = 0.0m,
|
||||
decimal? floatMax = 1.0m)
|
||||
{
|
||||
var skin = new Skin
|
||||
{
|
||||
Id = _nextId,
|
||||
Slug = $"skin-{_nextId}",
|
||||
Name = $"Skin {_nextId}",
|
||||
Rarity = rarity,
|
||||
Weapon = weapon ?? Rifle,
|
||||
FloatMin = floatMin,
|
||||
FloatMax = floatMax,
|
||||
Collections = new List<Collection> { collection },
|
||||
};
|
||||
_nextId++;
|
||||
Skins.Add(skin);
|
||||
return skin;
|
||||
}
|
||||
}
|
||||
|
||||
private static Collection NewCollection(string name, string type = "Collection")
|
||||
=> new() { Name = name, Slug = $"col-{name}", Type = type };
|
||||
|
||||
[Fact]
|
||||
public async Task Resolves_across_a_rarity_gap()
|
||||
{
|
||||
// MilSpec + Classified present, Restricted absent → MilSpec must resolve to Classified.
|
||||
await using var db = NewContext();
|
||||
var cat = new Catalogue();
|
||||
var col = NewCollection("Gap");
|
||||
cat.Add(col, "Mil-Spec Grade");
|
||||
cat.Add(col, "Classified");
|
||||
db.Skins.AddRange(cat.Skins);
|
||||
|
||||
var graph = await BuildAsync(db);
|
||||
|
||||
var group = Assert.Single(graph.Groups);
|
||||
Assert.Equal(WeaponRarity.MilSpec, group.InputRarity);
|
||||
Assert.Equal(WeaponRarity.Classified, group.OutputRarity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Covert_is_an_output_but_never_an_input()
|
||||
{
|
||||
await using var db = NewContext();
|
||||
var cat = new Catalogue();
|
||||
var col = NewCollection("Tops at Covert");
|
||||
cat.Add(col, "Restricted");
|
||||
cat.Add(col, "Classified");
|
||||
cat.Add(col, "Covert"); // a weapon Covert (eligible output)
|
||||
db.Skins.AddRange(cat.Skins);
|
||||
|
||||
var graph = await BuildAsync(db);
|
||||
|
||||
Assert.Contains(graph.Groups, g => g.InputRarity == WeaponRarity.Restricted && g.OutputRarity == WeaponRarity.Classified);
|
||||
Assert.Contains(graph.Groups, g => g.InputRarity == WeaponRarity.Classified && g.OutputRarity == WeaponRarity.Covert);
|
||||
Assert.DoesNotContain(graph.Groups, g => g.InputRarity == WeaponRarity.Covert);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Knife_with_covert_rarity_is_excluded_as_output()
|
||||
{
|
||||
// The only "Covert" in the collection is a knife → the Covert tier has no eligible
|
||||
// output, so Classified resolves to nothing and yields no group.
|
||||
await using var db = NewContext();
|
||||
var cat = new Catalogue();
|
||||
var col = NewCollection("Knife Covert");
|
||||
cat.Add(col, "Classified");
|
||||
cat.Add(col, "Covert", weapon: cat.Knife);
|
||||
db.Skins.AddRange(cat.Skins);
|
||||
|
||||
var graph = await BuildAsync(db);
|
||||
|
||||
Assert.Empty(graph.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Contraband_and_gloves_are_not_weapon_tiers()
|
||||
{
|
||||
await using var db = NewContext();
|
||||
var cat = new Catalogue();
|
||||
var col = NewCollection("Howl");
|
||||
cat.Add(col, "Classified");
|
||||
cat.Add(col, "Contraband"); // The Howl
|
||||
cat.Add(col, "Extraordinary", weapon: cat.Glove); // a glove
|
||||
db.Skins.AddRange(cat.Skins);
|
||||
|
||||
var graph = await BuildAsync(db);
|
||||
|
||||
// Classified has no higher weapon tier present → no tradeup.
|
||||
Assert.Empty(graph.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Skins_without_float_bounds_are_excluded()
|
||||
{
|
||||
await using var db = NewContext();
|
||||
var cat = new Catalogue();
|
||||
var col = NewCollection("No Float");
|
||||
cat.Add(col, "Mil-Spec Grade", floatMin: null, floatMax: null); // floatless → not a tier
|
||||
cat.Add(col, "Classified");
|
||||
db.Skins.AddRange(cat.Skins);
|
||||
|
||||
var graph = await BuildAsync(db);
|
||||
|
||||
// The only would-be input tier is excluded, so nothing resolves.
|
||||
Assert.Empty(graph.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Limited_edition_pseudo_collection_is_skipped()
|
||||
{
|
||||
await using var db = NewContext();
|
||||
var cat = new Catalogue();
|
||||
var col = NewCollection("Limited Edition Item");
|
||||
cat.Add(col, "Mil-Spec Grade");
|
||||
cat.Add(col, "Classified");
|
||||
db.Skins.AddRange(cat.Skins);
|
||||
|
||||
var graph = await BuildAsync(db);
|
||||
|
||||
Assert.Empty(graph.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Containers_are_not_treated_as_collections()
|
||||
{
|
||||
await using var db = NewContext();
|
||||
var cat = new Catalogue();
|
||||
var crate = NewCollection("Some Case", type: "Container");
|
||||
cat.Add(crate, "Mil-Spec Grade");
|
||||
cat.Add(crate, "Classified");
|
||||
db.Skins.AddRange(cat.Skins);
|
||||
|
||||
var graph = await BuildAsync(db);
|
||||
|
||||
// Grouping is by Type='Collection' only; case weapons carry a separate Collection
|
||||
// source in reality, but a Container source on its own yields nothing.
|
||||
Assert.Empty(graph.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Output_skins_carry_float_bounds_and_stattrak_flag()
|
||||
{
|
||||
await using var db = NewContext();
|
||||
var cat = new Catalogue();
|
||||
var col = NewCollection("Bounds");
|
||||
cat.Add(col, "Classified");
|
||||
var covert = cat.Add(col, "Covert", floatMin: 0.0m, floatMax: 0.8m);
|
||||
covert.StatTrakAvailable = true;
|
||||
db.Skins.AddRange(cat.Skins);
|
||||
|
||||
var graph = await BuildAsync(db);
|
||||
|
||||
var group = Assert.Single(graph.Groups);
|
||||
var output = Assert.Single(group.OutputSkins);
|
||||
Assert.Equal(0.0m, output.FloatMin);
|
||||
Assert.Equal(0.8m, output.FloatMax);
|
||||
Assert.True(output.StatTrakAvailable);
|
||||
}
|
||||
}
|
||||
52
BlueLaminate/BlueLaminate.Tests/Tradeups/TradeupMathTests.cs
Normal file
52
BlueLaminate/BlueLaminate.Tests/Tradeups/TradeupMathTests.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using BlueLaminate.Core.Tradeups;
|
||||
using Xunit;
|
||||
|
||||
namespace BlueLaminate.Tests.Tradeups;
|
||||
|
||||
public class TradeupMathTests
|
||||
{
|
||||
[Fact]
|
||||
public void NormalizedFraction_maps_value_into_its_own_range()
|
||||
{
|
||||
// Mid-point of a 0.06–0.80 range.
|
||||
var frac = TradeupMath.NormalizedFraction(0.43m, 0.06m, 0.80m);
|
||||
Assert.Equal(0.5m, frac, precision: 6);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-0.5)] // below min
|
||||
[InlineData(2.0)] // above max
|
||||
public void NormalizedFraction_clamps_out_of_range_values(double value)
|
||||
{
|
||||
var frac = TradeupMath.NormalizedFraction((decimal)value, 0.0m, 1.0m);
|
||||
Assert.InRange(frac, 0m, 1m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizedFraction_returns_zero_for_zero_width_range()
|
||||
{
|
||||
Assert.Equal(0m, TradeupMath.NormalizedFraction(0.3m, 0.3m, 0.3m));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutputFloat_maps_average_fraction_onto_output_range()
|
||||
{
|
||||
// avg 0.10 onto a 0.00–0.70 output range → 0.07 (the FN/MW boundary).
|
||||
var outFloat = TradeupMath.OutputFloat(0.10m, 0.00m, 0.70m);
|
||||
Assert.Equal(0.07m, outFloat, precision: 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Full_contract_float_math_matches_hand_calculation()
|
||||
{
|
||||
// Ten inputs, each normalised to its own range, then averaged and mapped onto the
|
||||
// output's 0.00–0.80 range. Five inputs at fraction 0.2 and five at 0.4 → avg 0.3.
|
||||
var fractions = new[] { 0.2m, 0.2m, 0.2m, 0.2m, 0.2m, 0.4m, 0.4m, 0.4m, 0.4m, 0.4m };
|
||||
var avg = fractions.Sum() / fractions.Length;
|
||||
Assert.Equal(0.3m, avg, precision: 6);
|
||||
|
||||
var outFloat = TradeupMath.OutputFloat(avg, 0.00m, 0.80m);
|
||||
Assert.Equal(0.24m, outFloat, precision: 6);
|
||||
Assert.Equal(WearBand.FieldTested, WearBands.FromFloat(outFloat));
|
||||
}
|
||||
}
|
||||
204
BlueLaminate/BlueLaminate.Tests/Tradeups/TradeupSelectorTests.cs
Normal file
204
BlueLaminate/BlueLaminate.Tests/Tradeups/TradeupSelectorTests.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
using BlueLaminate.Core.Tradeups;
|
||||
using Xunit;
|
||||
|
||||
namespace BlueLaminate.Tests.Tradeups;
|
||||
|
||||
public class TradeupSelectorTests
|
||||
{
|
||||
private const decimal Bucket = 0.005m;
|
||||
|
||||
private static SelectableInput Item(decimal fraction, decimal price)
|
||||
=> new(fraction, new InputListing(
|
||||
SkinId: 1,
|
||||
MarketHashName: "Test Skin",
|
||||
Marketplace: "test",
|
||||
InspectLink: null,
|
||||
ExternalId: "0",
|
||||
FloatValue: fraction,
|
||||
Price: price));
|
||||
|
||||
[Fact]
|
||||
public void Cheapest_full_selection_is_the_ten_cheapest_copies()
|
||||
{
|
||||
// 12 copies priced 1..12 at assorted fractions. With no binding float target the
|
||||
// cheapest ten (cost 55) must be attainable at some summed-fraction bucket.
|
||||
var pool = new List<SelectableInput>();
|
||||
for (var i = 1; i <= 12; i++)
|
||||
{
|
||||
pool.Add(Item(fraction: 0.01m * i, price: i));
|
||||
}
|
||||
|
||||
var selection = TradeupSelector.Solve(pool, contractSize: 10, Bucket);
|
||||
var selections = selection.Selections().ToList();
|
||||
|
||||
Assert.NotEmpty(selections);
|
||||
|
||||
var cheapest = selections.MinBy(s => s.Cost);
|
||||
Assert.Equal(55m, cheapest.Cost);
|
||||
|
||||
var picks = cheapest.Picks.ToList();
|
||||
Assert.Equal(10, picks.Count);
|
||||
Assert.Equal(Enumerable.Range(1, 10).Select(i => (decimal)i), picks.Select(p => p.Price).OrderBy(p => p));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reported_average_fraction_is_a_conservative_upper_bound()
|
||||
{
|
||||
var pool = new List<SelectableInput>();
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
pool.Add(Item(fraction: 0.123m, price: 1m));
|
||||
}
|
||||
|
||||
var only = Assert.Single(TradeupSelector.Solve(pool, 10, Bucket).Selections());
|
||||
|
||||
var trueAverage = only.Picks.ToList().Average(p => p.FloatValue);
|
||||
Assert.True(only.AverageFraction >= trueAverage,
|
||||
$"bucketed average {only.AverageFraction} should round up from true {trueAverage}");
|
||||
// 0.123 rounds up to the 0.125 bucket (0.005 grid).
|
||||
Assert.Equal(0.125m, only.AverageFraction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Selecting_cheaper_low_float_copies_lowers_the_attainable_average()
|
||||
{
|
||||
// Cheap high-float copies vs. pricier low-float copies. A lower average is only
|
||||
// reachable by paying for the low-float set, so a low-average selection must cost
|
||||
// more than the unconstrained cheapest.
|
||||
var pool = new List<SelectableInput>();
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
pool.Add(Item(fraction: 0.60m, price: 1m)); // cheap, bad float
|
||||
}
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
pool.Add(Item(fraction: 0.10m, price: 5m)); // pricey, good float
|
||||
}
|
||||
|
||||
var selections = TradeupSelector.Solve(pool, 10, Bucket).Selections().ToList();
|
||||
|
||||
var cheapest = selections.MinBy(s => s.Cost);
|
||||
Assert.Equal(10m, cheapest.Cost); // ten cheap copies
|
||||
Assert.Equal(0.60m, cheapest.AverageFraction);
|
||||
|
||||
var lowestFloat = selections.MinBy(s => s.AverageFraction);
|
||||
Assert.Equal(0.10m, lowestFloat.AverageFraction);
|
||||
Assert.Equal(50m, lowestFloat.Cost); // forced onto the pricey low-float set
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_full_selection_when_pool_is_too_small()
|
||||
{
|
||||
var pool = Enumerable.Range(0, 5).Select(i => Item(0.2m, i + 1)).ToList();
|
||||
Assert.Empty(TradeupSelector.Solve(pool, 10, Bucket).Selections());
|
||||
}
|
||||
|
||||
private static TradeupSelector.RewardItem Reward(int bucket, double reward, decimal price = 0m)
|
||||
=> new(bucket, reward, new InputListing(1, "x", "m", null, "0", 0.1m, price));
|
||||
|
||||
[Fact]
|
||||
public void MaxReward_picks_the_highest_reward_set_within_the_cap()
|
||||
{
|
||||
// Six items; pick 3 maximising reward with bucket sum ≤ 6. The three best rewards
|
||||
// (9, 8, 7) sit at buckets 2,2,2 = 6 ≤ cap, so they win.
|
||||
var items = new[]
|
||||
{
|
||||
Reward(2, 9), Reward(2, 8), Reward(2, 7),
|
||||
Reward(1, 1), Reward(1, 2), Reward(1, 3),
|
||||
};
|
||||
|
||||
var picks = TradeupSelector.SolveMaxReward(items, contractSize: 3, capBucket: 6);
|
||||
|
||||
Assert.NotNull(picks);
|
||||
Assert.Equal(3, picks!.ToList().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxReward_respects_the_float_cap_even_at_the_cost_of_reward()
|
||||
{
|
||||
// The fat-reward items sit at high buckets; the cap forces the low-bucket set.
|
||||
var items = new[]
|
||||
{
|
||||
Reward(5, 100, price: 50m), // too high-float to fit under a tight cap
|
||||
Reward(1, 5, price: 1m),
|
||||
Reward(1, 4, price: 1m),
|
||||
Reward(1, 3, price: 1m),
|
||||
};
|
||||
|
||||
var picks = TradeupSelector.SolveMaxReward(items, contractSize: 3, capBucket: 3);
|
||||
|
||||
Assert.NotNull(picks);
|
||||
var chosen = picks!.ToList();
|
||||
Assert.Equal(3, chosen.Count);
|
||||
Assert.All(chosen, p => Assert.Equal(1m, p.Price)); // the three low-float copies
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxReward_returns_null_when_the_contract_cannot_be_filled_under_the_cap()
|
||||
{
|
||||
// Only two items fit under the cap, but three are required.
|
||||
var items = new[] { Reward(1, 5), Reward(1, 4), Reward(9, 100) };
|
||||
Assert.Null(TradeupSelector.SolveMaxReward(items, contractSize: 3, capBucket: 3));
|
||||
}
|
||||
}
|
||||
|
||||
public class OutputPriceBookTests
|
||||
{
|
||||
private static TradeupListingRow Row(WearBand band, bool st, decimal price)
|
||||
{
|
||||
// A float squarely inside the band, so Build files it where we expect.
|
||||
var f = band switch
|
||||
{
|
||||
WearBand.FactoryNew => 0.03m,
|
||||
WearBand.MinimalWear => 0.10m,
|
||||
WearBand.FieldTested => 0.25m,
|
||||
WearBand.WellWorn => 0.40m,
|
||||
_ => 0.60m,
|
||||
};
|
||||
return new TradeupListingRow(1, "M4A4 | X-Ray", "csmoney", null, "0", st, false, f, price);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Liquid_band_uses_its_own_price()
|
||||
{
|
||||
// Ten MW listings -> the MW band is trusted at its own lowest ask.
|
||||
var rows = Enumerable.Range(0, 10).Select(i => Row(WearBand.MinimalWear, true, 90m + i));
|
||||
var book = TradeupListingData.Build(rows).OutputPrices;
|
||||
|
||||
var r = book.Resolve(1, statTrak: true, outputFloat: 0.10m, thinThreshold: 10);
|
||||
|
||||
Assert.Equal(OutputPriceBasis.Band, r.Basis);
|
||||
Assert.Equal(90m, r.LowestAsk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Thin_band_falls_back_to_the_skin_overall_floor()
|
||||
{
|
||||
// The X-Ray case: two outlier FN listings ($1287) but a liquid MW market (~$90).
|
||||
// A produced FN output must price off the $90 floor, not the $1287 band outlier.
|
||||
var rows = new List<TradeupListingRow>
|
||||
{
|
||||
Row(WearBand.FactoryNew, true, 1287m),
|
||||
Row(WearBand.FactoryNew, true, 1290m),
|
||||
};
|
||||
rows.AddRange(Enumerable.Range(0, 10).Select(i => Row(WearBand.MinimalWear, true, 90m + i)));
|
||||
|
||||
var book = TradeupListingData.Build(rows).OutputPrices;
|
||||
|
||||
var r = book.Resolve(1, statTrak: true, outputFloat: 0.0695m, thinThreshold: 10);
|
||||
|
||||
Assert.Equal(OutputPriceBasis.Floor, r.Basis);
|
||||
Assert.Equal(90m, r.LowestAsk); // skin-wide floor, not the $1287 FN outlier
|
||||
Assert.Equal(2, r.BandLiquidity); // still reports the thin band count → triggers CSFloat
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unlisted_skin_resolves_to_none()
|
||||
{
|
||||
var book = TradeupListingData.Build(Array.Empty<TradeupListingRow>()).OutputPrices;
|
||||
var r = book.Resolve(1, statTrak: false, outputFloat: 0.1m, thinThreshold: 10);
|
||||
Assert.Equal(OutputPriceBasis.None, r.Basis);
|
||||
Assert.Null(r.LowestAsk);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using BlueLaminate.Core.Tradeups;
|
||||
using Xunit;
|
||||
|
||||
namespace BlueLaminate.Tests.Tradeups;
|
||||
|
||||
public class WeaponRarityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Consumer Grade", WeaponRarity.Consumer)]
|
||||
[InlineData("Industrial Grade", WeaponRarity.Industrial)]
|
||||
[InlineData("Mil-Spec Grade", WeaponRarity.MilSpec)]
|
||||
[InlineData("Restricted", WeaponRarity.Restricted)]
|
||||
[InlineData("Classified", WeaponRarity.Classified)]
|
||||
[InlineData("Covert", WeaponRarity.Covert)]
|
||||
public void Maps_each_weapon_tier_literal(string literal, WeaponRarity expected)
|
||||
{
|
||||
Assert.True(WeaponRarityExtensions.TryParse(literal, out var rarity));
|
||||
Assert.Equal(expected, rarity);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Contraband")] // The Howl
|
||||
[InlineData("Extraordinary")] // Gloves
|
||||
public void Reports_non_weapon_rarities_as_not_a_tier(string literal)
|
||||
{
|
||||
Assert.False(WeaponRarityExtensions.TryParse(literal, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Throws_on_unknown_literal_so_a_catalogue_rename_is_loud()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => WeaponRarityExtensions.TryParse("Mythical", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tiers_are_strictly_ordered()
|
||||
{
|
||||
Assert.True(WeaponRarity.Consumer < WeaponRarity.Industrial);
|
||||
Assert.True(WeaponRarity.MilSpec < WeaponRarity.Restricted);
|
||||
Assert.True(WeaponRarity.Classified < WeaponRarity.Covert);
|
||||
}
|
||||
}
|
||||
31
BlueLaminate/BlueLaminate.Tests/Tradeups/WearBandTests.cs
Normal file
31
BlueLaminate/BlueLaminate.Tests/Tradeups/WearBandTests.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using BlueLaminate.Core.Tradeups;
|
||||
using Xunit;
|
||||
|
||||
namespace BlueLaminate.Tests.Tradeups;
|
||||
|
||||
public class WearBandTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0.00, WearBand.FactoryNew)]
|
||||
[InlineData(0.0699, WearBand.FactoryNew)]
|
||||
[InlineData(0.07, WearBand.MinimalWear)] // boundary is upper-exclusive
|
||||
[InlineData(0.1499, WearBand.MinimalWear)]
|
||||
[InlineData(0.15, WearBand.FieldTested)]
|
||||
[InlineData(0.3799, WearBand.FieldTested)]
|
||||
[InlineData(0.38, WearBand.WellWorn)]
|
||||
[InlineData(0.4499, WearBand.WellWorn)]
|
||||
[InlineData(0.45, WearBand.BattleScarred)]
|
||||
[InlineData(1.00, WearBand.BattleScarred)]
|
||||
public void FromFloat_classifies_on_absolute_thresholds(double value, WearBand expected)
|
||||
{
|
||||
Assert.Equal(expected, WearBands.FromFloat((decimal)value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToName_matches_listing_wear_strings()
|
||||
{
|
||||
Assert.Equal("Factory New", WearBand.FactoryNew.ToName());
|
||||
Assert.Equal("Field-Tested", WearBand.FieldTested.ToName());
|
||||
Assert.Equal("Battle-Scarred", WearBand.BattleScarred.ToName());
|
||||
}
|
||||
}
|
||||
113
BlueLaminate/BlueLaminate.Tests/Tui/TradeupBrowserTests.cs
Normal file
113
BlueLaminate/BlueLaminate.Tests/Tui/TradeupBrowserTests.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using BlueLaminate.Cli.Tui;
|
||||
using BlueLaminate.Core.Tradeups;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace BlueLaminate.Tests.Tui;
|
||||
|
||||
/// <summary>
|
||||
/// Render-path smoke tests for the TUI: the navigation loop needs a live terminal, but the
|
||||
/// rendering (and its Spectre markup) is exercised against a recording console so a stray
|
||||
/// markup tag or unescaped name fails the build instead of crashing on first launch.
|
||||
/// </summary>
|
||||
public class TradeupBrowserTests
|
||||
{
|
||||
private static InputListing Input(string name, string market, string? inspect, decimal price)
|
||||
=> new(SkinId: 1, MarketHashName: name, Marketplace: market,
|
||||
InspectLink: inspect, ExternalId: "999", FloatValue: 0.1234m, Price: price);
|
||||
|
||||
private static TradeupCandidate Candidate(
|
||||
string collection = "The Sample Collection",
|
||||
bool statTrak = false,
|
||||
bool guaranteed = true,
|
||||
decimal worstNet = 120m,
|
||||
IReadOnlyList<TradeupOutcome>? outcomes = null,
|
||||
IReadOnlyList<InputListing>? inputs = null)
|
||||
{
|
||||
outcomes ??= new[]
|
||||
{
|
||||
new TradeupOutcome(10, "Output Alpha", 0.0672m, WearBand.FactoryNew, 0.5m, 175.75m, 26),
|
||||
new TradeupOutcome(11, "Output Beta", 0.0695m, WearBand.FactoryNew, 0.5m, null, 0),
|
||||
};
|
||||
inputs ??= new[]
|
||||
{
|
||||
Input("AK-47 | Sample (Field-Tested)", "csfloat",
|
||||
"steam://rungame/730/0/+csgo_econ_action_preview%20ABC123", 1.94m),
|
||||
Input("MP9 | Sample (Minimal Wear)", "csmoney", null, 6.52m),
|
||||
};
|
||||
|
||||
return new TradeupCandidate(
|
||||
CollectionId: 1,
|
||||
CollectionName: collection,
|
||||
InputRarity: WeaponRarity.Classified,
|
||||
OutputRarity: WeaponRarity.Covert,
|
||||
StatTrak: statTrak,
|
||||
AverageFraction: 0.0695m,
|
||||
InputCost: 33.16m,
|
||||
ExpectedNet: 120.53m,
|
||||
WorstCaseNet: worstNet,
|
||||
Guaranteed: guaranteed,
|
||||
Inputs: inputs,
|
||||
Outcomes: outcomes,
|
||||
Composition: new[] { new TradeupContribution(1, collection, WeaponRarity.Covert, 10) });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderDetail_emits_the_key_sections_without_markup_errors()
|
||||
{
|
||||
var console = new TestConsole();
|
||||
|
||||
TradeupBrowser.RenderDetail(console, Candidate(), index: 0);
|
||||
|
||||
var output = console.Output;
|
||||
Assert.Contains("The Sample Collection", output);
|
||||
Assert.Contains("Possible outputs", output);
|
||||
Assert.Contains("Buy list", output);
|
||||
Assert.Contains("Output Alpha", output);
|
||||
Assert.Contains("unpriced", output); // the null-priced output is shown, not dropped
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, true, 120)] // guaranteed, positive worst-case (green)
|
||||
[InlineData(true, true, 120)] // StatTrak variant
|
||||
[InlineData(false, false, -40)] // not guaranteed, negative worst-case (red)
|
||||
public void SummaryLine_is_always_valid_markup(bool statTrak, bool guaranteed, int worstNet)
|
||||
{
|
||||
var line = TradeupBrowser.SummaryLine(
|
||||
Candidate(statTrak: statTrak, guaranteed: guaranteed, worstNet: worstNet), index: 3);
|
||||
|
||||
// The Markup ctor parses the string and throws on a malformed tag.
|
||||
_ = new Markup(line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_screen_renders_and_runs_on_enter()
|
||||
{
|
||||
var console = new TestConsole().Interactive();
|
||||
console.Input.PushKey(ConsoleKey.Enter); // first choice is "Run search"
|
||||
|
||||
var options = new BlueLaminate.Core.Options.TradeupOptions();
|
||||
var top = 20;
|
||||
var action = TradeupBrowser.PromptSettings(console, options, ref top);
|
||||
|
||||
Assert.Equal(SettingsAction.RunSearch, action);
|
||||
Assert.Contains("StatTrak universe", console.Output);
|
||||
Assert.Contains("Run search", console.Output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Names_and_links_with_markup_characters_are_escaped()
|
||||
{
|
||||
var console = new TestConsole();
|
||||
var hostile = Candidate(
|
||||
collection: "Danger [Collection]",
|
||||
outcomes: new[] { new TradeupOutcome(10, "Skin [X]", 0.1m, WearBand.MinimalWear, 1m, 5m, 3) },
|
||||
inputs: new[] { Input("Weapon [Y] (FT)", "market", inspect: null, price: 1m) });
|
||||
|
||||
// Would throw a markup-parse exception if any bracketed field reached Spectre unescaped.
|
||||
TradeupBrowser.RenderDetail(console, hostile, index: 0);
|
||||
|
||||
Assert.Contains("Danger", console.Output);
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,5 @@
|
||||
<Project Path="BlueLaminate.Core/BlueLaminate.Core.csproj" />
|
||||
<Project Path="BlueLaminate.Cli/BlueLaminate.Cli.csproj" />
|
||||
<Project Path="BlueLaminate.C2/BlueLaminate.C2.csproj" />
|
||||
<Project Path="BlueLaminate.Tests/BlueLaminate.Tests.csproj" />
|
||||
</Solution>
|
||||
|
||||
Reference in New Issue
Block a user