Compare commits
2 commits
0e57a76ce6
...
a4dd680445
| Author | SHA1 | Date | |
|---|---|---|---|
| a4dd680445 | |||
| 10708de615 |
11 changed files with 159 additions and 116 deletions
|
|
@ -107,10 +107,10 @@
|
|||
a
|
||||
color: inherit
|
||||
|
||||
.owls-info-link
|
||||
.nc-trade-guide-info-link
|
||||
cursor: help
|
||||
|
||||
.owls-info-label
|
||||
.nc-trade-guide-info-label
|
||||
text-decoration-line: underline
|
||||
text-decoration-style: dotted
|
||||
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ class ItemsController < ApplicationController
|
|||
|
||||
# For Dyeworks items whose base is currently in the NC Mall, preload their
|
||||
# trade values. We'll use this to determine which ones are fully buyable rn
|
||||
# (because Owls tracks this data and we don't).
|
||||
# (because our NC values guide tracks this data and we don't).
|
||||
Item.preload_nc_trade_values(@items[:dyeworks])
|
||||
|
||||
# Start loading the NC trade values for the non-Mall NC items.
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ module ItemsHelper
|
|||
end
|
||||
|
||||
def nc_trade_value_updated_at_text(nc_trade_value)
|
||||
return nil if nc_trade_value.updated_at.nil?
|
||||
return "NC trade value" if nc_trade_value.updated_at.nil?
|
||||
|
||||
# Render both "[X] [days] ago", and also the exact date, only including the
|
||||
# year if it's not this same year.
|
||||
|
|
@ -160,7 +160,7 @@ module ItemsHelper
|
|||
nc_trade_value.updated_at.strftime("%b %-d") :
|
||||
nc_trade_value.updated_at.strftime("%b %-d, %Y")
|
||||
|
||||
"Last updated: #{date_str} (#{time_ago_str} ago)"
|
||||
"NC trade value—Last updated: #{date_str} (#{time_ago_str} ago)"
|
||||
end
|
||||
|
||||
NC_TRADE_VALUE_ESTIMATE_PATTERN = %r{
|
||||
|
|
@ -184,7 +184,7 @@ module ItemsHelper
|
|||
# nicely for our use case.
|
||||
def nc_trade_value_estimate_text(nc_trade_value)
|
||||
match = nc_trade_value.value_text.match(NC_TRADE_VALUE_ESTIMATE_PATTERN)
|
||||
return nc_trade_value if match.nil?
|
||||
return nc_trade_value.value_text if match.nil?
|
||||
|
||||
match => {single:, low:, high:}
|
||||
if single.present?
|
||||
|
|
@ -192,7 +192,7 @@ module ItemsHelper
|
|||
elsif low.present? && high.present?
|
||||
"#{low}–#{high} capsules"
|
||||
else
|
||||
nc_trade_value
|
||||
nc_trade_value.value_text
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -118,12 +118,11 @@ class Item < ApplicationRecord
|
|||
return @nc_trade_value if @nc_trade_value_loaded
|
||||
|
||||
@nc_trade_value = begin
|
||||
Rails.logger.debug "Item #{id} (#{name}) <lookup>"
|
||||
OwlsValueGuide.find_by_name(name)
|
||||
rescue OwlsValueGuide::NotFound => error
|
||||
LebronNCValues.find_by_name(name)
|
||||
rescue LebronNCValues::NotFound => error
|
||||
Rails.logger.debug("No NC trade value listed for #{name} (#{id})")
|
||||
nil
|
||||
rescue OwlsValueGuide::NetworkError => error
|
||||
rescue LebronNCValues::NetworkError => error
|
||||
Rails.logger.error("Couldn't load nc_trade_value: #{error.full_message}")
|
||||
nil
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ class Item
|
|||
end
|
||||
|
||||
# Whether this is a Dyeworks item whose base item can currently be purchased
|
||||
# in the NC Mall, then dyed via Dyeworks. (Owls tracks this last part!)
|
||||
# in the NC Mall, then dyed via Dyeworks. (Lebron tracks this last part!)
|
||||
def dyeworks_buyable?
|
||||
dyeworks_base_buyable? && dyeworks_dyeable?
|
||||
end
|
||||
|
|
@ -18,14 +18,14 @@ class Item
|
|||
end
|
||||
|
||||
# Whether this is a Dyeworks item that can be dyed in the NC Mall ~right now,
|
||||
# either at any time or as a limited-time event. (Owls tracks this, not us!)
|
||||
# either at any time or as a limited-time event. (Lebron tracks this, not us!)
|
||||
def dyeworks_dyeable?
|
||||
dyeworks_permanent? || dyeworks_limited_active?
|
||||
end
|
||||
|
||||
# Whether this is one of the few Dyeworks items that can be dyed in the NC
|
||||
# Mall at any time, rather than as part of a limited-time event. (Owls tracks
|
||||
# this, not us!)
|
||||
# Mall at any time, rather than as part of a limited-time event. (Lebron
|
||||
# tracks this, not us!)
|
||||
DYEWORKS_PERMANENT_PATTERN = /Permanent\s*Dyeworks/i
|
||||
def dyeworks_permanent?
|
||||
return false if nc_trade_value.nil?
|
||||
|
|
@ -33,11 +33,11 @@ class Item
|
|||
end
|
||||
|
||||
# Whether this is a Dyeworks item that can be dyed in the NC Mall ~right
|
||||
# now, as part of a limited-time event. (Owls tracks this, not us!)
|
||||
# now, as part of a limited-time event. (Lebron tracks this, not us!)
|
||||
#
|
||||
# If we aren't sure of the final date, this will still return `true`, on
|
||||
# the assumption it *is* dyeable right now and we just don't understand the
|
||||
# details of what Owls told us.
|
||||
# details of what Lebron told us.
|
||||
def dyeworks_limited_active?
|
||||
return false unless dyeworks_limited?
|
||||
return true if dyeworks_limited_final_date.nil?
|
||||
|
|
@ -51,8 +51,8 @@ class Item
|
|||
|
||||
# Whether this is a Dyeworks item that can only be dyed as part of a
|
||||
# limited-time event. (This may return true even if the end date has
|
||||
# passed, see `dyeworks_limited_active?`.) (Owls tracks this, not us!)
|
||||
DYEWORKS_LIMITED_PATTERN = /Limited\s*Dyeworks/i
|
||||
# passed, see `dyeworks_limited_active?`.) (Lebron tracks this, not us!)
|
||||
DYEWORKS_LIMITED_PATTERN = /Dyeworks\s*Thru/i
|
||||
def dyeworks_limited?
|
||||
return false if nc_trade_value.nil?
|
||||
nc_trade_value.value_text.match?(DYEWORKS_LIMITED_PATTERN)
|
||||
|
|
@ -60,9 +60,9 @@ class Item
|
|||
|
||||
# If this is a limited-time Dyeworks item, this is the date we think the
|
||||
# event will end on. Even if `dyeworks_limited?` returns true, this could
|
||||
# still be `nil`, if we fail to parse this. (Owls tracks this, not us!)
|
||||
# still be `nil`, if we fail to parse this. (Lebron tracks this, not us!)
|
||||
DYEWORKS_LIMITED_FINAL_DATE_PATTERN =
|
||||
/Dyeable\s*Thru\s*(?<month>[a-z]+)\s*(?<day>[0-9]+)/i
|
||||
/Dyeworks\s*Thru\s*(?<month>[a-z]+)\s*(?<day>[0-9]+)/i
|
||||
def dyeworks_limited_final_date
|
||||
return nil unless dyeworks_limited?
|
||||
|
||||
|
|
|
|||
50
app/services/lebron_nc_values.rb
Normal file
50
app/services/lebron_nc_values.rb
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
module LebronNCValues
|
||||
# NOTE: While we still have `updated_at` infra lying around from the Owls
|
||||
# days, the current JSON file doesn't tell us that info, so it's always
|
||||
# `nil` for now.
|
||||
Value = Struct.new(:value_text, :updated_at)
|
||||
|
||||
class Error < StandardError;end
|
||||
class NetworkError < Error;end
|
||||
class NotFound < Error;end
|
||||
|
||||
class << self
|
||||
def find_by_name(name)
|
||||
value_text = all_values[name.downcase]
|
||||
raise NotFound if value_text.nil? || value_text.strip == '-'
|
||||
|
||||
Value.new(value_text, nil)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def all_values
|
||||
Rails.cache.fetch('LebronNCValues.load_all_values', expires_in: 1.day) do
|
||||
Sync { load_all_values }
|
||||
end
|
||||
end
|
||||
|
||||
ALL_VALUES_URL = "https://lebron-values.netlify.app/item_values.json"
|
||||
def load_all_values
|
||||
begin
|
||||
DTIRequests.get(ALL_VALUES_URL) do |response|
|
||||
if response.status != 200
|
||||
raise NetworkError,
|
||||
"Lebron returned status code #{response.status} (expected 200)"
|
||||
end
|
||||
|
||||
begin
|
||||
JSON.parse(response.read)
|
||||
rescue JSON::ParserError => error
|
||||
raise NetworkError,
|
||||
"Lebron returned unsupported data format: #{error.message}"
|
||||
end
|
||||
end
|
||||
rescue Async::TimeoutError => error
|
||||
raise NetworkError, "Lebron timed out: #{error.message}"
|
||||
rescue SocketError => error
|
||||
raise NetworkError, "Could not connected to Lebron: #{error.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
module OwlsValueGuide
|
||||
include HTTParty
|
||||
|
||||
ITEMDATA_URL_TEMPLATE = Addressable::Template.new(
|
||||
"https://neo-owls.net/itemdata/{item_name}"
|
||||
)
|
||||
|
||||
def self.find_by_name(item_name)
|
||||
# Load the itemdata, pulling from the Rails cache if possible.
|
||||
cache_key = "OwlsValueGuide/itemdata/#{item_name}"
|
||||
data = Rails.cache.fetch(cache_key, expires_in: 1.day) do
|
||||
load_itemdata(item_name)
|
||||
end
|
||||
|
||||
if data == :not_found
|
||||
raise NotFound
|
||||
end
|
||||
|
||||
# Owls has records of some items that it explicitly marks as having no
|
||||
# listed value. We don't care about that distinction, just return nil!
|
||||
return nil if data['owls_value'].blank?
|
||||
|
||||
Value.new(data['owls_value'], parse_last_updated(data['last_updated']))
|
||||
end
|
||||
|
||||
Value = Struct.new(:value_text, :updated_at)
|
||||
|
||||
class Error < StandardError;end
|
||||
class NetworkError < Error;end
|
||||
class NotFound < Error;end
|
||||
|
||||
private
|
||||
|
||||
def self.load_itemdata(item_name)
|
||||
Rails.logger.info "[OwlsValueGuide] Loading value for #{item_name.inspect}"
|
||||
|
||||
url = ITEMDATA_URL_TEMPLATE.expand(item_name: item_name)
|
||||
begin
|
||||
res = get(url, headers: {
|
||||
"User-Agent" => Rails.configuration.user_agent_for_neopets,
|
||||
})
|
||||
rescue StandardError => error
|
||||
raise NetworkError, "Couldn't connect to Owls: #{error.message}"
|
||||
end
|
||||
|
||||
if res.code == 404
|
||||
# Instead of raising immediately, return `:not_found` to save this
|
||||
# result in the cache, then raise *after* we exit the cache block. That
|
||||
# way, we won't make repeat requests for items we have that Owls
|
||||
# doesn't.
|
||||
return :not_found
|
||||
end
|
||||
|
||||
if res.code != 200
|
||||
raise NetworkError, "Owls returned status code #{res.code} (expected 200)"
|
||||
end
|
||||
|
||||
begin
|
||||
res.parsed_response
|
||||
rescue HTTParty::Error => error
|
||||
raise NetworkError, "Owls returned unsupported data format: #{error.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def self.parse_last_updated(date_str)
|
||||
return nil if date_str.blank?
|
||||
|
||||
begin
|
||||
Date.strptime(date_str, '%Y-%m-%d')
|
||||
rescue Date::Error
|
||||
Rails.logger.error(
|
||||
"[OwlsValueGuide] unexpected last_updated format: #{date_str.inspect}"
|
||||
)
|
||||
return nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -33,9 +33,11 @@
|
|||
= link_to t('items.show.resources.jn_items'), jn_items_url_for(item)
|
||||
= link_to t('items.show.resources.impress_2020'), impress_2020_url_for(item)
|
||||
- if item.nc_trade_value
|
||||
= link_to t('items.show.resources.owls', value: item.nc_trade_value.value_text),
|
||||
"https://www.neopets.com/~owls",
|
||||
%span{
|
||||
title: nc_trade_value_updated_at_text(item.nc_trade_value)
|
||||
}
|
||||
= t 'items.show.resources.lebron',
|
||||
value: nc_trade_value_estimate_text(item.nc_trade_value)
|
||||
- unless item.nc?
|
||||
= link_to t('items.show.resources.shop_wizard'), shop_wizard_url_for(item)
|
||||
= link_to t('items.show.resources.trading_post'), trading_post_url_for(item)
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@
|
|||
title: "This recipe is NOT currently scheduled to be removed " +
|
||||
"from Dyeworks. It might not stay forever, but it's also " +
|
||||
"not part of a known limited-time event, like most " +
|
||||
"Dyeworks items are. (Thanks Owls team!)"
|
||||
"Dyeworks items are. (Thanks Lebron team!)"
|
||||
}
|
||||
(Always available)
|
||||
- elsif item.dyeworks_limited_final_date.present?
|
||||
|
|
@ -100,14 +100,14 @@
|
|||
title: "This recipe is part of a limited-time Dyeworks " +
|
||||
"event. The last day you can dye this is " +
|
||||
"#{item.dyeworks_limited_final_date.to_fs(:long)}. " +
|
||||
"(Thanks Owls team!)"
|
||||
"(Thanks Lebron team!)"
|
||||
}
|
||||
(Limited-time: #{item.dyeworks_limited_final_date.to_fs(:month_and_day)})
|
||||
- elsif item.dyeworks_limited?
|
||||
%span.dyeworks-timeframe{
|
||||
title: "This recipe is part of a limited-time Dyeworks " +
|
||||
"event, and is scheduled to be removed from the NC Mall " +
|
||||
"soon. (Thanks Owls team!)"
|
||||
"soon. (Thanks Lebron team!)"
|
||||
}
|
||||
(Limited-time)
|
||||
|
||||
|
|
@ -210,11 +210,9 @@
|
|||
|
||||
- if @items[:other_nc].any?(&:nc_trade_value)
|
||||
:markdown
|
||||
The [Owls Value Guide][owls] often has more details about how to get
|
||||
The "Lebron NC Values" team keep track of the details about how to get
|
||||
these items, and how much they're usually worth in the NC Trading
|
||||
community. We've loaded their info here for you, too! Thanks, Owls team!
|
||||
|
||||
[owls]: https://www.neopets.com/~owls
|
||||
community. We've loaded their info here for you, too! Thanks, Lebron team!
|
||||
|
||||
%table.item-list{"data-complexity": complexity_for(@items[:other_nc])}
|
||||
%thead
|
||||
|
|
@ -227,17 +225,17 @@
|
|||
- content_for :subtitle, flush: true do
|
||||
- if item.nc_trade_value.present?
|
||||
- if nc_trade_value_is_estimate(item.nc_trade_value)
|
||||
= link_to "https://www.neopets.com/~owls",
|
||||
class: "owls-info-link", target: "_blank",
|
||||
title: 'Owls keeps track of approximate "capsule" values of NC items for trading. Items with similar values can often be traded for one another. This is an estimate, not a rule!' do
|
||||
%span.owls-info-label [Owls]
|
||||
= content_tag :span,
|
||||
class: "nc-trade-guide-info-link",
|
||||
title: 'Lebron keeps track of approximate "capsule" values of NC items for trading. Items with similar values can often be traded for one another. This is an estimate, not a rule!' do
|
||||
%span.nc-trade-guide-info-label [Lebron]
|
||||
Estimated value:
|
||||
= nc_trade_value_estimate_text(item.nc_trade_value)
|
||||
- else
|
||||
= link_to "https://www.neopets.com/~owls",
|
||||
class: "owls-info-link", target: "_blank",
|
||||
title: "Owls keeps track of how to get certain special items, even when there isn't a clear NC trade estimate." do
|
||||
%span.owls-info-label [Owls]
|
||||
= content_tag :span,
|
||||
class: "nc-trade-guide-info-link",
|
||||
title: "Lebron keeps track of how to get certain special items, even when there isn't a clear NC trade estimate." do
|
||||
%span.nc-trade-guide-info-label [Lebron]
|
||||
Trade info:
|
||||
#{item.nc_trade_value.value_text}
|
||||
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ en:
|
|||
resources:
|
||||
jn_items: JN Items
|
||||
impress_2020: DTI 2020
|
||||
owls: "Owls: %{value}"
|
||||
lebron: "Lebron: %{value}"
|
||||
shop_wizard: Shop Wizard
|
||||
trading_post: Trades
|
||||
auction_genie: Auctions
|
||||
|
|
|
|||
71
spec/services/lebron_nc_values_spec.rb
Normal file
71
spec/services/lebron_nc_values_spec.rb
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
require 'webmock/rspec'
|
||||
require_relative '../rails_helper'
|
||||
|
||||
RSpec.describe LebronNCValues, type: :model do
|
||||
describe ".find_by_name" do
|
||||
def stub_page_request
|
||||
stub_request(:get, "https://lebron-values.netlify.app/item_values.json").
|
||||
with(
|
||||
headers: {
|
||||
"User-Agent": Rails.configuration.user_agent_for_neopets,
|
||||
},
|
||||
)
|
||||
end
|
||||
|
||||
context "when loading" do
|
||||
before do
|
||||
stub_page_request.to_return(
|
||||
body: '{"10th birthday balloon garland": "-", "1 large neocola with ice": "1-2", "air faerie bubble necklace": "Permanent Buyable"}'
|
||||
)
|
||||
end
|
||||
|
||||
context "a standard item value" do
|
||||
subject(:value) { LebronNCValues.find_by_name("1 Large Neocola with Ice") }
|
||||
it("loads value text as-is") { expect(value[:value_text]).to eq("1-2") }
|
||||
end
|
||||
|
||||
context "a more complex item value" do
|
||||
subject(:value) { LebronNCValues.find_by_name("Air Faerie Bubble Necklace") }
|
||||
it("loads value text as-is") { expect(value[:value_text]).to eq("Permanent Buyable") }
|
||||
end
|
||||
|
||||
context "a completely unknown item value" do
|
||||
subject(:value) { LebronNCValues.find_by_name("Floating Negg Faerie Doll") }
|
||||
it("raises NotFound") { expect { value }.to raise_error(LebronNCValues::NotFound) }
|
||||
end
|
||||
|
||||
context "a known blank item value" do
|
||||
subject(:value) { LebronNCValues.find_by_name("10th Birthday Balloon Garland") }
|
||||
it("raises NotFound") { expect { value }.to raise_error(LebronNCValues::NotFound) }
|
||||
end
|
||||
end
|
||||
|
||||
context "when the request fails" do
|
||||
before { stub_page_request.to_return(status: 404, body: "{}") }
|
||||
|
||||
subject(:value) { LebronNCValues.find_by_name("1 Large Neocola with Ice") }
|
||||
it("raises NetworkError") { expect { value }.to raise_error(LebronNCValues::NetworkError) }
|
||||
end
|
||||
|
||||
context "when the response is malformed" do
|
||||
before { stub_page_request.to_return(body: "Hello, world!") }
|
||||
|
||||
subject(:value) { LebronNCValues.find_by_name("1 Large Neocola with Ice") }
|
||||
it("raises NetworkError") { expect { value }.to raise_error(LebronNCValues::NetworkError) }
|
||||
end
|
||||
|
||||
context "when the request times out" do
|
||||
before { stub_page_request.to_timeout }
|
||||
|
||||
subject(:value) { LebronNCValues.find_by_name("1 Large Neocola with Ice") }
|
||||
it("raises NetworkError") { expect { value }.to raise_error(LebronNCValues::NetworkError) }
|
||||
end
|
||||
|
||||
context "when the request fails to connect" do
|
||||
before { stub_page_request.to_raise(Socket::ResolutionError) }
|
||||
|
||||
subject(:value) { LebronNCValues.find_by_name("1 Large Neocola with Ice") }
|
||||
it("raises NetworkError") { expect { value }.to raise_error(LebronNCValues::NetworkError) }
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue