353 lines
14 KiB
C#
353 lines
14 KiB
C#
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)] + "…";
|
||
}
|