package cmd import ( "fmt" "time" "github.com/runescape-wiki/rsw/internal/prices" "github.com/runescape-wiki/rsw/internal/render" "github.com/runescape-wiki/rsw/internal/wiki" "github.com/spf13/cobra" ) func newPriceCmd() *cobra.Command { return &cobra.Command{ Use: "price ", Short: "Look up Grand Exchange prices for an item", Long: `Fetches real-time GE price data for an item: current buy/sell prices, trade volume, and recent trend. With --ironman, shows alch values and vendor prices instead of GE data. Examples: rsw osrs price "abyssal whip" rsw rs3 price "blue partyhat" rsw osrs price "dragon bones" --ironman`, Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { name := args[0] if Game() == "rs3" { return runRS3Price(name) } priceClient := prices.NewClient(GamePriceBaseURL()) mc := prices.NewMappingCache(priceClient, Game()) if err := mc.Load(); err != nil { return fmt.Errorf("failed to load item mapping: %w", err) } item := mc.LookupByName(name) if item == nil { matches := mc.SearchByName(name) if len(matches) == 0 { return fmt.Errorf("no item found matching %q", name) } if len(matches) > 1 { md := render.New() md.H2("Multiple items found") md.P("Did you mean one of these?") shown := 0 for _, m := range matches { md.Bullet(m.Name) shown++ if shown >= 15 { md.P(fmt.Sprintf("...and %d more", len(matches)-15)) break } } fmt.Print(md.String()) return nil } item = matches[0] } md := render.New() md.H1(item.Name) md.KV("Examine", item.Examine) md.KV("Members", fmt.Sprintf("%v", item.Members)) md.KV("Store value", render.FormatGP(item.Value)) if item.HighAlch != nil { md.KV("High Alch", render.FormatGP(*item.HighAlch)) } if item.LowAlch != nil { md.KV("Low Alch", render.FormatGP(*item.LowAlch)) } md.Newline() if Ironman() { md.H2("Ironman Info") md.P("*GE trading unavailable — showing self-sufficient values.*") md.KV("Store value", render.FormatGP(item.Value)) if item.HighAlch != nil { md.KV("High Alch value", render.FormatGP(*item.HighAlch)) } md.P("Use `rsw item` for drop sources and shop locations.") } else { latest, err := priceClient.GetLatestForItem(item.ID) if err != nil { md.P("*Price data unavailable.*") } else { md.H2("Grand Exchange Price") if latest.High != nil { md.KV("Instant buy", render.FormatGP(*latest.High)) if latest.HighTime != nil { md.KV("Last buy", timeAgo(*latest.HighTime)) } } if latest.Low != nil { md.KV("Instant sell", render.FormatGP(*latest.Low)) if latest.LowTime != nil { md.KV("Last sell", timeAgo(*latest.LowTime)) } } if item.Limit != nil { md.KV("Buy limit", render.FormatNumber(*item.Limit)) } md.Newline() } hourly, err := priceClient.Get1Hour(item.ID) if err == nil { md.H2("Recent Activity (1h)") if hourly.AvgHighPrice != nil { md.KV("Avg buy price", render.FormatGP(*hourly.AvgHighPrice)) } md.KV("Buy volume", render.FormatNumber(hourly.HighVolume)) if hourly.AvgLowPrice != nil { md.KV("Avg sell price", render.FormatGP(*hourly.AvgLowPrice)) } md.KV("Sell volume", render.FormatNumber(hourly.LowVolume)) md.Newline() } } fmt.Print(md.String()) return nil }, } } func runRS3Price(name string) error { wikiClient := wiki.NewClient(GameBaseURL()) results, err := wikiClient.Search(name, 5) if err != nil { return fmt.Errorf("search failed: %w", err) } if len(results) == 0 { return fmt.Errorf("no wiki page found for %q", name) } pageTitle := results[0].Title exchangeItem, err := wikiClient.GetExchangeModule(pageTitle) if err != nil { return fmt.Errorf("failed to look up exchange data: %w", err) } if exchangeItem == nil { return fmt.Errorf("%q is not tradeable on the Grand Exchange", pageTitle) } rs3Client := prices.NewRS3Client() detail, err := rs3Client.GetDetail(exchangeItem.ItemID) if err != nil { return fmt.Errorf("failed to fetch RS3 price data: %w", err) } md := render.New() md.H1(detail.Name) md.KV("Examine", exchangeItem.Examine) md.KV("Members", fmt.Sprintf("%v", exchangeItem.Members)) if exchangeItem.Limit > 0 { md.KV("Buy limit", render.FormatNumber(exchangeItem.Limit)) } md.KV("Store value", render.FormatGP(exchangeItem.Value)) md.Newline() md.H2("Grand Exchange Price") md.KV("Current price", detail.CurrentPrice+" gp") if detail.TodayPrice != 0 { md.KV("Today's change", render.FormatGP(detail.TodayPrice)) } else { md.KV("Today's change", "0 gp") } md.KV("30-day change", fmt.Sprintf("%s (%s)", detail.Day30Change, detail.Day30Trend)) md.KV("90-day change", fmt.Sprintf("%s (%s)", detail.Day90Change, detail.Day90Trend)) md.KV("180-day change", fmt.Sprintf("%s (%s)", detail.Day180Change, detail.Day180Trend)) md.Newline() fmt.Print(md.String()) return nil } func timeAgo(unixTime int64) string { t := time.Unix(unixTime, 0) d := time.Since(t) switch { case d < time.Minute: return "just now" case d < time.Hour: return fmt.Sprintf("%d min ago", int(d.Minutes())) case d < 24*time.Hour: return fmt.Sprintf("%d hours ago", int(d.Hours())) default: return fmt.Sprintf("%d days ago", int(d.Hours()/24)) } } func init() { RegisterCommand(newPriceCmd) }