2022-03-30 03:23:41 +00:00
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
2022-04-01 04:56:29 +00:00
"math"
2022-03-30 03:23:41 +00:00
"math/rand"
"net/http"
"os"
"sort"
"strings"
"sync"
"text/template"
"time"
2022-07-31 18:45:48 +00:00
2023-11-24 20:42:05 +00:00
inventorytools "code.mashffxiv.deadbeef.codes/MashPotato/gilgetter/inventorytools"
2022-03-30 03:23:41 +00:00
)
2022-04-03 17:48:15 +00:00
var (
2022-07-31 23:54:45 +00:00
items map [ int ] * MarketItem // hash map used to quickly guarantee uniqueness of MarketItems
itemSlice [ ] MarketItem // rank-ordered slice of the same data in the items map
nameUpdateQueue chan int // requests sent to this channel use xivapi https://xivapi.com/
priceUpdateQueue chan int // requests sent to this channel use universalis.app API https://universalis.app/docs/index.html
updateRequestWg sync . WaitGroup // Used to wait until the queues are empty indicating all items have up to date names and prices
t * template . Template // Used to cache HTML template parsing, done once at startup
lastFullPriceUpdateTime time . Time // The last time the prices were updated from Universalis (both start and finish. Start if ongoing, finished when not running)
startFullPriceUpdateTime time . Time // The last time the prices were started to be updated
lastSortTime time . Time // The last time the itemSlice was sorted by metric
lastUniversalisRequestTime time . Time // The last time any request was made to Universalis
priceUpdateProgress int // an integer from 0-100 indicating percentage of any ongoing Universalis update. If 100, then the update is complete.
world , datacenter , universalisPriceEndpoint string // filter Universalis to this world name
2023-01-08 07:17:43 +00:00
priceUpdateErrors map [ int ] bool // if an item ID exists in here, then there was an error when updating its price. Gets reset after priceUpdateQueue is drained.
2023-01-28 19:22:35 +00:00
withdrawItemsAhk [ ] byte // contains the processed ahk template of items that need to be withdrawn for crafting or selling
2022-07-31 23:54:45 +00:00
retainers [ ] string // contains name of retainers
craftingCheckoutItemIDs [ ] int // The item IDs which were selected on the crafting checkout page, used for shopping list
inventories inventorytools . Inventories
2022-04-03 17:48:15 +00:00
)
// Object to represent a crafting material / ingredient in a recipe
2022-03-30 03:23:41 +00:00
type CraftingMaterial struct {
ID int ` json:"id" `
Quantity int ` json:"quantity" `
}
// Object to represent an item that can be sold on the market
type MarketItem struct {
ID int ` json:"id" `
Name string ` json:"name,omitempty" `
2022-07-23 05:23:26 +00:00
Job string ` json:"job,omitempty" `
2022-12-06 00:02:38 +00:00
CraftingMetric int ` json:"craftingmetric,omitempty" `
ListingMetric int ` json:"listingmetric,omitempty" `
2022-04-15 20:02:01 +00:00
MaterialCost int ` json:"materialcost,omitempty" `
2022-03-30 03:23:41 +00:00
Velocity int ` json:"velocity,omitempty" `
CraftingMaterials [ ] CraftingMaterial ` json:"craftingmaterials,omitempty" `
MarketBoardListing * UniversalisApiMarketBoardListingResponse ` json:"marketboardlisting,omitempty" `
2022-08-11 21:33:46 +00:00
InStock bool ` json:"instock" ` // true if item is in character inventory, retainer market listing or retainer bag
2022-03-30 03:23:41 +00:00
}
// JSON object mapping for HTTP response from XIVAPI
type XivApiSearchResponse struct {
Pagination struct {
Page int ` json:"Page" `
PageNext interface { } ` json:"PageNext" `
PagePrev interface { } ` json:"PagePrev" `
PageTotal int ` json:"PageTotal" `
Results int ` json:"Results" `
ResultsPerPage int ` json:"ResultsPerPage" `
ResultsTotal int ` json:"ResultsTotal" `
} ` json:"Pagination" `
Results [ ] struct {
ID int ` json:"ID" `
Icon string ` json:"Icon" `
Name string ` json:"Name" `
URL string ` json:"Url" `
URLType string ` json:"UrlType" `
Score int ` json:"_Score" `
} ` json:"Results" `
SpeedMs int ` json:"SpeedMs" `
}
// JSON object mapping for HTTP response from Universalis
type UniversalisApiMarketBoardListingResponse struct {
ItemID int ` json:"itemID" `
WorldID int ` json:"worldID" `
LastUploadTime int64 ` json:"lastUploadTime" `
Listings [ ] struct {
LastReviewTime int ` json:"lastReviewTime" `
PricePerUnit int ` json:"pricePerUnit" `
Quantity int ` json:"quantity" `
StainID int ` json:"stainID" `
CreatorName string ` json:"creatorName" `
CreatorID string ` json:"creatorID" `
Hq bool ` json:"hq" `
IsCrafted bool ` json:"isCrafted" `
ListingID interface { } ` json:"listingID" `
Materia [ ] interface { } ` json:"materia" `
OnMannequin bool ` json:"onMannequin" `
RetainerCity int ` json:"retainerCity" `
RetainerID string ` json:"retainerID" `
RetainerName string ` json:"retainerName" `
SellerID string ` json:"sellerID" `
Total int ` json:"total" `
} ` json:"listings" `
RecentHistory [ ] struct {
Hq bool ` json:"hq" `
PricePerUnit int ` json:"pricePerUnit" `
Quantity int ` json:"quantity" `
Timestamp int ` json:"timestamp" `
BuyerName string ` json:"buyerName" `
Total int ` json:"total" `
} ` json:"recentHistory" `
CurrentAveragePrice float64 ` json:"currentAveragePrice" `
CurrentAveragePriceNQ float64 ` json:"currentAveragePriceNQ" `
CurrentAveragePriceHQ float64 ` json:"currentAveragePriceHQ" `
RegularSaleVelocity float64 ` json:"regularSaleVelocity" `
NqSaleVelocity float64 ` json:"nqSaleVelocity" `
HqSaleVelocity float64 ` json:"hqSaleVelocity" `
AveragePrice float64 ` json:"averagePrice" `
AveragePriceNQ float64 ` json:"averagePriceNQ" `
AveragePriceHQ float64 ` json:"averagePriceHQ" `
MinPrice int ` json:"minPrice" `
MinPriceNQ int ` json:"minPriceNQ" `
MinPriceHQ int ` json:"minPriceHQ" `
MaxPrice int ` json:"maxPrice" `
MaxPriceNQ int ` json:"maxPriceNQ" `
MaxPriceHQ int ` json:"maxPriceHQ" `
StackSizeHistogram struct {
Num1 int ` json:"1" `
} ` json:"stackSizeHistogram" `
StackSizeHistogramNQ struct {
} ` json:"stackSizeHistogramNQ" `
StackSizeHistogramHQ struct {
Num1 int ` json:"1" `
} ` json:"stackSizeHistogramHQ" `
WorldName string ` json:"worldName" `
}
2022-04-03 17:48:15 +00:00
// Build queues/channels, load crafting recipes, and launch worker goroutines
2022-03-30 03:23:41 +00:00
func init ( ) {
log . Println ( "gilgetter init" )
2022-04-04 01:24:30 +00:00
// Load application configuration from environment variables
// Validate that all required environment variables are set
envVars := make ( map [ string ] string )
envVars [ "world" ] = os . Getenv ( "world" )
2022-07-31 23:54:45 +00:00
envVars [ "datacenter" ] = os . Getenv ( "datacenter" )
2022-04-04 01:24:30 +00:00
for key , value := range envVars {
if value == "" {
log . Fatalf ( "required environment variable '%s' is not set or has empty value" , key )
}
}
2022-04-30 02:15:54 +00:00
retainers = make ( [ ] string , 0 )
for i := 0 ; true ; i ++ {
retainer := os . Getenv ( fmt . Sprintf ( "retainer_%d" , i ) )
if retainer == "" {
break
}
retainers = append ( retainers , retainer )
}
2022-04-04 01:24:30 +00:00
world = envVars [ "world" ]
2022-07-31 23:54:45 +00:00
datacenter = envVars [ "datacenter" ]
universalisPriceEndpoint = world
2022-03-30 03:23:41 +00:00
updateRequestWg = sync . WaitGroup { }
2022-03-30 13:39:54 +00:00
nameUpdateQueue = make ( chan int , 8192 )
priceUpdateQueue = make ( chan int , 8192 )
2023-01-08 07:17:43 +00:00
priceUpdateErrors = make ( map [ int ] bool )
2022-03-30 03:23:41 +00:00
items = make ( map [ int ] * MarketItem )
2022-03-31 02:51:01 +00:00
loadRecipes ( )
2023-01-28 20:27:58 +00:00
for i := 0 ; i < 8 ; i ++ {
2022-03-30 03:23:41 +00:00
go nameUpdateWorker ( )
go priceUpdateWorker ( )
}
2022-04-30 00:26:30 +00:00
go reSortWorker ( )
2022-03-30 03:23:41 +00:00
2022-03-31 02:51:01 +00:00
priceUpdateProgress = 100 // must be set to 100 to allow a price update to occur, to prevent concurrent price update requests
err := fullPriceRefresh ( )
if err != nil {
log . Fatalf ( "init failed to refresh price data: %v" , err )
2022-03-30 03:23:41 +00:00
}
2022-04-30 00:26:30 +00:00
lastFullPriceUpdateTime = time . Now ( )
2022-03-30 03:23:41 +00:00
itemSlice = make ( [ ] MarketItem , 0 )
2023-01-28 07:42:47 +00:00
2023-01-28 19:22:35 +00:00
withdrawItemsAhk = make ( [ ] byte , 0 )
2022-03-31 02:51:01 +00:00
}
2022-03-30 03:23:41 +00:00
2022-03-31 02:51:01 +00:00
// HTTP routing
func main ( ) {
2022-04-03 17:48:15 +00:00
// Parse all HTML template files
2022-03-30 03:23:41 +00:00
var err error
t , err = template . ParseGlob ( "./templates/*" )
if err != nil {
log . Fatalf ( "couldn't parse HTML templates: %v" , err )
}
2022-04-03 17:48:15 +00:00
// Serve public static files such as css, js, images
2022-03-30 03:23:41 +00:00
http . Handle ( "/public/" , http . StripPrefix ( "/public/" , http . FileServer ( http . Dir ( "public" ) ) ) )
2023-01-28 07:42:47 +00:00
// Download .ahk file
http . HandleFunc ( "/auto-xiv" , autoXivHandler )
2022-03-30 03:23:41 +00:00
// Page Handlers
// Anything that is responsible for the base elements of a viewable web page
http . HandleFunc ( "/" , homePageHandler )
2022-04-04 01:46:18 +00:00
http . HandleFunc ( "/stale-items" , staleItemsPageHandler )
2022-04-30 05:59:59 +00:00
http . HandleFunc ( "/crafting-checkout" , craftingCheckoutPageHandler )
2022-07-31 18:45:48 +00:00
http . HandleFunc ( "/inventory" , inventoryPageHandler )
2022-10-30 05:21:46 +00:00
http . HandleFunc ( "/listings" , listingsPageHandler )
2022-03-30 03:23:41 +00:00
2022-03-31 02:51:01 +00:00
// API Handlers
// Anything that is called by the front end but is not a web page
2022-04-30 00:33:16 +00:00
http . HandleFunc ( "/full-price-update" , fullPriceUpdateHandler )
2022-04-30 01:00:13 +00:00
http . HandleFunc ( "/price-update" , singlePriceUpdateHandler )
2022-04-03 06:20:25 +00:00
http . HandleFunc ( "/status" , statusHandler )
2022-04-03 22:04:43 +00:00
http . HandleFunc ( "/items-list" , itemsListHandler )
2023-03-25 03:20:13 +00:00
http . HandleFunc ( "/bugged-metric-items-list" , buggedMetricItemsListHandler )
2022-04-03 23:10:45 +00:00
http . HandleFunc ( "/stale-items-list" , staleItemsListHandler )
2022-09-24 00:15:02 +00:00
http . HandleFunc ( "/search-listing-retainer" , searchListingRetainerHandler )
2022-09-24 00:20:57 +00:00
http . HandleFunc ( "/search-listing-craftedby" , searchListingCraftedbyHandler )
http . HandleFunc ( "/search-listing-price" , searchListingPriceHandler )
2022-03-31 02:51:01 +00:00
2022-03-30 03:23:41 +00:00
log . Print ( "Service listening on :8080" )
log . Fatal ( http . ListenAndServe ( ":8080" , nil ) )
}
// Processes the nameUpdateQueue which contains item IDs that need to be mapped to names
// sends request to xivapi
func nameUpdateWorker ( ) {
for {
func ( ) { // anonymous function to allow using defer each loop interation
id := <- nameUpdateQueue
updateRequestWg . Add ( 1 )
defer updateRequestWg . Done ( )
// By this point we know the item name is not found in the cache
// and this is the first request to populate it
2022-10-30 05:21:46 +00:00
// Sleep for between 1-1.1 seconds to avoid rate limits and play nice with the service providers
2022-10-30 05:48:04 +00:00
timeFuzzing := rand . Intn ( 500 ) // between 0 and 100ms
2023-01-28 20:27:58 +00:00
time . Sleep ( time . Millisecond * 333 + time . Duration ( timeFuzzing ) * time . Millisecond )
2022-03-30 03:23:41 +00:00
log . Printf ( "ID: %d - Name Update Request" , id )
resp , err := http . Get ( fmt . Sprintf ( "https://xivapi.com/search?filters=ID=%d" , id ) )
if err != nil {
log . Printf ( "request to get item ID '%d' name failed: %v" , id , err )
return
}
if resp . StatusCode != http . StatusOK {
log . Printf ( "response to get item ID '%d' name expected status is '%d' but got '%d'" , id , http . StatusOK , resp . StatusCode )
return
}
body , err := ioutil . ReadAll ( resp . Body )
if err != nil {
log . Printf ( "failed to read xivapi response body for item ID '%d': %v" , id , err )
return
}
xivApiSearchResponse := & XivApiSearchResponse { }
err = json . Unmarshal ( body , xivApiSearchResponse )
if err != nil {
log . Printf ( "failed to unmarshal xivapi response body for item ID '%d': %v" , id , err )
return
}
for _ , xivApiSearchResult := range xivApiSearchResponse . Results {
if xivApiSearchResult . URLType == "Item" {
items [ id ] . Name = xivApiSearchResult . Name
break
}
}
} ( )
}
}
// Processes the priceUpdateQueue which contains item IDs that need prices
// sends request to universalis
func priceUpdateWorker ( ) {
for {
func ( ) { // anonymous function to allow using defer each loop interation
2022-12-04 06:20:41 +00:00
id := <- priceUpdateQueue // Block here until we can read something from the price update queue
2022-04-30 00:26:30 +00:00
2022-03-30 03:23:41 +00:00
defer updateRequestWg . Done ( )
2023-01-08 07:17:43 +00:00
// Sleep for avoid rate limits and play nice with the service providers
2023-01-28 20:27:58 +00:00
timeFuzzing := rand . Intn ( 300 ) // in ms
time . Sleep ( time . Millisecond * 220 + time . Duration ( timeFuzzing ) * time . Millisecond )
2022-03-30 03:23:41 +00:00
// By this point we know the item name is not found in the cache
// and this is the first request to populate it
2022-07-31 23:54:45 +00:00
resp , err := http . Get ( fmt . Sprintf ( "https://universalis.app/api/%s/%d" , universalisPriceEndpoint , id ) )
2022-03-30 03:23:41 +00:00
if err != nil {
log . Printf ( "request to get item ID '%d' price failed: %v" , id , err )
return
}
if resp . StatusCode != http . StatusOK {
log . Printf ( "response to get item ID '%d' price expected status is '%d' but got '%d'" , id , http . StatusOK , resp . StatusCode )
2023-01-08 07:17:43 +00:00
if _ , ok := priceUpdateErrors [ id ] ; ! ok { // if this item hasn't already had an error
go func ( ) { // wait 5 seconds requeue the item if it failed, in goroutine so the priceUpdateWorker isn't blocked
time . Sleep ( time . Second * 5 )
2023-01-08 07:42:53 +00:00
updateRequestWg . Add ( 1 )
2023-01-08 07:17:43 +00:00
priceUpdateQueue <- id
2023-01-08 07:42:53 +00:00
2023-01-08 07:17:43 +00:00
} ( )
}
priceUpdateErrors [ id ] = true // requeue only works one time
2022-03-30 03:23:41 +00:00
return
}
body , err := ioutil . ReadAll ( resp . Body )
if err != nil {
log . Printf ( "failed to read universalis response body for item ID '%d': %v" , id , err )
return
}
universalisApiMarketBoardListingResponse := & UniversalisApiMarketBoardListingResponse { }
err = json . Unmarshal ( body , universalisApiMarketBoardListingResponse )
if err != nil {
log . Printf ( "failed to unmarshal universalis response body for item ID '%d': %v" , id , err )
return
}
2022-07-23 05:23:26 +00:00
2022-03-30 03:23:41 +00:00
items [ id ] . MarketBoardListing = universalisApiMarketBoardListingResponse
} ( )
2022-04-30 00:26:30 +00:00
lastUniversalisRequestTime = time . Now ( )
2022-04-30 01:46:15 +00:00
if priceUpdateProgress < 100 { // Only time this is <100 is when a full price update is occurring, we don't want this changed if single items are being updated though
priceUpdateProgress = ( len ( items ) - len ( priceUpdateQueue ) ) * 100 / ( len ( items ) ) - 1
}
2022-03-30 03:23:41 +00:00
}
}
2022-04-30 00:26:30 +00:00
// Will trigger a resort (destroy and recreate) of the itemSlice when conditions are met
func reSortWorker ( ) {
for {
time . Sleep ( time . Second * 30 )
if time . Now ( ) . Before ( lastSortTime . Add ( time . Second * 60 ) ) { // if the last sort occurred within 60 seconds, ignore
continue
}
if time . Now ( ) . Before ( lastUniversalisRequestTime . Add ( time . Second * 30 ) ) { // if the last universalis price update occurred within 30 seconds, ignore
continue // This is because there might be more coming!
}
2022-04-30 01:02:24 +00:00
if time . Now ( ) . After ( lastUniversalisRequestTime . Add ( time . Second * 60 ) ) { // If the last universalis request was more than a minute ago, we've already sorted
continue
}
2022-04-30 00:26:30 +00:00
sortItemSlice ( )
}
}
2022-03-30 03:23:41 +00:00
// Loads crafting recipe data from the filesystem
2022-03-31 02:51:01 +00:00
// populates the items map
func loadRecipes ( ) {
2022-03-30 03:23:41 +00:00
// Get all files in the crafting-recipes directory
files , err := ioutil . ReadDir ( "crafting-recipes" )
if err != nil {
log . Fatalf ( "failed to read crafting-recipes directory: %v" , err )
}
// Loop through all files in the crafting-recipes directory
for _ , file := range files {
2022-04-03 02:21:26 +00:00
if ! strings . HasSuffix ( file . Name ( ) , ".json" ) {
log . Printf ( "ignoring non-json file '%s'" , file . Name ( ) )
continue
}
2022-03-30 03:23:41 +00:00
log . Printf ( "loading file - crafting-recipes/%s" , file . Name ( ) )
jsonItems , err := loadJsonFile ( fmt . Sprintf ( "crafting-recipes/%s" , file . Name ( ) ) )
if err != nil {
log . Printf ( "error loading crafting-recipe file '%s': %v" , file . Name ( ) , err )
continue
}
// Check for any new unique item encountered gets queued up for
// information updates from xivapi and universalis
for i , item := range * jsonItems {
if _ , found := items [ item . ID ] ; ! found { // item is new/unique
if item . ID == 0 {
log . Printf ( "error found market item with ID of 0 at index %d in file %s" , i , file . Name ( ) )
continue
}
2022-07-23 05:23:26 +00:00
items [ item . ID ] = & MarketItem { ID : item . ID , Job : item . Job , CraftingMaterials : item . CraftingMaterials }
2022-03-30 03:23:41 +00:00
items [ item . ID ] . MarketBoardListing = & UniversalisApiMarketBoardListingResponse { }
// Queue up any crafting items for information updates as well
for j , craftingMaterial := range item . CraftingMaterials {
if _ , found := items [ craftingMaterial . ID ] ; ! found { // item is new/unique
if craftingMaterial . ID == 0 {
log . Printf ( "error found crafting item with ID of 0 in market item with ID of '%d', crafting item index %d in file %s" , item . ID , j , file . Name ( ) )
continue
}
2022-04-30 05:59:59 +00:00
items [ craftingMaterial . ID ] = & MarketItem { ID : craftingMaterial . ID }
2022-03-30 03:23:41 +00:00
items [ craftingMaterial . ID ] . MarketBoardListing = & UniversalisApiMarketBoardListingResponse { }
}
}
}
}
}
}
// Loads a JSON file into a slice of market items
func loadJsonFile ( file string ) ( * [ ] MarketItem , error ) {
// Load the crafting recipes
jsonFile , err := os . Open ( file )
if err != nil {
return nil , fmt . Errorf ( "failed to open file '%s': %v" , file , err )
}
2022-04-03 00:13:16 +00:00
defer jsonFile . Close ( )
2022-03-30 03:23:41 +00:00
jsonBytes , err := ioutil . ReadAll ( jsonFile )
if err != nil {
return nil , fmt . Errorf ( "failed to read file '%s' to bytes: %v" , file , err )
}
jsonItems := make ( [ ] MarketItem , 0 )
json . Unmarshal ( jsonBytes , & jsonItems )
return & jsonItems , nil
}
2022-03-31 02:51:01 +00:00
// Performs a full price refresh of all items from Universalis
func fullPriceRefresh ( ) error {
2022-04-30 00:26:30 +00:00
if time . Since ( lastFullPriceUpdateTime ) < time . Minute * 90 {
2022-03-31 02:51:01 +00:00
return fmt . Errorf ( "price refresh cannot occur less than 90 minutes apart" )
}
2022-03-31 05:01:51 +00:00
if priceUpdateProgress != 100 {
return fmt . Errorf ( "price refresh is already in progress" )
}
2022-03-31 02:51:01 +00:00
for _ , item := range items {
if item . Name == "" { // only update names once unless app restarts
nameUpdateQueue <- item . ID
}
priceUpdateQueue <- item . ID
2022-04-30 00:26:30 +00:00
updateRequestWg . Add ( 1 )
2022-03-31 02:51:01 +00:00
}
2022-04-30 00:26:30 +00:00
startFullPriceUpdateTime = time . Now ( )
lastFullPriceUpdateTime = time . Now ( )
2022-03-31 02:51:01 +00:00
2022-04-30 00:41:05 +00:00
go func ( ) { //anonymous callback function which runs immediately after all items have their prices updated
updateRequestWg . Wait ( ) // wait for all items to be updated
lastFullPriceUpdateTime = time . Now ( )
sortItemSlice ( )
} ( )
2022-04-15 20:02:01 +00:00
2022-04-30 00:26:30 +00:00
priceUpdateProgress = 0
return nil
}
2022-04-15 20:02:01 +00:00
2022-04-30 00:33:16 +00:00
// Destroys and recreates the itemSlice using the items map, sorted by weight
2022-04-30 00:26:30 +00:00
func sortItemSlice ( ) {
2022-04-30 00:41:05 +00:00
2022-04-30 01:00:13 +00:00
log . Printf ( "metric re-sort triggered" )
2022-07-23 04:33:21 +00:00
priceUpdateProgress = 100 // We set this to 100 to ensure any Universalis refresh is in the completed state when we sort the items.
2022-04-30 00:26:30 +00:00
itemSlice = make ( [ ] MarketItem , 0 )
2022-04-16 03:30:35 +00:00
2022-04-30 00:26:30 +00:00
// Calculate the metric for each item
for _ , item := range items {
2022-07-23 04:33:21 +00:00
if len ( item . CraftingMaterials ) < 1 { // If there are no crafting materials for an item (i.e. the item is a crafting material itself), we ignore it.
2022-04-30 00:26:30 +00:00
continue
}
//item.Metric = int(item.MarketBoardListing.HqSaleVelocity * float64(item.MarketBoardListing.MinPriceHQ))
2022-03-31 02:51:01 +00:00
2022-04-30 00:26:30 +00:00
// Subtract cost of crafting materials
item . MaterialCost = 0
for _ , craftingMaterial := range item . CraftingMaterials {
2022-12-04 06:20:41 +00:00
if int ( items [ craftingMaterial . ID ] . MarketBoardListing . AveragePrice ) > 0 { // if we have historical sales data use it
item . MaterialCost += ( craftingMaterial . Quantity * int ( items [ craftingMaterial . ID ] . MarketBoardListing . AveragePrice ) )
} else { // Otherwise use the current min prices
item . MaterialCost += items [ craftingMaterial . ID ] . MarketBoardListing . MinPrice
}
2022-03-31 02:51:01 +00:00
}
2022-04-30 00:26:30 +00:00
// Calculate the base metric based on sale price and velocity
if item . MarketBoardListing . MinPriceHQ != 0 {
2022-05-12 14:49:35 +00:00
// Use HQ by default
item . Velocity = int ( math . Round ( item . MarketBoardListing . HqSaleVelocity * 10 / 2.857143 ) ) // 2.857143 is max universalis sale velocity value, so this gives us an "out of 10" rating
2022-12-06 00:02:38 +00:00
item . CraftingMetric = item . MarketBoardListing . MinPriceHQ
2022-12-09 21:50:32 +00:00
item . ListingMetric = item . CraftingMetric * item . Velocity + item . MarketBoardListing . MinPriceHQ
2022-04-30 00:26:30 +00:00
} else {
2022-05-12 14:49:35 +00:00
// Item is normal quality only for example, housing items
item . Velocity = int ( math . Round ( item . MarketBoardListing . NqSaleVelocity * 10 / 2.857143 ) ) // 2.857143 is max universalis sale velocity value, so this gives us an "out of 10" rating
2022-12-06 00:02:38 +00:00
item . CraftingMetric = item . MarketBoardListing . MinPriceNQ
2022-12-09 21:50:32 +00:00
item . ListingMetric = item . CraftingMetric * item . Velocity + item . MarketBoardListing . MinPriceNQ
2022-04-30 00:26:30 +00:00
}
2022-12-09 21:50:32 +00:00
2022-12-06 00:02:38 +00:00
item . CraftingMetric = ( item . CraftingMetric - item . MaterialCost ) * item . Velocity - item . MaterialCost * 2
2022-04-30 00:26:30 +00:00
// Linear scale down outliers
2022-07-23 22:55:27 +00:00
outlierWeight := 2.0
2022-07-23 18:48:01 +00:00
if float64 ( item . MarketBoardListing . MinPriceHQ ) > item . MarketBoardListing . AveragePriceHQ { // only scale down when current min price is above average price. Do not scale up ever.
2022-12-06 00:02:38 +00:00
item . CraftingMetric = item . CraftingMetric - int ( outlierWeight * ( float64 ( item . MarketBoardListing . MinPriceHQ ) - item . MarketBoardListing . AveragePriceHQ ) )
2022-07-23 18:48:01 +00:00
}
2022-04-30 00:26:30 +00:00
itemSlice = append ( itemSlice , * item )
}
// Sort the item slice by metric
sort . Slice ( itemSlice , func ( i , j int ) bool {
2022-12-06 00:02:38 +00:00
return itemSlice [ i ] . CraftingMetric > itemSlice [ j ] . CraftingMetric
2022-04-30 00:26:30 +00:00
} )
lastSortTime = time . Now ( )
2022-03-31 02:51:01 +00:00
}