Item price support
This commit is contained in:
@@ -13,7 +13,7 @@
|
|||||||
"name": "wiki",
|
"name": "wiki",
|
||||||
"source": "./plugins/rsw",
|
"source": "./plugins/rsw",
|
||||||
"description": "RuneScape Wiki CLI",
|
"description": "RuneScape Wiki CLI",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Sam Myers"
|
"name": "Sam Myers"
|
||||||
}
|
}
|
||||||
|
|||||||
78
README.md
Normal file
78
README.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# RuneScape Wiki — Claude Code Plugin
|
||||||
|
|
||||||
|
Query the RuneScape Wiki directly from Claude Code. Look up items, quests, skill training guides, and live Grand Exchange prices for both **OSRS** and **RS3**, without leaving your terminal.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Add the marketplace:
|
||||||
|
```
|
||||||
|
plugin marketplace add https://git.samlab.cloud/sam/claude-plugin-runescape.git
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install the plugin:
|
||||||
|
```
|
||||||
|
/plugins
|
||||||
|
```
|
||||||
|
Search for `wiki` and install **runescape-wiki**.
|
||||||
|
|
||||||
|
> **Requires Go** to be installed. The plugin binary is built automatically on first use.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
Once installed, Claude can use the RuneScape Wiki tools when you ask questions like:
|
||||||
|
|
||||||
|
- *"How do I get an abyssal whip in OSRS?"*
|
||||||
|
- *"What are the requirements for Plague's End in RS3?"*
|
||||||
|
- *"What's the current GE price of a blue partyhat?"*
|
||||||
|
- *"Show me a mining training guide for levels 50–70."*
|
||||||
|
- *"I'm an ironman — how do I get dragon bones?"*
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
All commands take the game (`osrs` or `rs3`) as the first argument.
|
||||||
|
|
||||||
|
### `item <name>`
|
||||||
|
Look up an item's stats, equipment bonuses, drop sources, and Grand Exchange price.
|
||||||
|
|
||||||
|
```
|
||||||
|
rsw osrs item "abyssal whip"
|
||||||
|
rsw rs3 item "dragon bones" --ironman
|
||||||
|
```
|
||||||
|
|
||||||
|
### `quest <name>`
|
||||||
|
Look up quest requirements, items needed, enemies to defeat, and rewards.
|
||||||
|
|
||||||
|
```
|
||||||
|
rsw osrs quest "Monkey Madness I"
|
||||||
|
rsw rs3 quest "Plague's End"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `skill <name>`
|
||||||
|
Fetch a skill training guide with section-by-section methods. Filter to a level range with `--level`.
|
||||||
|
|
||||||
|
```
|
||||||
|
rsw osrs skill mining
|
||||||
|
rsw rs3 skill prayer --level 50-70
|
||||||
|
```
|
||||||
|
|
||||||
|
### `price <name>`
|
||||||
|
Get live Grand Exchange prices including instant buy/sell, buy limit, and recent 1-hour trade volume.
|
||||||
|
|
||||||
|
```
|
||||||
|
rsw osrs price "abyssal whip"
|
||||||
|
rsw rs3 price "blue partyhat"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flags
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `--ironman` / `-i` | Ironman mode: hides GE prices, emphasizes drops, shops, and self-sufficient methods |
|
||||||
|
| `--raw` | Output raw wikitext instead of rendered markdown |
|
||||||
|
|
||||||
|
## Supported games
|
||||||
|
|
||||||
|
| Argument | Wiki |
|
||||||
|
|----------|------|
|
||||||
|
| `osrs` | [oldschool.runescape.wiki](https://oldschool.runescape.wiki) |
|
||||||
|
| `rs3` | [runescape.wiki](https://runescape.wiki) |
|
||||||
15
scripts/rsw/Makefile
Normal file
15
scripts/rsw/Makefile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
.PHONY: build test lint clean
|
||||||
|
|
||||||
|
BIN := rsw
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o $(BIN) .
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./... -count=1
|
||||||
|
|
||||||
|
lint:
|
||||||
|
golangci-lint run ./...
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(BIN)
|
||||||
@@ -122,8 +122,13 @@ func findItemInfobox(templates []extract.Infobox) *extract.Infobox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func renderGEPrice(md *render.Builder, name, pageTitle string) {
|
func renderGEPrice(md *render.Builder, name, pageTitle string) {
|
||||||
|
if Game() == "rs3" {
|
||||||
|
renderRS3GEPrice(md, pageTitle)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
priceClient := prices.NewClient(GamePriceBaseURL())
|
priceClient := prices.NewClient(GamePriceBaseURL())
|
||||||
mc := prices.NewMappingCache(priceClient)
|
mc := prices.NewMappingCache(priceClient, Game())
|
||||||
if err := mc.Load(); err != nil {
|
if err := mc.Load(); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -152,6 +157,32 @@ func renderGEPrice(md *render.Builder, name, pageTitle string) {
|
|||||||
md.Newline()
|
md.Newline()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderRS3GEPrice(md *render.Builder, pageTitle string) {
|
||||||
|
wikiClient := wiki.NewClient(GameBaseURL())
|
||||||
|
exchangeItem, err := wikiClient.GetExchangeModule(pageTitle)
|
||||||
|
if err != nil || exchangeItem == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rs3Client := prices.NewRS3Client()
|
||||||
|
detail, err := rs3Client.GetDetail(exchangeItem.ItemID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
md.H2("Grand Exchange")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
if exchangeItem.Limit > 0 {
|
||||||
|
md.KV("Buy limit", render.FormatNumber(exchangeItem.Limit))
|
||||||
|
}
|
||||||
|
md.Newline()
|
||||||
|
}
|
||||||
|
|
||||||
func renderDropSources(md *render.Builder, templates []extract.Infobox) {
|
func renderDropSources(md *render.Builder, templates []extract.Infobox) {
|
||||||
drops := extract.FindAllTemplates(templates, "DropsLine")
|
drops := extract.FindAllTemplates(templates, "DropsLine")
|
||||||
if len(drops) == 0 {
|
if len(drops) == 0 {
|
||||||
@@ -182,7 +213,7 @@ func renderSourcesSection(md *render.Builder, page *wiki.ParsedPage, client *wik
|
|||||||
lower := strings.ToLower(s.Line)
|
lower := strings.ToLower(s.Line)
|
||||||
if lower == "item sources" || lower == "sources" || lower == "obtaining" || lower == "acquisition" {
|
if lower == "item sources" || lower == "sources" || lower == "obtaining" || lower == "acquisition" {
|
||||||
idx := 0
|
idx := 0
|
||||||
fmt.Sscanf(s.Index, "%d", &idx)
|
_, _ = fmt.Sscanf(s.Index, "%d", &idx)
|
||||||
if idx > 0 {
|
if idx > 0 {
|
||||||
sectionPage, err := client.GetPageSection(title, idx)
|
sectionPage, err := client.GetPageSection(title, idx)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/runescape-wiki/rsw/internal/prices"
|
"github.com/runescape-wiki/rsw/internal/prices"
|
||||||
"github.com/runescape-wiki/rsw/internal/render"
|
"github.com/runescape-wiki/rsw/internal/render"
|
||||||
|
"github.com/runescape-wiki/rsw/internal/wiki"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,8 +26,13 @@ Examples:
|
|||||||
Args: cobra.MinimumNArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
name := args[0]
|
name := args[0]
|
||||||
|
|
||||||
|
if Game() == "rs3" {
|
||||||
|
return runRS3Price(name)
|
||||||
|
}
|
||||||
|
|
||||||
priceClient := prices.NewClient(GamePriceBaseURL())
|
priceClient := prices.NewClient(GamePriceBaseURL())
|
||||||
mc := prices.NewMappingCache(priceClient)
|
mc := prices.NewMappingCache(priceClient, Game())
|
||||||
|
|
||||||
if err := mc.Load(); err != nil {
|
if err := mc.Load(); err != nil {
|
||||||
return fmt.Errorf("failed to load item mapping: %w", err)
|
return fmt.Errorf("failed to load item mapping: %w", err)
|
||||||
@@ -104,17 +110,16 @@ Examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
hourly, err := priceClient.Get1Hour(item.ID)
|
hourly, err := priceClient.Get1Hour(item.ID)
|
||||||
if err == nil && len(hourly.Data) > 0 {
|
if err == nil {
|
||||||
recent := hourly.Data[len(hourly.Data)-1]
|
|
||||||
md.H2("Recent Activity (1h)")
|
md.H2("Recent Activity (1h)")
|
||||||
if recent.AvgHighPrice != nil {
|
if hourly.AvgHighPrice != nil {
|
||||||
md.KV("Avg buy price", render.FormatGP(*recent.AvgHighPrice))
|
md.KV("Avg buy price", render.FormatGP(*hourly.AvgHighPrice))
|
||||||
}
|
}
|
||||||
md.KV("Buy volume", render.FormatNumber(recent.HighVolume))
|
md.KV("Buy volume", render.FormatNumber(hourly.HighVolume))
|
||||||
if recent.AvgLowPrice != nil {
|
if hourly.AvgLowPrice != nil {
|
||||||
md.KV("Avg sell price", render.FormatGP(*recent.AvgLowPrice))
|
md.KV("Avg sell price", render.FormatGP(*hourly.AvgLowPrice))
|
||||||
}
|
}
|
||||||
md.KV("Sell volume", render.FormatNumber(recent.LowVolume))
|
md.KV("Sell volume", render.FormatNumber(hourly.LowVolume))
|
||||||
md.Newline()
|
md.Newline()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,6 +130,58 @@ Examples:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func timeAgo(unixTime int64) string {
|
||||||
t := time.Unix(unixTime, 0)
|
t := time.Unix(unixTime, 0)
|
||||||
d := time.Since(t)
|
d := time.Since(t)
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ func renderQuestSection(md *render.Builder, page *wiki.ParsedPage, client *wiki.
|
|||||||
for _, target := range sectionNames {
|
for _, target := range sectionNames {
|
||||||
if lower == target || strings.Contains(lower, target) {
|
if lower == target || strings.Contains(lower, target) {
|
||||||
idx := 0
|
idx := 0
|
||||||
fmt.Sscanf(s.Index, "%d", &idx)
|
_, _ = fmt.Sscanf(s.Index, "%d", &idx)
|
||||||
if idx > 0 {
|
if idx > 0 {
|
||||||
sectionPage, err := client.GetPageSection(title, idx)
|
sectionPage, err := client.GetPageSection(title, idx)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -64,12 +64,11 @@ func GameBaseURL() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GamePriceBaseURL returns the real-time price API base URL.
|
// GamePriceBaseURL returns the real-time price API base URL.
|
||||||
|
// Only valid for OSRS; RS3 uses a separate client (prices.RS3Client).
|
||||||
func GamePriceBaseURL() string {
|
func GamePriceBaseURL() string {
|
||||||
switch game {
|
switch game {
|
||||||
case "osrs":
|
case "osrs":
|
||||||
return "https://prices.runescape.wiki/api/v1/osrs"
|
return "https://prices.runescape.wiki/api/v1/osrs"
|
||||||
case "rs3":
|
|
||||||
return "https://prices.runescape.wiki/api/v1/rs"
|
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ func (w *walker) renderListItem(n *html.Node) {
|
|||||||
ctx := &w.listCtx[len(w.listCtx)-1]
|
ctx := &w.listCtx[len(w.listCtx)-1]
|
||||||
if ctx.ordered {
|
if ctx.ordered {
|
||||||
ctx.index++
|
ctx.index++
|
||||||
w.sb.WriteString(fmt.Sprintf("%s%d. ", indent, ctx.index))
|
fmt.Fprintf(&w.sb, "%s%d. ", indent, ctx.index)
|
||||||
} else {
|
} else {
|
||||||
w.sb.WriteString(indent + "- ")
|
w.sb.WriteString(indent + "- ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,31 +96,40 @@ type VolumeEntry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TimeseriesResponse wraps the 5m/1h API responses.
|
// TimeseriesResponse wraps the 5m/1h API responses.
|
||||||
|
// The API returns data as a map keyed by item ID string.
|
||||||
type TimeseriesResponse struct {
|
type TimeseriesResponse struct {
|
||||||
Data []VolumeEntry `json:"data"`
|
Data map[string]VolumeEntry `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get5Min fetches 5-minute average data for an item.
|
// Get5Min fetches 5-minute average data for an item.
|
||||||
func (c *Client) Get5Min(itemID int) (*TimeseriesResponse, error) {
|
func (c *Client) Get5Min(itemID int) (*VolumeEntry, error) {
|
||||||
url := fmt.Sprintf("%s/5m?id=%d", c.baseURL, itemID)
|
url := fmt.Sprintf("%s/5m?id=%d", c.baseURL, itemID)
|
||||||
var resp TimeseriesResponse
|
var resp TimeseriesResponse
|
||||||
if err := c.getJSON(url, &resp); err != nil {
|
if err := c.getJSON(url, &resp); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &resp, nil
|
idStr := fmt.Sprintf("%d", itemID)
|
||||||
|
if entry, ok := resp.Data[idStr]; ok {
|
||||||
|
return &entry, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no 5m data for item %d", itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get1Hour fetches 1-hour average data for an item.
|
// Get1Hour fetches 1-hour average data for an item.
|
||||||
func (c *Client) Get1Hour(itemID int) (*TimeseriesResponse, error) {
|
func (c *Client) Get1Hour(itemID int) (*VolumeEntry, error) {
|
||||||
url := fmt.Sprintf("%s/1h?id=%d", c.baseURL, itemID)
|
url := fmt.Sprintf("%s/1h?id=%d", c.baseURL, itemID)
|
||||||
var resp TimeseriesResponse
|
var resp TimeseriesResponse
|
||||||
if err := c.getJSON(url, &resp); err != nil {
|
if err := c.getJSON(url, &resp); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &resp, nil
|
idStr := fmt.Sprintf("%d", itemID)
|
||||||
|
if entry, ok := resp.Data[idStr]; ok {
|
||||||
|
return &entry, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no 1h data for item %d", itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) getJSON(url string, dest interface{}) error {
|
func (c *Client) getJSON(url string, dest any) error {
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating request: %w", err)
|
return fmt.Errorf("creating request: %w", err)
|
||||||
@@ -131,7 +140,7 @@ func (c *Client) getJSON(url string, dest interface{}) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("executing request: %w", err)
|
return fmt.Errorf("executing request: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|||||||
268
scripts/rsw/internal/prices/integration_test.go
Normal file
268
scripts/rsw/internal/prices/integration_test.go
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
package prices_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/runescape-wiki/rsw/internal/prices"
|
||||||
|
)
|
||||||
|
|
||||||
|
const osrsPriceBaseURL = "https://prices.runescape.wiki/api/v1/osrs"
|
||||||
|
|
||||||
|
func osrsClient() *prices.Client {
|
||||||
|
return prices.NewClient(osrsPriceBaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OSRS Mapping Tests ---
|
||||||
|
|
||||||
|
func TestOSRS_GetMapping_ReturnsItems(t *testing.T) {
|
||||||
|
items, err := osrsClient().GetMapping()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetMapping failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) < 100 {
|
||||||
|
t.Fatalf("expected at least 100 items, got %d", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOSRS_GetMapping_ContainsAbyssalWhip(t *testing.T) {
|
||||||
|
items, err := osrsClient().GetMapping()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetMapping failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, item := range items {
|
||||||
|
if strings.EqualFold(item.Name, "abyssal whip") {
|
||||||
|
found = true
|
||||||
|
if item.ID != 4151 {
|
||||||
|
t.Errorf("expected Abyssal whip ID to be 4151, got %d", item.ID)
|
||||||
|
}
|
||||||
|
if !item.Members {
|
||||||
|
t.Error("expected Abyssal whip to be members-only")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("expected to find 'Abyssal whip' in mapping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OSRS MappingCache Tests ---
|
||||||
|
|
||||||
|
func TestOSRS_MappingCache_LookupByName(t *testing.T) {
|
||||||
|
mc := prices.NewMappingCache(osrsClient(), "osrs")
|
||||||
|
if err := mc.Load(); err != nil {
|
||||||
|
t.Fatalf("Load failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
item := mc.LookupByName("Abyssal whip")
|
||||||
|
if item == nil {
|
||||||
|
t.Fatal("expected to find Abyssal whip")
|
||||||
|
}
|
||||||
|
if item.ID != 4151 {
|
||||||
|
t.Errorf("expected ID 4151, got %d", item.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOSRS_MappingCache_LookupByName_CaseInsensitive(t *testing.T) {
|
||||||
|
mc := prices.NewMappingCache(osrsClient(), "osrs")
|
||||||
|
if err := mc.Load(); err != nil {
|
||||||
|
t.Fatalf("Load failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
item := mc.LookupByName("ABYSSAL WHIP")
|
||||||
|
if item == nil {
|
||||||
|
t.Fatal("expected case-insensitive lookup to find Abyssal whip")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOSRS_MappingCache_SearchByName(t *testing.T) {
|
||||||
|
mc := prices.NewMappingCache(osrsClient(), "osrs")
|
||||||
|
if err := mc.Load(); err != nil {
|
||||||
|
t.Fatalf("Load failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := mc.SearchByName("dragon")
|
||||||
|
if len(results) == 0 {
|
||||||
|
t.Fatal("expected to find items containing 'dragon'")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range results {
|
||||||
|
if !strings.Contains(strings.ToLower(item.Name), "dragon") {
|
||||||
|
t.Errorf("result %q does not contain 'dragon'", item.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOSRS_MappingCache_LookupByID(t *testing.T) {
|
||||||
|
mc := prices.NewMappingCache(osrsClient(), "osrs")
|
||||||
|
if err := mc.Load(); err != nil {
|
||||||
|
t.Fatalf("Load failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
item := mc.LookupByID(4151)
|
||||||
|
if item == nil {
|
||||||
|
t.Fatal("expected to find item 4151")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(item.Name, "abyssal whip") {
|
||||||
|
t.Errorf("expected 'Abyssal whip', got %q", item.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OSRS Latest Price Tests ---
|
||||||
|
|
||||||
|
func TestOSRS_GetLatestForItem_AbyssalWhip(t *testing.T) {
|
||||||
|
latest, err := osrsClient().GetLatestForItem(4151)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestForItem failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if latest.High == nil && latest.Low == nil {
|
||||||
|
t.Error("expected at least one of High or Low to be non-nil")
|
||||||
|
}
|
||||||
|
if latest.High != nil && *latest.High <= 0 {
|
||||||
|
t.Errorf("expected positive high price, got %d", *latest.High)
|
||||||
|
}
|
||||||
|
if latest.Low != nil && *latest.Low <= 0 {
|
||||||
|
t.Errorf("expected positive low price, got %d", *latest.Low)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOSRS_GetLatestForItem_Nonexistent(t *testing.T) {
|
||||||
|
_, err := osrsClient().GetLatestForItem(999999999)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for nonexistent item ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OSRS Timeseries Tests ---
|
||||||
|
|
||||||
|
func TestOSRS_Get1Hour_AbyssalWhip(t *testing.T) {
|
||||||
|
entry, err := osrsClient().Get1Hour(4151)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get1Hour failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.AvgHighPrice == nil {
|
||||||
|
t.Error("expected AvgHighPrice to be non-nil for Abyssal whip")
|
||||||
|
} else if *entry.AvgHighPrice <= 0 {
|
||||||
|
t.Errorf("expected positive AvgHighPrice, got %d", *entry.AvgHighPrice)
|
||||||
|
}
|
||||||
|
if entry.AvgLowPrice == nil {
|
||||||
|
t.Error("expected AvgLowPrice to be non-nil for Abyssal whip")
|
||||||
|
} else if *entry.AvgLowPrice <= 0 {
|
||||||
|
t.Errorf("expected positive AvgLowPrice, got %d", *entry.AvgLowPrice)
|
||||||
|
}
|
||||||
|
if entry.HighVolume <= 0 {
|
||||||
|
t.Errorf("expected positive buy volume, got %d", entry.HighVolume)
|
||||||
|
}
|
||||||
|
if entry.LowVolume <= 0 {
|
||||||
|
t.Errorf("expected positive sell volume, got %d", entry.LowVolume)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOSRS_Get5Min_AbyssalWhip(t *testing.T) {
|
||||||
|
entry, err := osrsClient().Get5Min(4151)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get5Min failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.AvgHighPrice == nil {
|
||||||
|
t.Error("expected AvgHighPrice to be non-nil for Abyssal whip")
|
||||||
|
} else if *entry.AvgHighPrice <= 0 {
|
||||||
|
t.Errorf("expected positive AvgHighPrice, got %d", *entry.AvgHighPrice)
|
||||||
|
}
|
||||||
|
if entry.AvgLowPrice == nil {
|
||||||
|
t.Error("expected AvgLowPrice to be non-nil for Abyssal whip")
|
||||||
|
} else if *entry.AvgLowPrice <= 0 {
|
||||||
|
t.Errorf("expected positive AvgLowPrice, got %d", *entry.AvgLowPrice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RS3 Client Tests ---
|
||||||
|
|
||||||
|
func TestRS3_GetDetail_AbyssalWhip(t *testing.T) {
|
||||||
|
c := prices.NewRS3Client()
|
||||||
|
detail, err := c.GetDetail(4151)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDetail failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if detail.Name != "Abyssal whip" {
|
||||||
|
t.Errorf("expected name 'Abyssal whip', got %q", detail.Name)
|
||||||
|
}
|
||||||
|
if detail.CurrentPrice == "" {
|
||||||
|
t.Error("expected non-empty current price")
|
||||||
|
}
|
||||||
|
if detail.ID != 4151 {
|
||||||
|
t.Errorf("expected ID 4151, got %d", detail.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRS3_GetDetail_BluePartyhat(t *testing.T) {
|
||||||
|
c := prices.NewRS3Client()
|
||||||
|
detail, err := c.GetDetail(1042)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDetail failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(strings.ToLower(detail.Name), "partyhat") &&
|
||||||
|
!strings.Contains(strings.ToLower(detail.Name), "party hat") {
|
||||||
|
t.Errorf("expected name to contain 'partyhat', got %q", detail.Name)
|
||||||
|
}
|
||||||
|
if detail.CurrentPrice == "" {
|
||||||
|
t.Error("expected non-empty current price")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRS3_GetDetail_HasTrends(t *testing.T) {
|
||||||
|
c := prices.NewRS3Client()
|
||||||
|
detail, err := c.GetDetail(4151)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDetail failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if detail.Day30Change == "" {
|
||||||
|
t.Error("expected non-empty 30-day change")
|
||||||
|
}
|
||||||
|
if detail.Day90Change == "" {
|
||||||
|
t.Error("expected non-empty 90-day change")
|
||||||
|
}
|
||||||
|
if detail.Day180Change == "" {
|
||||||
|
t.Error("expected non-empty 180-day change")
|
||||||
|
}
|
||||||
|
|
||||||
|
validTrends := map[string]bool{"positive": true, "negative": true, "neutral": true}
|
||||||
|
if !validTrends[detail.Day30Trend] {
|
||||||
|
t.Errorf("unexpected 30-day trend %q", detail.Day30Trend)
|
||||||
|
}
|
||||||
|
if !validTrends[detail.Day90Trend] {
|
||||||
|
t.Errorf("unexpected 90-day trend %q", detail.Day90Trend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRS3_GetDetail_Nonexistent(t *testing.T) {
|
||||||
|
c := prices.NewRS3Client()
|
||||||
|
_, err := c.GetDetail(999999999)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for nonexistent RS3 item ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RS3 Exchange Module Tests (via wiki client) ---
|
||||||
|
// These are in the wiki package test file, but we verify the RS3 price
|
||||||
|
// client integrates correctly with real item IDs from exchange modules.
|
||||||
|
|
||||||
|
func TestRS3_GetDetail_Members_Field(t *testing.T) {
|
||||||
|
c := prices.NewRS3Client()
|
||||||
|
detail, err := c.GetDetail(4151)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDetail failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if detail.Members != "true" {
|
||||||
|
t.Errorf("expected Abyssal whip members='true', got %q", detail.Members)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,16 +18,19 @@ type MappingCache struct {
|
|||||||
byID map[int]*MappingItem
|
byID map[int]*MappingItem
|
||||||
byName map[string]*MappingItem
|
byName map[string]*MappingItem
|
||||||
cacheDir string
|
cacheDir string
|
||||||
|
game string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMappingCache creates a mapping cache backed by the given client.
|
// NewMappingCache creates a mapping cache backed by the given client.
|
||||||
func NewMappingCache(client *Client) *MappingCache {
|
// The game parameter scopes the cache file (e.g. "osrs" or "rs3").
|
||||||
|
func NewMappingCache(client *Client, game string) *MappingCache {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
return &MappingCache{
|
return &MappingCache{
|
||||||
client: client,
|
client: client,
|
||||||
byID: make(map[int]*MappingItem),
|
byID: make(map[int]*MappingItem),
|
||||||
byName: make(map[string]*MappingItem),
|
byName: make(map[string]*MappingItem),
|
||||||
cacheDir: filepath.Join(home, ".rsw", "cache"),
|
cacheDir: filepath.Join(home, ".rsw", "cache"),
|
||||||
|
game: game,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +84,8 @@ func (mc *MappingCache) index() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (mc *MappingCache) cachePath() string {
|
func (mc *MappingCache) cachePath() string {
|
||||||
return filepath.Join(mc.cacheDir, "mapping.json")
|
filename := mc.game + "_mapping.json"
|
||||||
|
return filepath.Join(mc.cacheDir, filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mc *MappingCache) loadFromDisk() bool {
|
func (mc *MappingCache) loadFromDisk() bool {
|
||||||
|
|||||||
125
scripts/rsw/internal/prices/rs3client.go
Normal file
125
scripts/rsw/internal/prices/rs3client.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package prices
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const rs3BaseURL = "https://secure.runescape.com/m=itemdb_rs"
|
||||||
|
|
||||||
|
// RS3Client wraps HTTP requests to the Jagex RS3 Grand Exchange API.
|
||||||
|
type RS3Client struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRS3Client creates an RS3 price API client.
|
||||||
|
func NewRS3Client() *RS3Client {
|
||||||
|
return &RS3Client{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RS3ItemDetail holds current price and trend data for an RS3 item.
|
||||||
|
type RS3ItemDetail struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Members string
|
||||||
|
CurrentPrice string // pre-formatted, e.g. "91.6b"
|
||||||
|
CurrentTrend string
|
||||||
|
TodayPrice int
|
||||||
|
TodayTrend string
|
||||||
|
Day30Change string
|
||||||
|
Day30Trend string
|
||||||
|
Day90Change string
|
||||||
|
Day90Trend string
|
||||||
|
Day180Change string
|
||||||
|
Day180Trend string
|
||||||
|
}
|
||||||
|
|
||||||
|
// rs3DetailResponse matches the JSON shape from the Jagex catalogue API.
|
||||||
|
type rs3DetailResponse struct {
|
||||||
|
Item struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Members string `json:"members"`
|
||||||
|
Current struct {
|
||||||
|
Trend string `json:"trend"`
|
||||||
|
Price any `json:"price"` // string or int
|
||||||
|
} `json:"current"`
|
||||||
|
Today struct {
|
||||||
|
Trend string `json:"trend"`
|
||||||
|
Price any `json:"price"` // int or string
|
||||||
|
} `json:"today"`
|
||||||
|
Day30 struct {
|
||||||
|
Trend string `json:"trend"`
|
||||||
|
Change string `json:"change"`
|
||||||
|
} `json:"day30"`
|
||||||
|
Day90 struct {
|
||||||
|
Trend string `json:"trend"`
|
||||||
|
Change string `json:"change"`
|
||||||
|
} `json:"day90"`
|
||||||
|
Day180 struct {
|
||||||
|
Trend string `json:"trend"`
|
||||||
|
Change string `json:"change"`
|
||||||
|
} `json:"day180"`
|
||||||
|
} `json:"item"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDetail fetches current price and trend data for an RS3 item by ID.
|
||||||
|
func (c *RS3Client) GetDetail(itemID int) (*RS3ItemDetail, error) {
|
||||||
|
url := fmt.Sprintf("%s/api/catalogue/detail.json?item=%d", rs3BaseURL, itemID)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("executing request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("RS3 API returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw rs3DetailResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("decoding response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := &RS3ItemDetail{
|
||||||
|
ID: raw.Item.ID,
|
||||||
|
Name: raw.Item.Name,
|
||||||
|
Description: raw.Item.Description,
|
||||||
|
Members: raw.Item.Members,
|
||||||
|
CurrentPrice: fmt.Sprintf("%v", raw.Item.Current.Price),
|
||||||
|
CurrentTrend: raw.Item.Current.Trend,
|
||||||
|
TodayTrend: raw.Item.Today.Trend,
|
||||||
|
Day30Change: raw.Item.Day30.Change,
|
||||||
|
Day30Trend: raw.Item.Day30.Trend,
|
||||||
|
Day90Change: raw.Item.Day90.Change,
|
||||||
|
Day90Trend: raw.Item.Day90.Trend,
|
||||||
|
Day180Change: raw.Item.Day180.Change,
|
||||||
|
Day180Trend: raw.Item.Day180.Trend,
|
||||||
|
}
|
||||||
|
|
||||||
|
// today.price can be int or string
|
||||||
|
switch v := raw.Item.Today.Price.(type) {
|
||||||
|
case float64:
|
||||||
|
detail.TodayPrice = int(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return detail, nil
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ func (c *Client) get(params url.Values, dest interface{}) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("executing request: %w", err)
|
return fmt.Errorf("executing request: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|||||||
114
scripts/rsw/internal/wiki/exchange.go
Normal file
114
scripts/rsw/internal/wiki/exchange.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package wiki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RS3ExchangeItem holds metadata parsed from a Module:Exchange/<name> page.
|
||||||
|
type RS3ExchangeItem struct {
|
||||||
|
ItemID int
|
||||||
|
Name string
|
||||||
|
Value int
|
||||||
|
Limit int
|
||||||
|
Members bool
|
||||||
|
Category string
|
||||||
|
Examine string
|
||||||
|
}
|
||||||
|
|
||||||
|
// revisionResponse matches the JSON from action=query&prop=revisions&rvslots=main.
|
||||||
|
type revisionResponse struct {
|
||||||
|
Query struct {
|
||||||
|
Pages map[string]struct {
|
||||||
|
PageID int `json:"pageid"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Revisions []struct {
|
||||||
|
Slots struct {
|
||||||
|
Main struct {
|
||||||
|
Content string `json:"*"`
|
||||||
|
} `json:"main"`
|
||||||
|
} `json:"slots"`
|
||||||
|
} `json:"revisions"`
|
||||||
|
} `json:"pages"`
|
||||||
|
} `json:"query"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExchangeModule fetches Module:Exchange/<name> and parses item metadata.
|
||||||
|
// Returns nil, nil if the module page does not exist (item not tradeable).
|
||||||
|
func (c *Client) GetExchangeModule(itemName string) (*RS3ExchangeItem, error) {
|
||||||
|
title := "Module:Exchange/" + itemName
|
||||||
|
|
||||||
|
params := url.Values{
|
||||||
|
"action": {"query"},
|
||||||
|
"titles": {title},
|
||||||
|
"prop": {"revisions"},
|
||||||
|
"rvprop": {"content"},
|
||||||
|
"rvslots": {"main"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp revisionResponse
|
||||||
|
if err := c.get(params, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching exchange module: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for id, page := range resp.Query.Pages {
|
||||||
|
// Page ID -1 means the page doesn't exist
|
||||||
|
if id == "-1" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if len(page.Revisions) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content := page.Revisions[0].Slots.Main.Content
|
||||||
|
return parseLuaModule(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
reItemID = regexp.MustCompile(`itemId\s*=\s*(\d+)`)
|
||||||
|
reItem = regexp.MustCompile(`item\s*=\s*'((?:[^'\\]|\\.)*)'`)
|
||||||
|
reValue = regexp.MustCompile(`value\s*=\s*(\d+)`)
|
||||||
|
reLimit = regexp.MustCompile(`limit\s*=\s*(\d+)`)
|
||||||
|
reMembers = regexp.MustCompile(`members\s*=\s*(true|false)`)
|
||||||
|
reCategory = regexp.MustCompile(`category\s*=\s*'((?:[^'\\]|\\.)*)'`)
|
||||||
|
reExamine = regexp.MustCompile(`examine\s*=\s*'((?:[^'\\]|\\.)*)'`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseLuaModule(content string) *RS3ExchangeItem {
|
||||||
|
item := &RS3ExchangeItem{}
|
||||||
|
|
||||||
|
if m := reItemID.FindStringSubmatch(content); m != nil {
|
||||||
|
item.ItemID, _ = strconv.Atoi(m[1])
|
||||||
|
}
|
||||||
|
if m := reItem.FindStringSubmatch(content); m != nil {
|
||||||
|
item.Name = unescapeLua(m[1])
|
||||||
|
}
|
||||||
|
if m := reValue.FindStringSubmatch(content); m != nil {
|
||||||
|
item.Value, _ = strconv.Atoi(m[1])
|
||||||
|
}
|
||||||
|
if m := reLimit.FindStringSubmatch(content); m != nil {
|
||||||
|
item.Limit, _ = strconv.Atoi(m[1])
|
||||||
|
}
|
||||||
|
if m := reMembers.FindStringSubmatch(content); m != nil {
|
||||||
|
item.Members = strings.ToLower(m[1]) == "true"
|
||||||
|
}
|
||||||
|
if m := reCategory.FindStringSubmatch(content); m != nil {
|
||||||
|
item.Category = unescapeLua(m[1])
|
||||||
|
}
|
||||||
|
if m := reExamine.FindStringSubmatch(content); m != nil {
|
||||||
|
item.Examine = unescapeLua(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
// unescapeLua handles Lua string escape sequences in single-quoted strings.
|
||||||
|
func unescapeLua(s string) string {
|
||||||
|
return strings.NewReplacer(`\'`, `'`, `\\`, `\`).Replace(s)
|
||||||
|
}
|
||||||
108
scripts/rsw/internal/wiki/exchange_integration_test.go
Normal file
108
scripts/rsw/internal/wiki/exchange_integration_test.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package wiki_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- RS3 Exchange Module Tests ---
|
||||||
|
|
||||||
|
func TestRS3_GetExchangeModule_AbyssalWhip(t *testing.T) {
|
||||||
|
item, err := rs3Client().GetExchangeModule("Abyssal whip")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetExchangeModule failed: %v", err)
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
t.Fatal("expected non-nil exchange item for Abyssal whip")
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.ItemID != 4151 {
|
||||||
|
t.Errorf("expected item ID 4151, got %d", item.ItemID)
|
||||||
|
}
|
||||||
|
if item.Name != "Abyssal whip" {
|
||||||
|
t.Errorf("expected name 'Abyssal whip', got %q", item.Name)
|
||||||
|
}
|
||||||
|
if !item.Members {
|
||||||
|
t.Error("expected Abyssal whip to be members-only")
|
||||||
|
}
|
||||||
|
if item.Limit <= 0 {
|
||||||
|
t.Errorf("expected positive buy limit, got %d", item.Limit)
|
||||||
|
}
|
||||||
|
if item.Value <= 0 {
|
||||||
|
t.Errorf("expected positive store value, got %d", item.Value)
|
||||||
|
}
|
||||||
|
if item.Examine == "" {
|
||||||
|
t.Error("expected non-empty examine text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRS3_GetExchangeModule_BluePartyhat(t *testing.T) {
|
||||||
|
item, err := rs3Client().GetExchangeModule("Blue partyhat")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetExchangeModule failed: %v", err)
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
t.Fatal("expected non-nil exchange item for Blue partyhat")
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.ItemID != 1042 {
|
||||||
|
t.Errorf("expected item ID 1042, got %d", item.ItemID)
|
||||||
|
}
|
||||||
|
if item.Members {
|
||||||
|
t.Error("expected Blue partyhat to not be members-only")
|
||||||
|
}
|
||||||
|
if item.Limit <= 0 {
|
||||||
|
t.Errorf("expected positive buy limit, got %d", item.Limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRS3_GetExchangeModule_DragonBones(t *testing.T) {
|
||||||
|
item, err := rs3Client().GetExchangeModule("Dragon bones")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetExchangeModule failed: %v", err)
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
t.Fatal("expected non-nil exchange item for Dragon bones")
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.ItemID <= 0 {
|
||||||
|
t.Error("expected positive item ID")
|
||||||
|
}
|
||||||
|
if item.Name != "Dragon bones" {
|
||||||
|
t.Errorf("expected name 'Dragon bones', got %q", item.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRS3_GetExchangeModule_NonTradeableItem(t *testing.T) {
|
||||||
|
// "Quest point cape" is not tradeable, so no exchange module exists
|
||||||
|
item, err := rs3Client().GetExchangeModule("Quest point cape")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetExchangeModule failed: %v", err)
|
||||||
|
}
|
||||||
|
if item != nil {
|
||||||
|
t.Error("expected nil for non-tradeable item")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRS3_GetExchangeModule_NonExistentItem(t *testing.T) {
|
||||||
|
item, err := rs3Client().GetExchangeModule("Completely Fake Item That Does Not Exist")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetExchangeModule failed: %v", err)
|
||||||
|
}
|
||||||
|
if item != nil {
|
||||||
|
t.Error("expected nil for non-existent item")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRS3_GetExchangeModule_HasCategory(t *testing.T) {
|
||||||
|
item, err := rs3Client().GetExchangeModule("Abyssal whip")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetExchangeModule failed: %v", err)
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
t.Fatal("expected non-nil exchange item")
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Category == "" {
|
||||||
|
t.Error("expected non-empty category")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user