bdoap 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/bin/bdoap +2 -1
- data/lib/bdo_alchemy_profits.rb +68 -60
- data/lib/bdo_codex/bdo_codex_searcher.rb +63 -14
- data/lib/central_market/category_search_options.rb +30 -12
- data/lib/central_market/exchange_items.rb +33 -0
- data/lib/central_market/market_searcher.rb +72 -32
- data/lib/utils/constants.rb +28 -7
- data/lib/utils/npc_item_index.rb +27 -0
- data/lib/utils/recipe_logger.rb +30 -5
- data/lib/utils/user_cli.rb +52 -7
- data/lib/version.rb +5 -0
- metadata +3 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2fabb7eadd3b5446aa42832f5ce1164777c8ed9669c1679a16d61138e9be9de7
|
4
|
+
data.tar.gz: 20232aec55bb79082273f1dcdadcc04347d83948b9f3cd2977143672a247d19e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d7ce743bbc6e663ae0967e4a4a1117611ed87d83c3a6b793a76b5c635aafc8270652e4fc489644f87bb70cc0d5b5a73865314c56a6d0a4150a42baa08e2ccdfd
|
7
|
+
data.tar.gz: 1af804e8da23ec52a1551e1e0db2da5fc2b975a7da9ada2ef1ef0b51ed2df46f4fbf4251226aa0002e4e25c1d198e2ac8e78d873332c7238eb6fb072b567e979
|
data/bin/bdoap
CHANGED
data/lib/bdo_alchemy_profits.rb
CHANGED
@@ -29,66 +29,74 @@ require_relative './utils/user_cli'
|
|
29
29
|
require_relative './central_market/market_searcher'
|
30
30
|
require_relative './bdo_codex/bdo_codex_searcher'
|
31
31
|
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
32
|
+
# main module for bdoap. contains the BDOAlchemyProfits class
|
33
|
+
module BDOAP
|
34
|
+
# used to search for profitable alchemy recipes
|
35
|
+
class BDOAlchemyProfits
|
36
|
+
include Utils
|
37
|
+
|
38
|
+
# begin the configuration and search process
|
39
|
+
def start_cli
|
40
|
+
begin
|
41
|
+
options = {}
|
42
|
+
|
43
|
+
OptionParser.new do |opt|
|
44
|
+
opt.on('--silent', '-s') { options[:silent] = true }
|
45
|
+
end.parse!
|
46
|
+
|
47
|
+
# option setup
|
48
|
+
cli = UserCLI.new options
|
49
|
+
|
50
|
+
category = cli.choose_category
|
51
|
+
cli.end_cli if category == 'exit'
|
52
|
+
region = cli.choose_region
|
53
|
+
cli.end_cli if region == 'exit'
|
54
|
+
lang = cli.choose_lang
|
55
|
+
cli.end_cli if lang == 'exit'
|
56
|
+
aggression = cli.choose_aggression
|
57
|
+
cli.end_cli if aggression == 'exit'
|
58
|
+
free_ingredients = cli.choose_free_ingredients
|
59
|
+
show_out_of_stock = cli.choose_show_out_of_stock
|
60
|
+
|
61
|
+
if aggression == 'hyperaggressive'
|
62
|
+
puts cli.orange("\nWARN: hyperagressive mode is RISKY AND SLOW. this will evaluate every substitution for every recipe. hammers apis violently. you will get rate limited. you will get IP blocked. her royal holiness imperva incapsula WILL get you. select if you know what all that stuff means and you are ok with waiting 20 minutes.")
|
63
|
+
end
|
64
|
+
|
65
|
+
# start searching
|
66
|
+
cli.vipiko("\n♫ let's see if #{cli.yellow category} alchemy items are profitable in #{cli.yellow region}!")
|
67
|
+
|
68
|
+
market_searcher = MarketSearcher.new(region, cli, free_ingredients)
|
69
|
+
|
70
|
+
market_item_list = market_searcher.get_alchemy_market_data category
|
71
|
+
|
72
|
+
cli.vipiko("I'll look for #{cli.yellow(market_item_list.length.to_s)} item#{
|
73
|
+
market_item_list.empty? || market_item_list.length > 1 ? 's' : ''
|
74
|
+
} in #{category == 'all' ? cli.yellow('all categories'): "the #{cli.yellow category} category"}!")
|
75
|
+
|
76
|
+
bdo_codex_searcher = BDOCodexSearcher.new(region, lang, cli, aggression == 'hyperaggressive')
|
77
|
+
|
78
|
+
item_codex_data = bdo_codex_searcher.get_item_codex_data market_item_list
|
79
|
+
|
80
|
+
recipe_prices = market_searcher.get_all_recipe_prices item_codex_data, category
|
81
|
+
|
82
|
+
mapped_prices = recipe_prices.reverse.sort_by { |recipe| recipe[:gain].to_i }.map { |recipe| recipe[:information] }
|
83
|
+
|
84
|
+
out_of_stock = recipe_prices.dig(0, :out_of_stock) || []
|
85
|
+
out_of_stock_list = ""
|
86
|
+
out_of_stock.each { |item| out_of_stock_list += "\n\t #{cli.yellow item}" }
|
87
|
+
|
88
|
+
if mapped_prices.length > 0
|
89
|
+
cli.vipiko_overwrite "done!"
|
90
|
+
puts "\n\n"
|
91
|
+
puts mapped_prices
|
92
|
+
else
|
93
|
+
cli.vipiko_overwrite "none of those recipes look profitable right now...let's go gathering!\n\n"
|
94
|
+
end
|
95
|
+
|
96
|
+
puts " items that were out of stock: #{out_of_stock_list}" if show_out_of_stock && out_of_stock_list.length > 0
|
97
|
+
rescue Interrupt => e
|
98
|
+
puts "\n\nstopping!"
|
99
|
+
end
|
92
100
|
end
|
93
101
|
end
|
94
102
|
end
|
@@ -24,13 +24,18 @@ class BDO_CODEX_UTILS
|
|
24
24
|
'products',
|
25
25
|
'all ingredients',
|
26
26
|
]
|
27
|
+
def self.acc_number_to_enhance_level(number)
|
28
|
+
%w[PRI DUO TRI TET PEN][number]
|
29
|
+
end
|
27
30
|
end
|
28
31
|
|
29
32
|
# used to retrieve items from BDOCodex
|
30
33
|
class BDOCodexSearcher
|
31
34
|
include Utils
|
32
35
|
|
36
|
+
RECIPE_PRODUCTS_INDEX = 3
|
33
37
|
RECIPE_INGREDIENTS_INDEX = 2
|
38
|
+
RECIPE_NAME_KEY = 1
|
34
39
|
|
35
40
|
def initialize(region, lang, cli, hyper_aggressive = false)
|
36
41
|
@region = region
|
@@ -41,14 +46,15 @@ class BDOCodexSearcher
|
|
41
46
|
@hyper_aggressive = hyper_aggressive
|
42
47
|
end
|
43
48
|
|
44
|
-
def get_recipe_url(url)
|
49
|
+
def get_recipe_url(url, item_name)
|
45
50
|
begin
|
46
51
|
data = HTTParty.get(
|
47
52
|
URI(url),
|
48
53
|
headers: ENVData::REQUEST_OPTS[:bdo_codex_headers],
|
49
|
-
content_type: 'application/x-www-form-urlencoded'
|
50
54
|
)
|
51
55
|
|
56
|
+
return {} unless data.length < 1_000_000
|
57
|
+
|
52
58
|
JSON.parse data unless data.body.nil? or data.body.empty?
|
53
59
|
rescue
|
54
60
|
{}
|
@@ -85,6 +91,11 @@ class BDOCodexSearcher
|
|
85
91
|
permutated_chunks = ArrayUtils.deep_permute chunked_by_substitution_groups, original_recipe_length
|
86
92
|
|
87
93
|
permutated_chunks.each do |id_list|
|
94
|
+
if id_list.uniq.length != id_list.length
|
95
|
+
all_recipe_substitutions.push recipe
|
96
|
+
next
|
97
|
+
end
|
98
|
+
|
88
99
|
recipe_with_new_items = [*recipe]
|
89
100
|
recipe_with_new_items[RECIPE_INGREDIENTS_INDEX] = [*recipe][RECIPE_INGREDIENTS_INDEX].map.with_index do |recipe_list, idx|
|
90
101
|
{ **recipe_list, id: id_list[idx] }
|
@@ -100,6 +111,8 @@ class BDOCodexSearcher
|
|
100
111
|
|
101
112
|
def parse_raw_recipe(recipe_data, item_name)
|
102
113
|
item_with_ingredients = recipe_data.dig(BDO_CODEX_UTILS::BDOCODEX_QUERY_DATA_KEY)
|
114
|
+
has_subs = false
|
115
|
+
|
103
116
|
|
104
117
|
if item_with_ingredients
|
105
118
|
recipe_with_substitute_ids = item_with_ingredients.map do |arr|
|
@@ -109,29 +122,37 @@ class BDOCodexSearcher
|
|
109
122
|
end.first
|
110
123
|
|
111
124
|
all_potion_recipes = item_with_ingredients.map do |arr|
|
125
|
+
is_m_recipe = false
|
112
126
|
mapped_item_data = arr
|
113
127
|
.filter.with_index { |_, idx | !BDO_CODEX_UTILS::RECIPE_COLUMNS[idx].nil? }
|
114
128
|
.map.with_index do |raw_element, idx|
|
115
129
|
category = BDO_CODEX_UTILS::RECIPE_COLUMNS[idx]
|
116
130
|
|
117
|
-
next if ['skill level', 'exp', '
|
131
|
+
next if ['skill level', 'exp', 'icon', 'total weight of materials'].include? category
|
118
132
|
|
119
133
|
element = Nokogiri::HTML5 raw_element.to_s
|
120
134
|
|
135
|
+
has_subs = true if element.to_s.include? 'icon-repeat'
|
136
|
+
|
121
137
|
result = {
|
122
138
|
:category => category,
|
123
139
|
:element => element
|
124
140
|
}
|
125
141
|
|
126
|
-
|
142
|
+
if %w[title id].include? category
|
143
|
+
result[:element] = element.text.downcase
|
144
|
+
end
|
127
145
|
|
128
|
-
|
146
|
+
if category == 'type'
|
147
|
+
is_m_recipe = true unless %w[cooking alchemy].include? element.text.downcase
|
148
|
+
end
|
129
149
|
|
130
150
|
if %w[materials products].include? category
|
131
151
|
quants = element.text.scan(/\](\d+)/im).map { |e| e[0].to_i }
|
152
|
+
enhance_levels = element.text.scan(/](\+?\d)/im).map { |e| e[0].to_s }
|
132
153
|
|
133
154
|
ids = element.to_s.scan(/#{@region_lang}\/item\/(\d+)/).map { |e| e[0].to_i }
|
134
|
-
result[:element] = ids.map.with_index { |id, i| { id: id, quant: quants[i]} }.flatten
|
155
|
+
result[:element] = ids.map.with_index { |id, i| { id: id, quant: quants[i] || 1, enhance_level: enhance_levels[i][0] == '+' ? enhance_levels[i].to_i : 0, is_m_recipe: is_m_recipe } }.flatten
|
135
156
|
end
|
136
157
|
|
137
158
|
result
|
@@ -145,6 +166,8 @@ class BDOCodexSearcher
|
|
145
166
|
filtered_item_data
|
146
167
|
end
|
147
168
|
|
169
|
+
return all_potion_recipes unless has_subs
|
170
|
+
|
148
171
|
get_recipe_substitutions recipe_with_substitute_ids, all_potion_recipes, item_name
|
149
172
|
end
|
150
173
|
end
|
@@ -152,21 +175,21 @@ class BDOCodexSearcher
|
|
152
175
|
def get_item_recipes(item_id, item_name)
|
153
176
|
recipe_direct_url = "https://bdocodex.com/query.php?a=recipes&type=product&item_id=#{item_id}&l=#{@region_lang}"
|
154
177
|
mrecipe_direct_url = "https://bdocodex.com/query.php?a=mrecipes&type=product&item_id=#{item_id}&l=#{@region_lang}"
|
155
|
-
|
178
|
+
houserecipe_direct_url = "https://bdocodex.com/query.php?a=designs&type=product&item_id=#{item_id}&l=#{@region_lang}"
|
156
179
|
|
157
180
|
# TODO: there MUST be a better way to determine which recipe to use, rather than just trying them both.
|
158
181
|
begin
|
159
|
-
direct_data = get_recipe_url recipe_direct_url
|
182
|
+
direct_data = get_recipe_url recipe_direct_url, item_name
|
160
183
|
|
161
184
|
if !direct_data || direct_data.empty?
|
162
|
-
mrecipe_data = get_recipe_url mrecipe_direct_url
|
185
|
+
mrecipe_data = get_recipe_url mrecipe_direct_url, item_name
|
163
186
|
|
164
187
|
return parse_raw_recipe mrecipe_data, item_name
|
165
188
|
else
|
166
189
|
parsed = parse_raw_recipe direct_data, item_name
|
167
190
|
|
168
191
|
if !parsed || parsed.empty?
|
169
|
-
mrecipe_data = get_recipe_url mrecipe_direct_url
|
192
|
+
mrecipe_data = get_recipe_url mrecipe_direct_url, item_name
|
170
193
|
|
171
194
|
return parse_raw_recipe mrecipe_data, item_name
|
172
195
|
end
|
@@ -198,7 +221,7 @@ class BDOCodexSearcher
|
|
198
221
|
cache_data = {}
|
199
222
|
|
200
223
|
# TODO: remove this check
|
201
|
-
# return unless item_name.downcase == '
|
224
|
+
# return unless item_name.downcase == 'essence of dawn - damage reduction'
|
202
225
|
|
203
226
|
unless potential_cached_recipes.to_a.empty?
|
204
227
|
recipe_to_maybe_select = potential_cached_recipes.filter { |elem| elem[1].downcase == item_name.downcase }
|
@@ -206,9 +229,33 @@ class BDOCodexSearcher
|
|
206
229
|
end
|
207
230
|
|
208
231
|
recipes = get_item_recipes item_id, item_name
|
209
|
-
cache_data[item_index] = recipes
|
210
232
|
|
211
|
-
|
233
|
+
if recipes
|
234
|
+
cache_data[item_index] = [*recipes]
|
235
|
+
has_uncacheable = recipes.index do |cache_item|
|
236
|
+
has_enhanced_ingredient = cache_item[RECIPE_INGREDIENTS_INDEX].index { |ingredient| ingredient[:enhance_level] > 0 }
|
237
|
+
|
238
|
+
has_enhanced_ingredient
|
239
|
+
end
|
240
|
+
|
241
|
+
cache_data[item_index] = recipes.map do |cache_item|
|
242
|
+
new_cache_recipes = cache_item[RECIPE_INGREDIENTS_INDEX].map do |ingredient|
|
243
|
+
{ id: ingredient[:id], quant: ingredient[:quant] }
|
244
|
+
end
|
245
|
+
new_cache_ouputs = cache_item[RECIPE_PRODUCTS_INDEX].map do |ingredient|
|
246
|
+
{ id: ingredient[:id], quant: ingredient[:quant] }
|
247
|
+
end
|
248
|
+
|
249
|
+
new_cache_item = [*cache_item]
|
250
|
+
|
251
|
+
new_cache_item[RECIPE_INGREDIENTS_INDEX] = new_cache_recipes
|
252
|
+
new_cache_item[RECIPE_PRODUCTS_INDEX] = new_cache_ouputs
|
253
|
+
|
254
|
+
new_cache_item
|
255
|
+
end
|
256
|
+
|
257
|
+
@cache.write cache_data unless has_uncacheable
|
258
|
+
end
|
212
259
|
|
213
260
|
recipes
|
214
261
|
end
|
@@ -239,10 +286,12 @@ class BDOCodexSearcher
|
|
239
286
|
@cli.vipiko_overwrite "(#{index + 1} / #{item_list.length}) let's read the recipe for #{@cli.yellow "[#{item[:name].downcase}]"}. hmm..."
|
240
287
|
|
241
288
|
stock_count = item[:total_in_stock].to_i.zero? ? item[:count].to_i : item[:total_in_stock].to_i
|
289
|
+
price = item[:price_per_one]
|
290
|
+
|
242
291
|
recipe_hash = {
|
243
292
|
name: item[:name],
|
244
293
|
recipe_list: all_recipes_for_item,
|
245
|
-
price:
|
294
|
+
price: price,
|
246
295
|
id: item[:main_key],
|
247
296
|
total_trade_count: item[:total_trade_count],
|
248
297
|
total_in_stock: stock_count,
|
@@ -23,49 +23,48 @@ module MarketSearchTools
|
|
23
23
|
name: 'black stone',
|
24
24
|
url: url,
|
25
25
|
query_string: "#{ENVData::RVT}&mainCategory=30&subCategory=1",
|
26
|
-
|
27
|
-
update: ->(data) { data['list'] }
|
26
|
+
update: ->(data) { data['marketList'] }
|
28
27
|
},
|
29
28
|
{
|
30
29
|
name: 'misc',
|
31
30
|
url: url,
|
32
31
|
query_string: "#{ENVData::RVT}&mainCategory=25&subCategory=8",
|
33
|
-
|
34
|
-
update: ->(data) { data['list'] }
|
32
|
+
update: ->(data) { data['marketList'] }
|
35
33
|
},
|
36
34
|
{
|
37
35
|
name: 'other tools',
|
38
36
|
url: url,
|
39
37
|
query_string: "#{ENVData::RVT}&mainCategory=40&subCategory=10",
|
40
|
-
|
41
|
-
update: ->(data) { data['list'] }
|
38
|
+
update: ->(data) { data['marketList'] }
|
42
39
|
},
|
43
40
|
{
|
44
41
|
name: 'blood',
|
45
42
|
url: search_url,
|
46
43
|
query_string: "#{ENVData::RVT}&searchText='s+blood",
|
47
|
-
|
44
|
+
update: ->(data) { data['list'] }
|
45
|
+
},
|
46
|
+
{
|
47
|
+
name: 'essences of dawn',
|
48
|
+
url: search_url,
|
49
|
+
query_string: "#{ENVData::RVT}&searchText=essence+of+dawn",
|
48
50
|
update: ->(data) { data['list'] }
|
49
51
|
},
|
50
52
|
{
|
51
53
|
name: 'reagent',
|
52
54
|
url: search_url,
|
53
55
|
query_string: "#{ENVData::RVT}&searchText=reagent",
|
54
|
-
# update: ->(data) { { reagentResponse: data['list'] } },
|
55
56
|
update: ->(data) { data['list'] }
|
56
57
|
},
|
57
58
|
{
|
58
59
|
name: 'oil',
|
59
60
|
url: search_url,
|
60
61
|
query_string: "#{ENVData::RVT}&searchText=oil+of",
|
61
|
-
# update: ->(data) { { oilResponse: data['list'] } },
|
62
62
|
update: ->(data) { data['list'] }
|
63
63
|
},
|
64
64
|
{
|
65
65
|
name: 'alchemy stone',
|
66
66
|
url: search_url,
|
67
|
-
query_string: "#{ENVData::RVT}&searchText=stone+of",
|
68
|
-
# update: ->(data) { { alchemyStoneResponse: data['list'] } },
|
67
|
+
query_string: "#{ENVData::RVT}&searchText=imperfect+alchemy+stone+of",
|
69
68
|
update: ->(data) { data['list'].filter { |i| i['grade'] == 0 } }
|
70
69
|
},
|
71
70
|
# {
|
@@ -75,12 +74,17 @@ module MarketSearchTools
|
|
75
74
|
# # update: ->(data) { { magicCrystalResponse: data['list'] } },
|
76
75
|
# update: ->(data) { data['list'] }
|
77
76
|
# },
|
77
|
+
{
|
78
|
+
name: 'combined crystals',
|
79
|
+
url: url,
|
80
|
+
query_string: "#{ENVData::RVT}&mainCategory=50&subCategory=4",
|
81
|
+
update: ->(data) { data['marketList'] }
|
82
|
+
},
|
78
83
|
{
|
79
84
|
name: 'offensive',
|
80
85
|
url: url,
|
81
86
|
query_string:
|
82
87
|
"#{ENVData::RVT}&mainCategory=#{CONSUMABLE_CATEGORY}&subCategory=#{CONSUMABLE_SUBCATEGORIES[:offensive]}",
|
83
|
-
# update: ->(data) { { offensiveResponse: data['marketList'] } },
|
84
88
|
update: ->(data) { data['marketList'] }
|
85
89
|
},
|
86
90
|
{
|
@@ -114,6 +118,20 @@ module MarketSearchTools
|
|
114
118
|
"#{ENVData::RVT}&mainCategory=#{CONSUMABLE_CATEGORY}&subCategory=#{CONSUMABLE_SUBCATEGORIES[:other]}",
|
115
119
|
# update: ->(data) { { otherResponse: data['marketList'] } },
|
116
120
|
update: ->(data) { data['marketList'] }
|
121
|
+
},
|
122
|
+
{
|
123
|
+
name: 'manos',
|
124
|
+
url: search_url,
|
125
|
+
query_string: "#{ENVData::RVT}&searchText=manos",
|
126
|
+
# update: ->(data) { { otherResponse: data['marketList'] } },
|
127
|
+
update: ->(data) { data['list'] }
|
128
|
+
},
|
129
|
+
{
|
130
|
+
name: 'purified lightstone',
|
131
|
+
url: search_url,
|
132
|
+
query_string: "#{ENVData::RVT}&searchText=purified+lightstone+of",
|
133
|
+
# update: ->(data) { { otherResponse: data['marketList'] } },
|
134
|
+
update: ->(data) { data['list'] }
|
117
135
|
}
|
118
136
|
]
|
119
137
|
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ExchangeItems
|
4
|
+
EXCHANGE_ITEMS = {
|
5
|
+
# magical lightstone crystal
|
6
|
+
766108 => {
|
7
|
+
# items you can exchange for this item. there are technically way more
|
8
|
+
exchange_with: [766105, 766104, 766107, 766106],
|
9
|
+
exchange_with_names: ['imperfect lightstone'],
|
10
|
+
# how many of this item would you get if you exchanged it for the item ID above
|
11
|
+
exchanging_grants: 6,
|
12
|
+
exchange_with_npc: 'dalishain',
|
13
|
+
count: Float::INFINITY,
|
14
|
+
is_npc_item: true,
|
15
|
+
name: 'Magical Lightstone Crystal',
|
16
|
+
id: 766108,
|
17
|
+
}
|
18
|
+
}.freeze
|
19
|
+
|
20
|
+
def get_exchange_item_info(item_to_exchange_id, item_to_exchange_for_id, price_of_exchange, quant_required)
|
21
|
+
exchange_info = EXCHANGE_ITEMS[item_to_exchange_for_id]
|
22
|
+
|
23
|
+
if exchange_info
|
24
|
+
if exchange_info[:exchange_with].include? item_to_exchange_id
|
25
|
+
# if a recipe requires 10 magical lightstone crystals, for example, and the exchange rate is
|
26
|
+
# 1 imperfect lightstone for 6 crystals, you'll have to buy 2 imperfect lightstones to fill
|
27
|
+
# the quant. since you can't buy a fraction of an item, we .ceil the number required.
|
28
|
+
mult = (quant_required.to_f / exchange_info[:exchanging_grants].to_f).ceil
|
29
|
+
return { **exchange_info, price: price_of_exchange * mult, must_exchange: mult }
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -8,13 +8,15 @@ require_relative '../utils/price_calculator'
|
|
8
8
|
require_relative '../utils/recipe_logger'
|
9
9
|
require_relative '../utils/npc_item_index'
|
10
10
|
require_relative './category_search_options'
|
11
|
+
require_relative './exchange_items'
|
11
12
|
|
12
13
|
# search for information on recipes in given categories
|
13
14
|
class MarketSearcher
|
14
15
|
include Utils
|
16
|
+
include ExchangeItems
|
15
17
|
include MarketSearchTools
|
16
18
|
|
17
|
-
def initialize(region, cli)
|
19
|
+
def initialize(region, cli, free_ingredients)
|
18
20
|
@root_url = ENVData.get_root_url region
|
19
21
|
@region_subdomain = CLIConstants::REGION_DOMAINS[region.to_sym].split('.')[1..].join('.')
|
20
22
|
@market_list_url = "#{@root_url}#{ENVData::WORLD_MARKET_LIST}"
|
@@ -23,6 +25,8 @@ class MarketSearcher
|
|
23
25
|
@market_sell_buy_url = "#{@root_url}#{ENVData::MARKET_SELL_BUY_INFO}"
|
24
26
|
@cli = cli
|
25
27
|
@ingredient_cache = {}
|
28
|
+
@free_ingredients = free_ingredients
|
29
|
+
@out_of_stock_items = []
|
26
30
|
end
|
27
31
|
|
28
32
|
def get_alchemy_market_data(category)
|
@@ -61,6 +65,9 @@ class MarketSearcher
|
|
61
65
|
puts
|
62
66
|
mapped_aggregate = filtered_aggregate.map.with_index do |elem, index|
|
63
67
|
|
68
|
+
# TODO: remove this check
|
69
|
+
# next unless elem['name'].downcase == 'essence of dawn - damage reduction'
|
70
|
+
|
64
71
|
@cli.vipiko_overwrite "(#{index + 1} / #{filtered_aggregate.length}) researching #{@cli.yellow elem['name'].downcase}... (category: #{subcategory})"
|
65
72
|
|
66
73
|
begin
|
@@ -129,7 +136,8 @@ class MarketSearcher
|
|
129
136
|
aggregate_response.flatten
|
130
137
|
end
|
131
138
|
|
132
|
-
|
139
|
+
# set is_recipe_ingredient = true if you're using this function to get the cost of buying an ingredient
|
140
|
+
def get_item_price_info(ingredient_id, is_recipe_ingredient = true, enhance_level = 0)
|
133
141
|
npc_item = NPCItemIndex.get_item(ingredient_id)
|
134
142
|
|
135
143
|
return npc_item if npc_item
|
@@ -145,7 +153,7 @@ class MarketSearcher
|
|
145
153
|
content_type: 'application/x-www-form-urlencoded'
|
146
154
|
)
|
147
155
|
|
148
|
-
sleep rand
|
156
|
+
sleep rand
|
149
157
|
rescue StandardError => error
|
150
158
|
puts @cli.red("this could be a network failure. get_item_price_info broke.")
|
151
159
|
|
@@ -156,12 +164,15 @@ class MarketSearcher
|
|
156
164
|
end
|
157
165
|
end
|
158
166
|
|
159
|
-
|
167
|
+
# TODO: actually implement a normal way of handling malformed results
|
168
|
+
ingredient_data = {} if ingredient_data.to_s.downcase.include? 'use a different browser'
|
169
|
+
ingredient_data = {} if ingredient_data.to_s.downcase.include? 'incapsula incident'
|
160
170
|
|
161
171
|
if ingredient_data.dig('detailList')
|
162
|
-
resolved_data = ingredient_data['detailList'][
|
172
|
+
resolved_data = ingredient_data['detailList'].find { |entry| entry['subKey'].to_i == enhance_level.to_i }
|
173
|
+
resolved_data = ingredient_data['detailList'][0] if resolved_data.nil?
|
163
174
|
unless resolved_data.nil?
|
164
|
-
body_string = "#{ENVData::RVT}&mainKey=#{resolved_data['mainKey']}&subKey
|
175
|
+
body_string = "#{ENVData::RVT}&mainKey=#{resolved_data['mainKey']}&subKey=#{enhance_level}&chooseKey=0&isUp=true&keyType=0&name=#{URI.encode_www_form_component(resolved_data['name'])}"
|
165
176
|
|
166
177
|
detailed_price_list = {}
|
167
178
|
|
@@ -172,6 +183,9 @@ class MarketSearcher
|
|
172
183
|
body: body_string,
|
173
184
|
content_type: 'application/x-www-form-urlencoded'
|
174
185
|
)
|
186
|
+
|
187
|
+
detailed_price_list = {} if detailed_price_list.to_s.downcase.include? 'incapsula incident'
|
188
|
+
|
175
189
|
sleep rand
|
176
190
|
rescue StandardError => error
|
177
191
|
puts @cli.red("this could be a network failure. get_item_price_info broke.")
|
@@ -193,53 +207,65 @@ class MarketSearcher
|
|
193
207
|
|
194
208
|
if optimal_price
|
195
209
|
if optimal_price['pricePerOne'] && optimal_price['sellCount']
|
196
|
-
{ **resolved_data, count: total_stock, pricePerOne: optimal_price['pricePerOne'] }
|
210
|
+
{ **resolved_data, count: total_stock, pricePerOne: optimal_price['pricePerOne'], enhanceLevel: enhance_level }
|
197
211
|
else
|
198
|
-
{ **resolved_data, count: total_stock, pricePerOne: resolved_data['pricePerOne'] }
|
212
|
+
{ **resolved_data, count: total_stock, pricePerOne: resolved_data['pricePerOne'], enhanceLevel: enhance_level }
|
199
213
|
end
|
200
214
|
end
|
201
215
|
end
|
202
216
|
end
|
203
|
-
|
204
217
|
end
|
205
218
|
end
|
206
219
|
|
207
220
|
def get_all_recipe_prices(item_codex_data, subcategory)
|
208
221
|
mapped_recipe_prices = []
|
209
|
-
out_of_stock_items = []
|
210
|
-
# vipiko is about to start writing carriage returns,
|
211
|
-
# so printing newline here
|
212
222
|
|
213
223
|
item_codex_data.each.with_index do |item_with_recipe, index|
|
214
224
|
potential_recipes = []
|
215
225
|
name = item_with_recipe[:name].downcase
|
216
226
|
recipe_list = item_with_recipe[:recipe_list]
|
217
227
|
|
228
|
+
# TODO: remove this line
|
229
|
+
# next unless name == 'essence of dawn - damage reduction'
|
230
|
+
|
218
231
|
@cli.vipiko_overwrite "(#{index + 1} / #{item_codex_data.length}) I'll ask a merchant about the price of ingredients for #{@cli.yellow name}!"
|
219
232
|
|
220
|
-
recipe_list.each do |
|
233
|
+
recipe_list.each do |recipe_id, recipe|
|
221
234
|
potential_recipe = []
|
222
235
|
|
223
|
-
recipe.each do |ingredient|
|
236
|
+
recipe.each.with_index do |ingredient, ing_index|
|
224
237
|
ingredient_id = ingredient['id'] ? ingredient['id'] : ingredient[:id]
|
225
238
|
quant = ingredient['quant'] ? ingredient['quant'] : ingredient[:quant]
|
239
|
+
enhance_level = ingredient['enhance_level'] ? ingredient['enhance_level'] : ingredient[:enhance_level]
|
240
|
+
is_m_recipe = ingredient['is_m_recipe'] ? ingredient['is_m_recipe'] : ingredient[:is_m_recipe]
|
241
|
+
quant = 1 if quant.nil?
|
242
|
+
enhance_level = 0 if enhance_level.nil?
|
226
243
|
|
227
|
-
if @ingredient_cache[ingredient_id]
|
228
|
-
cached_ingredient = { **@ingredient_cache[ingredient_id], quant: quant }
|
244
|
+
if @ingredient_cache[ingredient_id] && EXCHANGE_ITEMS[ingredient_id].nil? == true
|
245
|
+
cached_ingredient = { **@ingredient_cache[ingredient_id], quant: quant, for_recipe_id: recipe_id, is_m_recipe: is_m_recipe }
|
229
246
|
potential_recipe.push cached_ingredient
|
230
247
|
next
|
231
248
|
end
|
232
249
|
|
233
|
-
item_price_info_hash = get_item_price_info ingredient_id, true
|
250
|
+
item_price_info_hash = get_item_price_info ingredient_id, true, enhance_level
|
251
|
+
previous_ingredient_id = recipe.dig(ing_index - 1, :id)
|
252
|
+
previous_ingredient_price = potential_recipe.dig(ing_index - 1, :price)
|
234
253
|
|
235
|
-
|
254
|
+
if item_price_info_hash.nil?
|
255
|
+
next if previous_ingredient_id.nil? || previous_ingredient_price.nil?
|
256
|
+
exchange_info = get_exchange_item_info(previous_ingredient_id, ingredient_id, previous_ingredient_price, quant)
|
257
|
+
next unless exchange_info
|
258
|
+
item_price_info_hash = exchange_info
|
259
|
+
end
|
236
260
|
|
237
261
|
item_price_info = item_price_info_hash.transform_keys { |key|
|
238
262
|
key.to_s.gsub(/(.)([A-Z])/,'\1_\2').downcase.to_sym
|
239
263
|
}
|
240
264
|
|
241
|
-
|
242
|
-
|
265
|
+
# gathering out of stock items to show to the user
|
266
|
+
stock_string = "#{quant}x [#{item_price_info[:main_key]}] #{item_price_info[:name].downcase}"
|
267
|
+
if item_price_info[:count] < quant && !@out_of_stock_items.include?(stock_string)
|
268
|
+
@out_of_stock_items.push(stock_string)
|
243
269
|
end
|
244
270
|
|
245
271
|
stock_count = get_stock_count item_price_info
|
@@ -249,6 +275,15 @@ class MarketSearcher
|
|
249
275
|
npc_data = item_price_info if item_price_info[:is_npc_item]
|
250
276
|
price_per_one = npc_data[:price].to_i.zero? ? item_price_info[:price_per_one].to_i : npc_data[:price].to_i
|
251
277
|
|
278
|
+
if @free_ingredients.include?(item_price_info[:main_key].to_s) ||
|
279
|
+
@free_ingredients.include?(item_price_info[:id].to_s) ||
|
280
|
+
(item_price_info[:exchange_with] || []).index { |item| @free_ingredients.include? item.to_s }
|
281
|
+
price_per_one = 0
|
282
|
+
stock_count = Float::INFINITY
|
283
|
+
npc_data[:price] = 0
|
284
|
+
npc_data[:price_per_one] = 0
|
285
|
+
end
|
286
|
+
|
252
287
|
# TODO: this is ridiculous, dedupe the hash values
|
253
288
|
potential_ingredient_hash = {
|
254
289
|
name: item_price_info[:name],
|
@@ -261,13 +296,13 @@ class MarketSearcher
|
|
261
296
|
quant: quant,
|
262
297
|
price_per_one: price_per_one,
|
263
298
|
count: stock_count,
|
264
|
-
|
299
|
+
enhance_level: item_price_info[:enhance_level],
|
265
300
|
**npc_data
|
266
301
|
}
|
267
302
|
|
268
303
|
@ingredient_cache[ingredient_id] = potential_ingredient_hash
|
269
304
|
|
270
|
-
potential_recipe.push potential_ingredient_hash
|
305
|
+
potential_recipe.push({ **potential_ingredient_hash, for_recipe_id: recipe_id, is_m_recipe: is_m_recipe })
|
271
306
|
end
|
272
307
|
|
273
308
|
next unless potential_recipe.length == recipe.length
|
@@ -285,7 +320,7 @@ class MarketSearcher
|
|
285
320
|
average_procs = 1
|
286
321
|
|
287
322
|
if [25, 35].include? item[:main_category]
|
288
|
-
unless /oil of|draught|\[mix\]|\[party\]|immortal:|perfume|indignation/im.match item[:name].downcase
|
323
|
+
unless /oil of|draught|\[mix\]|\[party\]|immortal:|perfume|indignation|flame of|essence of dawn/im.match item[:name].downcase
|
289
324
|
average_procs = 2.5
|
290
325
|
end
|
291
326
|
|
@@ -299,6 +334,11 @@ class MarketSearcher
|
|
299
334
|
end
|
300
335
|
end
|
301
336
|
|
337
|
+
# 1 in 4 chance to create an imperfect alchemy stone of any specific type with the recipe
|
338
|
+
if /imperfect alchemy stone/.match(item[:name].downcase)
|
339
|
+
average_procs = 0.25
|
340
|
+
end
|
341
|
+
|
302
342
|
filtered_recipes = potential_recipes.filter do |recipe|
|
303
343
|
recipe.all? do |ingredient|
|
304
344
|
if ingredient[:total_in_stock] == Float::INFINITY
|
@@ -317,7 +357,11 @@ class MarketSearcher
|
|
317
357
|
|
318
358
|
return nil if selected_recipe.nil?
|
319
359
|
|
320
|
-
|
360
|
+
# set procs to 1 if 1 blue reagent required
|
361
|
+
average_procs = 1 if selected_recipe.find { |a| a[:name].downcase == 'blue reagent' && a[:quant] == 1 }
|
362
|
+
|
363
|
+
# set procs to 1.5 if using magical lightstone crystals to purify lightstones
|
364
|
+
average_procs = 1.5 if selected_recipe.find { |a| a[:name].downcase == 'magical lightstone crystal' }
|
321
365
|
|
322
366
|
# remove recipes where one ingredient is used twice
|
323
367
|
# this usually happens because of incorrect substitution being
|
@@ -325,8 +369,6 @@ class MarketSearcher
|
|
325
369
|
# are some seriously mysterious alchemy recipes out there...
|
326
370
|
ingredients_already_appeared = []
|
327
371
|
filtered_selected_recipe = selected_recipe.filter do |ingredient|
|
328
|
-
# set procs to 1 if blue reagent required
|
329
|
-
average_procs = 1 if ingredient[:name].downcase == 'blue reagent'
|
330
372
|
return false if ingredients_already_appeared.include? ingredient[:name]
|
331
373
|
ingredients_already_appeared.push(ingredient[:name])
|
332
374
|
true
|
@@ -347,6 +389,8 @@ class MarketSearcher
|
|
347
389
|
content_type: 'application/x-www-form-urlencoded'
|
348
390
|
)
|
349
391
|
|
392
|
+
item_price_data = {} if item_price_data.to_s.downcase.include? 'incapsula incident'
|
393
|
+
|
350
394
|
# assuming we were able to find the item price list
|
351
395
|
if item_price_data&.dig('marketConditionList')
|
352
396
|
item_market_sell_price = item_price_data['marketConditionList']&.last&.dig('pricePerOne').to_i
|
@@ -381,7 +425,6 @@ class MarketSearcher
|
|
381
425
|
# taxed profit on selling max amount of this this recipe, with
|
382
426
|
# average procs accounted for
|
383
427
|
# @type [Integer]
|
384
|
-
#
|
385
428
|
max_taxed_sell_profit_after_procs = (PriceCalculator.calculate_taxed_price(item_market_sell_price * max_potion_count) - total_max_ingredient_cost) * average_procs
|
386
429
|
|
387
430
|
return nil if max_taxed_sell_profit_after_procs.to_s.downcase == 'nan'
|
@@ -389,7 +432,8 @@ class MarketSearcher
|
|
389
432
|
|
390
433
|
recipe_logger = RecipeLogger.new @cli
|
391
434
|
results = recipe_logger.log_recipe_data(item, selected_recipe, max_potion_count, item_market_sell_price, total_ingredient_cost, average_procs, total_max_ingredient_cost, raw_max_market_sell_price, max_taxed_sell_profit_after_procs, raw_profit_with_procs, taxed_sell_profit_after_procs)
|
392
|
-
|
435
|
+
|
436
|
+
{ information: results[:recipe_info], max_profit: max_taxed_sell_profit_after_procs, gain: results[:gain], out_of_stock: @out_of_stock_items }
|
393
437
|
end
|
394
438
|
end
|
395
439
|
|
@@ -403,10 +447,6 @@ class MarketSearcher
|
|
403
447
|
item_info[:total_in_stock].to_i.zero? ? item_info[:count].to_i : item_info[:total_in_stock].to_i
|
404
448
|
end
|
405
449
|
|
406
|
-
# async def do_if_category_matches(options, &procedure)
|
407
|
-
# procedure.call.wait if options[:subcategory] == options[:subcat_to_match] || options[:all_subcategories]
|
408
|
-
# end
|
409
|
-
|
410
450
|
def do_if_category_matches(options, &procedure)
|
411
451
|
procedure.call if options[:all_subcategories]
|
412
452
|
procedure.call if options[:subcategory] == options[:subcat_to_match]
|
data/lib/utils/constants.rb
CHANGED
@@ -16,13 +16,17 @@ module Utils
|
|
16
16
|
other: 'category 35, subcategory 8',
|
17
17
|
blood: 'searches "\'s blood"',
|
18
18
|
oil: "searches 'oil of'",
|
19
|
-
'alchemy stone': "searches 'stone of'",
|
19
|
+
'alchemy stone': "searches 'imperfect alchemy stone of'",
|
20
20
|
reagent: "searches 'reagent'",
|
21
21
|
'black stone': 'category 30, subcategory 1',
|
22
22
|
'misc': 'category 25, subcategory 8',
|
23
23
|
'other tools': 'category 40, subcategory 10',
|
24
|
+
'manos': "searches 'manos'",
|
25
|
+
'purified lightstone': "searches 'purified lightstone of' (requires guru 1 alchemy)",
|
26
|
+
'combined crystals': 'category 50, subcategory 4',
|
27
|
+
'essences of dawn': "searches 'essence of dawn'",
|
24
28
|
# 'magic crystal': "searches 'magic crystal'",
|
25
|
-
exit: 'stops the search'
|
29
|
+
exit: 'stops the search',
|
26
30
|
}.freeze
|
27
31
|
|
28
32
|
REGION_DOMAINS = {
|
@@ -39,7 +43,7 @@ module Utils
|
|
39
43
|
th: 'trade.th.playblackdesert.com',
|
40
44
|
tw: 'trade.tw.playblackdesert.com',
|
41
45
|
sa: 'blackdesert-tradeweb.playredfox.com',
|
42
|
-
exit: 'stops the search'
|
46
|
+
exit: 'stops the search',
|
43
47
|
}.freeze
|
44
48
|
|
45
49
|
REGION_LANGUAGES = {
|
@@ -59,13 +63,18 @@ module Utils
|
|
59
63
|
id: 'basa indonesia',
|
60
64
|
se: 'sea english',
|
61
65
|
gl: 'global lab',
|
62
|
-
exit: 'stops the search'
|
66
|
+
exit: 'stops the search',
|
63
67
|
}.freeze
|
64
68
|
|
65
69
|
AGGRESSION_LEVELS = {
|
66
70
|
normal: 'evaluate one permutation of each recipe',
|
67
71
|
hyperaggressive: 'evaluate every substitution for every recipe',
|
68
|
-
exit: 'stops the search'
|
72
|
+
exit: 'stops the search',
|
73
|
+
}.freeze
|
74
|
+
|
75
|
+
YES_OR_NO = {
|
76
|
+
no: false,
|
77
|
+
yes: true,
|
69
78
|
}.freeze
|
70
79
|
|
71
80
|
def self.set_rvt(rvt)
|
@@ -99,13 +108,25 @@ module Utils
|
|
99
108
|
'x-cdn': 'Imperva'
|
100
109
|
},
|
101
110
|
bdo_codex_headers: {
|
102
|
-
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
103
111
|
'User-Agent':
|
104
112
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
|
105
|
-
Dnt: '1'
|
113
|
+
Dnt: '1',
|
114
|
+
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7'
|
106
115
|
}
|
107
116
|
}.freeze
|
108
117
|
|
118
|
+
# TODO: wip...
|
119
|
+
def self.get_bdo_codex_headers(item_id, region)
|
120
|
+
item_url = "https://bdocodex.com/#{region}/item/#{item_id}"
|
121
|
+
cookie_string = "bddatabaselang=#{region}"
|
122
|
+
if rand > 0.5
|
123
|
+
REQUEST_OPTS[:central_market_headers]
|
124
|
+
{ **REQUEST_OPTS[:bdo_codex_headers], 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36', Cookie: cookie_string }
|
125
|
+
else
|
126
|
+
{ **REQUEST_OPTS[:bdo_codex_headers], 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36', Cookie: cookie_string }
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
109
130
|
def self.get_central_market_headers(incap_cookie = '')
|
110
131
|
original_cookie = REQUEST_OPTS[:central_market_headers][:Cookie]
|
111
132
|
if rand > 0.5
|
data/lib/utils/npc_item_index.rb
CHANGED
@@ -155,6 +155,33 @@ module Utils
|
|
155
155
|
count: Float::INFINITY,
|
156
156
|
is_npc_item: true,
|
157
157
|
npc_type: 'blacksmith',
|
158
|
+
},
|
159
|
+
820020 => {
|
160
|
+
name: 'Fused Crystal of Emotions',
|
161
|
+
id: 820020,
|
162
|
+
price: 50_000_000,
|
163
|
+
price_per_one: 50_000_000,
|
164
|
+
count: Float::INFINITY,
|
165
|
+
is_npc_item: true,
|
166
|
+
npc_type: 'blacksmith',
|
167
|
+
},
|
168
|
+
4929 => {
|
169
|
+
name: 'Restoration Stone',
|
170
|
+
id: 4929,
|
171
|
+
price: 1_000,
|
172
|
+
price_per_one: 1_000,
|
173
|
+
count: Float::INFINITY,
|
174
|
+
is_npc_item: true,
|
175
|
+
npc_type: 'blacksmith',
|
176
|
+
},
|
177
|
+
8172 => {
|
178
|
+
name: "Combatant's Restoration Stone",
|
179
|
+
id: 8172,
|
180
|
+
price: 5_000_000,
|
181
|
+
price_per_one: 5_000_000,
|
182
|
+
count: Float::INFINITY,
|
183
|
+
is_npc_item: true,
|
184
|
+
npc_type: 'blacksmith',
|
158
185
|
},
|
159
186
|
6656 => {
|
160
187
|
name: 'Purified Water',
|
data/lib/utils/recipe_logger.rb
CHANGED
@@ -31,41 +31,66 @@ module Utils
|
|
31
31
|
formatted_price = @cli.yellow PriceCalculator.format_price ingredient_market_sell_price
|
32
32
|
formatted_max_price = @cli.yellow PriceCalculator.format_price raw_max_ingredient_sell_price
|
33
33
|
formatted_potion_amount = @cli.yellow PriceCalculator.format_num(ingredient[:quant]).rjust(4, ' ')
|
34
|
+
formatted_ingredient_id = "[#{ingredient[:id]}]".rjust(7, ' ')
|
34
35
|
formatted_max_potion_amount = @cli.yellow PriceCalculator.format_num max_potion_count * ingredient[:quant]
|
35
36
|
formatted_stock_count = @cli.yellow PriceCalculator.format_num ingredient[:total_in_stock]
|
36
37
|
formatted_npc_information = ingredient[:is_npc_item] ? @cli.yellow(" (sold by #{ingredient[:npc_type]} npcs)") : ''
|
38
|
+
if ingredient[:exchange_with_npc]
|
39
|
+
formatted_npc_information = @cli.yellow(" (exchange at least #{ingredient[:must_exchange]} #{ingredient[:exchange_with_names].join(' / ')} for #{ingredient[:exchanging_grants] * ingredient[:must_exchange]} with #{ingredient[:exchange_with_npc]})")
|
40
|
+
end
|
37
41
|
|
38
|
-
|
42
|
+
formatted_enhance_level = ['0', ''].include?(ingredient[:enhance_level].to_s) ? ' ' : " +#{ingredient[:enhance_level]} "
|
43
|
+
|
44
|
+
stock_counts.push "#{formatted_potion_amount} [max: #{formatted_max_potion_amount}] #{formatted_ingredient_id}#{@cli.yellow "#{formatted_enhance_level}#{ingredient[:name].downcase}: #{formatted_stock_count}"} in stock#{formatted_npc_information}. price: #{formatted_price} [for max: #{formatted_max_price}]"
|
39
45
|
end
|
40
46
|
|
41
47
|
market_stock_string = item[:total_in_stock] > 2000 ? @cli.red(PriceCalculator.format_num(item[:total_in_stock])) : @cli.green(PriceCalculator.format_num(item[:total_in_stock]))
|
42
48
|
|
43
49
|
trade_count_string = item[:total_trade_count] < 10_000_000 ? @cli.red(PriceCalculator.format_num(item[:total_trade_count])) : @cli.green(PriceCalculator.format_num(item[:total_trade_count]))
|
44
50
|
|
51
|
+
ratio_string = if item[:total_trade_count] / (item[:total_in_stock].zero? ? 1 : item[:total_in_stock]) < 50_000
|
52
|
+
@cli.red('bad')
|
53
|
+
else
|
54
|
+
@cli.green('good')
|
55
|
+
end
|
56
|
+
|
45
57
|
estimated_craft_time = 1.2
|
46
58
|
calculated_time = max_potion_count * estimated_craft_time
|
47
59
|
crafting_time_string = calculated_time > 21600 ? @cli.red((seconds_to_str(calculated_time))) : @cli.green((seconds_to_str(calculated_time)))
|
48
|
-
silver_per_hour = max_taxed_sell_profit_after_procs.to_f / (calculated_time.to_f / 3600)
|
60
|
+
silver_per_hour = PriceCalculator.format_price(max_taxed_sell_profit_after_procs.to_f / (calculated_time.to_f / 3600))
|
61
|
+
estimated_craft_time_string = "(accounting for average #{estimated_craft_time}s / craft due to typical server delay)"
|
62
|
+
|
63
|
+
if selected_recipe.find { |ingredient| ingredient[:is_m_recipe] == true }
|
64
|
+
crafting_time_string = @cli.yellow "unknown: this is a processing recipe!"
|
65
|
+
estimated_craft_time_string = ''
|
66
|
+
silver_per_hour = 'unknown'
|
67
|
+
end
|
68
|
+
|
69
|
+
return_on_investment = max_taxed_sell_profit_after_procs.to_f / total_max_ingredient_cost.to_f
|
70
|
+
formatted_roi = return_on_investment > 1 ? @cli.green("#{return_on_investment.round(2)}x") : @cli.red("#{return_on_investment.round(2)}x" )
|
49
71
|
|
50
72
|
information = " #{@cli.yellow "[#{item[:id]}] [#{item[:name].downcase}], recipe id: #{selected_recipe[0][:for_recipe_id]}"}
|
51
73
|
|
52
74
|
#{padstr("market price of item")}#{@cli.yellow PriceCalculator.format_price item_market_sell_price}
|
53
75
|
#{padstr("market stock of item")}#{market_stock_string}
|
54
76
|
#{padstr("total trades of item")}#{trade_count_string}
|
77
|
+
#{padstr("ratio")}#{ratio_string}
|
55
78
|
#{padstr("you can craft")}#{@cli.yellow PriceCalculator.format_num max_potion_count}
|
56
79
|
#{padstr("theoretical max output")}#{@cli.yellow PriceCalculator.format_num(max_potion_count * average_procs)}
|
57
80
|
#{padstr("cost of ingredients")}#{@cli.yellow PriceCalculator.format_price total_ingredient_cost} (accounting for average #{average_procs} / craft)
|
58
81
|
#{padstr("max cost of ingredients")}#{@cli.yellow PriceCalculator.format_price total_max_ingredient_cost} (accounting for average #{average_procs} / craft)
|
59
|
-
#{padstr("time to craft max")}#{crafting_time_string}
|
82
|
+
#{padstr("time to craft max")}#{crafting_time_string} #{estimated_craft_time_string}
|
60
83
|
|
61
84
|
\t#{stock_counts.join "\n\t"}
|
62
85
|
|
63
86
|
total income for max items before cost subtraction #{@cli.green PriceCalculator.format_price raw_max_market_sell_price}
|
64
|
-
#{padstr("total taxed silver per hour")}#{@cli.green
|
87
|
+
#{padstr("total taxed silver per hour")}#{@cli.green silver_per_hour } silver / hour
|
65
88
|
#{padstr("total untaxed profit")}#{@cli.green PriceCalculator.format_price raw_profit_with_procs} [max: #{@cli.green PriceCalculator.format_price(raw_profit_with_procs * max_potion_count)}]
|
66
89
|
#{padstr("total taxed profit")}#{@cli.green PriceCalculator.format_price taxed_sell_profit_after_procs} [max: #{@cli.green PriceCalculator.format_price(max_taxed_sell_profit_after_procs)}]
|
90
|
+
#{padstr("multiply initial investment by")}#{formatted_roi}
|
67
91
|
"
|
68
|
-
|
92
|
+
|
93
|
+
{ recipe_info: information, gain: max_taxed_sell_profit_after_procs }
|
69
94
|
end
|
70
95
|
end
|
71
96
|
end
|
data/lib/utils/user_cli.rb
CHANGED
@@ -8,51 +8,68 @@ module Utils
|
|
8
8
|
class UserCLI
|
9
9
|
attr_accessor :silent
|
10
10
|
|
11
|
+
# @param [Hash] options options from ARGV
|
11
12
|
def initialize(options)
|
12
13
|
@silent = options[:silent]
|
13
14
|
@prompt = TTY::Prompt.new
|
14
15
|
end
|
15
16
|
|
17
|
+
# uses Rainbow to make a string yellow
|
18
|
+
# @param [String] string the string to print
|
19
|
+
# @return [String]
|
16
20
|
def yellow(string)
|
17
21
|
Rainbow(string).yellow
|
18
22
|
end
|
19
23
|
|
24
|
+
# uses Rainbow to make a string orange
|
25
|
+
# @param [String] string the string to print
|
26
|
+
# @return [String]
|
20
27
|
def orange(string)
|
21
28
|
Rainbow(string).orange
|
22
29
|
end
|
23
30
|
|
31
|
+
# uses Rainbow to make a string red
|
32
|
+
# @param [String] string the string to print
|
24
33
|
def red(string)
|
25
34
|
Rainbow(string).red
|
26
35
|
end
|
27
36
|
|
37
|
+
# uses Rainbow to make a string green
|
38
|
+
# @param [String] string the string to print
|
28
39
|
def green(string)
|
29
40
|
Rainbow(string).green
|
30
41
|
end
|
31
42
|
|
32
|
-
def cyan(string)
|
33
|
-
Rainbow(string).cyan
|
34
|
-
end
|
35
|
-
|
36
43
|
# log, but cutely
|
44
|
+
# @param [String] string the string to print
|
37
45
|
def vipiko(string)
|
38
46
|
puts string unless @silent
|
39
47
|
end
|
40
48
|
|
49
|
+
# calls print on a string preceded by a line erase and carriage return
|
50
|
+
# also calls $stdout.flush
|
51
|
+
# @param [String] string the string to print
|
41
52
|
def vipiko_overwrite(string)
|
42
53
|
print "\033[2K\r#{string}"
|
43
54
|
$stdout.flush
|
44
55
|
end
|
45
56
|
|
46
57
|
# log for other stuff
|
58
|
+
# @param [String] string the string to print
|
47
59
|
def log(string)
|
48
60
|
puts string
|
49
61
|
end
|
50
62
|
|
63
|
+
# adds usage info to a tty-prompt menu
|
64
|
+
# @param menu the tty-prompt menu object in the select() block
|
65
|
+
# @param [Hash] options the hash of options
|
51
66
|
def add_menu_info(menu, options)
|
52
67
|
menu.enum '.'
|
53
68
|
menu.help "use arrow keys or numbers 1-#{options.length} to navigate, press enter to select. type to search."
|
54
69
|
end
|
55
70
|
|
71
|
+
# prompt the user to choose the category to search through
|
72
|
+
# @return [String] the selected category
|
56
73
|
def choose_category
|
57
74
|
vipiko "\n♫ hello! oh? you want to sell #{yellow 'potions'} today? that sounds like fun!\n\n"
|
58
75
|
|
@@ -66,6 +83,8 @@ module Utils
|
|
66
83
|
option.to_s
|
67
84
|
end
|
68
85
|
|
86
|
+
# prompt the user to choose the region to search within
|
87
|
+
# @return [String] the selected region
|
69
88
|
def choose_region
|
70
89
|
option = @prompt.select('and where are you in the world?', { cycle: true, filter: true }) do |menu|
|
71
90
|
add_menu_info(menu, CLIConstants::REGION_DOMAINS)
|
@@ -77,6 +96,8 @@ module Utils
|
|
77
96
|
option.to_s
|
78
97
|
end
|
79
98
|
|
99
|
+
# prompt the user to choose the language to show results in
|
100
|
+
# @return [String] the selected language
|
80
101
|
def choose_lang
|
81
102
|
option = @prompt.select('what language would you like to search bdocodex with?', { cycle: true, filter: true }) do |menu|
|
82
103
|
add_menu_info(menu, CLIConstants::REGION_LANGUAGES)
|
@@ -88,8 +109,11 @@ module Utils
|
|
88
109
|
option.to_s
|
89
110
|
end
|
90
111
|
|
112
|
+
# prompt the user to choose the aggression to search with
|
113
|
+
# higher aggression will cause all recipe permutations / item substitutions to be considered in profit calculation
|
114
|
+
# @return [String] the selected aggression level
|
91
115
|
def choose_aggression
|
92
|
-
option = @prompt.select('
|
116
|
+
option = @prompt.select('what aggression level would you like to search for recipes with?', { cycle: true, filter: true }) do |menu|
|
93
117
|
add_menu_info(menu, CLIConstants::AGGRESSION_LEVELS)
|
94
118
|
CLIConstants::AGGRESSION_LEVELS.each do |k, v|
|
95
119
|
menu.choice "#{k} #{Rainbow(v).faint.white}", k
|
@@ -99,11 +123,32 @@ module Utils
|
|
99
123
|
option.to_s
|
100
124
|
end
|
101
125
|
|
126
|
+
# prompt the user to input item ids that they already have a lot of
|
127
|
+
# the script will consider these as "free" and set their cost to zero and stock to infinite
|
128
|
+
# @return [Array] the selected item ids
|
129
|
+
def choose_free_ingredients
|
130
|
+
option = @prompt.ask('what item ids do you have a lot of already? we can consider that in the final cost calculations.', convert: :list, default: [])
|
131
|
+
|
132
|
+
option&.to_a if option&.to_a.is_a? Array
|
133
|
+
end
|
134
|
+
|
135
|
+
# prompt the user to decide whether to show or hide a list of out of stock items
|
136
|
+
# @return [Boolean] true to show out of stock items, false to hide
|
137
|
+
def choose_show_out_of_stock
|
138
|
+
option = @prompt.select('finally, would you like to see a list the ingredients that were out of stock at the end?', { cycle: true, filter: true }) do |menu|
|
139
|
+
add_menu_info(menu, CLIConstants::YES_OR_NO)
|
140
|
+
CLIConstants::YES_OR_NO.each do |k, v|
|
141
|
+
menu.choice "#{k}", v
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
!!option
|
146
|
+
end
|
147
|
+
|
148
|
+
# ends the program
|
102
149
|
def end_cli
|
103
150
|
vipiko "\nnever mind, let's do some #{yellow 'cooking'} together instead! ♫\n\n"
|
104
151
|
|
105
|
-
@prompt.keypress('(press any key to exit)')
|
106
|
-
|
107
152
|
exit
|
108
153
|
end
|
109
154
|
end
|
data/lib/version.rb
ADDED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: bdoap
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- jpegzilla
|
@@ -22,6 +22,7 @@ files:
|
|
22
22
|
- lib/bdo_alchemy_profits.rb
|
23
23
|
- lib/bdo_codex/bdo_codex_searcher.rb
|
24
24
|
- lib/central_market/category_search_options.rb
|
25
|
+
- lib/central_market/exchange_items.rb
|
25
26
|
- lib/central_market/market_searcher.rb
|
26
27
|
- lib/utils/array_utils.rb
|
27
28
|
- lib/utils/constants.rb
|
@@ -30,6 +31,7 @@ files:
|
|
30
31
|
- lib/utils/price_calculator.rb
|
31
32
|
- lib/utils/recipe_logger.rb
|
32
33
|
- lib/utils/user_cli.rb
|
34
|
+
- lib/version.rb
|
33
35
|
homepage: https://github.com/jpegzilla/bdo-alchemy-profits
|
34
36
|
licenses:
|
35
37
|
- MIT
|