Files
Operation-Blue-Laminate-v2/BlueLaminate/BlueLaminate.Cli/Tui/TradeupBrowser.cs
2026-06-02 13:31:27 -05:00

353 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)] + "…";
}