using BlueLaminate.Core.Options;
using BlueLaminate.Core.Tradeups;
using Spectre.Console;
using System.Globalization;
namespace BlueLaminate.Cli.Tui;
/// What the user chose to do next after browsing results.
internal enum BrowseAction
{
/// Leave the finder.
Quit,
/// Return to the settings screen and run a fresh search.
AdjustSettings,
}
/// What the user chose on the settings screen.
internal enum SettingsAction
{
/// Run a search with the current settings.
RunSearch,
/// Leave the finder.
Quit,
}
///
/// 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 s the Core engine produces; it
/// only mutates a the command then feeds back to the finder.
///
/// The render/prompt methods take an so they can be exercised
/// against a recording console in tests; only the live loop needs the real terminal.
///
///
internal static class TradeupBrowser
{
private const int QuitSentinel = -1;
private const int AdjustSentinel = -2;
/// Whether the current terminal can host the interactive prompts.
public static bool IsSupported => AnsiConsole.Profile.Capabilities.Interactive;
///
/// Settings screen: shows the current search options and lets the user tweak any of them
/// before running. Mutates and in place;
/// returns whether to run a search or quit.
///
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()
.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()
.Title("StatTrak universe to search")
.AddChoices(StatTrakMode.Both, StatTrakMode.NonStatTrakOnly, StatTrakMode.StatTrakOnly));
}
else if (choice == rankItem)
{
options.Ranking = console.Prompt(
new SelectionPrompt()
.Title("Rank surviving contracts by")
.AddChoices(TradeupRanking.WorstCaseProfit, TradeupRanking.ExpectedProfit));
}
else if (choice == guarItem)
{
options.GuaranteedOnly = console.Prompt(
new SelectionPrompt()
.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("Minimum ranking profit (USD):")
.DefaultValue(options.MinProfit)
.ShowDefaultValue());
}
else if (choice == topItem)
{
top = console.Prompt(
new TextPrompt("Show how many contracts:")
.DefaultValue(top)
.ShowDefaultValue()
.Validate(v => v > 0 ? ValidationResult.Success() : ValidationResult.Error("must be > 0")));
}
}
}
public static BrowseAction Run(IReadOnlyList candidates, TradeupOptions options)
=> Run(AnsiConsole.Console, candidates, options);
public static BrowseAction Run(
IAnsiConsole console, IReadOnlyList 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()
.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()
.Title(string.Empty)
.AddChoices(back, adjust, quit));
if (next == quit)
{
console.Clear();
return BrowseAction.Quit;
}
if (next == adjust)
{
return BrowseAction.AdjustSettings;
}
}
}
private static Func ListChoiceLabel(IReadOnlyList 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)] + "…";
}