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)] + "…"; }